import argparse
import json
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, Iterable, List, Optional, Tuple

from tqdm.auto import tqdm

try:
    from openai import OpenAI  # type: ignore
except ImportError:
    OpenAI = None  # type: ignore


BatchItem = Dict[str, Any]
ModelResult = Dict[str, Any]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Infer original source track (artists and title) for remixes using "
            "the OpenAI API in batched requests."
        )
    )
    parser.add_argument(
        "--input",
        required=False,
        help="Path to input JSONL file containing remix metadata.",
    )
    parser.add_argument(
        "--output",
        required=False,
        help="Path to output JSONL file with enriched metadata.",
    )
    parser.add_argument(
        "--model",
        default="gpt-4.1-mini",
        help="OpenAI model name to use (default: gpt-4.1-mini).",
    )
    parser.add_argument(
        "--batch-size",
        type=int,
        default=50,
        help="Number of rows to send per OpenAI request (default: 50).",
    )
    parser.add_argument(
        "--max-rows",
        type=int,
        default=None,
        help="Optional maximum number of rows to process (for testing).",
    )
    parser.add_argument(
        "--rpm",
        type=float,
        default=None,
        help="Optional max requests per minute (simple rate limiting).",
    )
    parser.add_argument(
        "--max-retries",
        type=int,
        default=3,
        help="Maximum number of retries for a failed OpenAI request.",
    )
    parser.add_argument(
        "--num-workers",
        type=int,
        default=1,
        help="Number of concurrent OpenAI batch requests to run (default: 1).",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help=(
            "Run a small in-memory example batch and print results instead of "
            "reading/writing files."
        ),
    ) 
    return parser.parse_args()


def get_openai_api_key() -> str:
    """
    Fetch the OpenAI API key.

    By default, this reads from the OPENAI_API_KEY environment variable.
    You can also hard-code a fallback here if you prefer, e.g.:

        api_key = os.environ.get("OPENAI_API_KEY") or "sk-..."

    For security, it's recommended to keep using the environment variable.
    """
    api_key = os.environ.get(
        "OPENAI_API_KEY",
        "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",  # noqa: E501
    )
    if not api_key:
        raise RuntimeError(
            "OPENAI_API_KEY environment variable is not set. "
            "Please export it before running this script."
        )
    return api_key


def ensure_openai_initialized(api_key: str) -> None:
    if OpenAI is None:
        raise RuntimeError(
            "The 'openai' Python package is not installed. "
            "Install it with `pip install openai`."
        )


def iter_jsonl(
    path: str, max_rows: Optional[int] = None
) -> Iterable[Tuple[int, Dict[str, Any]]]:
    with open(path, "r", encoding="utf-8") as f:
        for idx, line in enumerate(f):
            if max_rows is not None and idx >= max_rows:
                break
            line = line.strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
            except json.JSONDecodeError as e:
                print(
                    f"Skipping malformed JSON on line {idx + 1}: {e}",
                    file=sys.stderr,
                )
                continue
            yield idx, obj


def write_jsonl(path: str, rows: Iterable[Dict[str, Any]]) -> None:
    with open(path, "w", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")


def build_batch_items(
    rows: List[Tuple[int, Dict[str, Any]]]
) -> List[BatchItem]:
    """
    Build the list of batch items sent to the model for remix parsing.

    Each item contains a subset of metadata fields that help identify the
    original source track for the remix.
    """
    batch: List[BatchItem] = []
    for global_idx, row in rows:
        item: BatchItem = {"id": global_idx}

        # Core textual metadata.
        item.update(
            {
                "title": row.get("title"),
                "artists": row.get("artists"),
                "album_name": row.get("album_name"),
                "label": row.get("label"),
                "genre": row.get("genre"),
                "bpm": row.get("bpm"),
                "key": row.get("key"),
                "duration": row.get("duration"),
                "release_date": row.get("release_date"),
                "data_source": row.get("data_source"),
                "existing_source_artists": row.get("source_artists"),
                "existing_source_title": row.get("source_title"),
                "remix_confidence": row.get("remix_confidence"),
                "output_id": row.get("output_id"),
                "source_id": row.get("source_id"),
                "source_votes": row.get("source_votes"),
                "s3_filepath": row.get("s3_filepath"),
            }
        )

        batch.append(item)
    return batch


def build_system_prompt() -> str:
    return (
        "You are an expert at parsing noisy remix metadata and at recognizing"
        " songs and artists from incomplete or corrupted information. Given a"
        " batch of remix metadata items, you must infer the original source"
        " song that is being remixed.\n\n"
        "Rules:\n"
        "- For each item, identify the single original source song and its"
        " artist or artists. Most items correspond to a remix of exactly one"
        " track.\n"
        "- Use whatever metadata is present, including title, artists, album"
        " name, label, genre, BPM, key, duration, release date, and data"
        " source.\n"
        "- Use your knowledge of real-world music (including underground,"
        " indie, DJ/producer scenes, and remix culture) to infer the most"
        " likely original track even when titles or names are missing,"
        " truncated, translated, or misspelled. Correct obvious typos and"
        " normalize aliases and stylizations to canonical artist and track"
        " names when possible.\n"
        "- Prefer artist–song combinations that you know (or strongly expect)"
        " actually exist over unlikely or inconsistent ones, and avoid"
        " inventing obviously fake artists or titles. When multiple songs"
        " could match, choose the most musically and historically plausible"
        " option given genre, era, and context, and briefly note your"
        " reasoning in parsing_notes.\n"
        "- Do not automatically assume that the listed remix artist is the"
        " original source artist; many remixes are created by third-party"
        " producers or DJs.\n"
        "- In many cases, the original artist appears in the `artists` field"
        " alongside the remixer (for example: 'Original Artist - Track Name"
        " (Remixer Name Remix)'). When the formatting clearly separates"
        " original vs remixer, treat the original performer as the"
        " source_artist and the remixer as a non-source contributor.\n"
        "- If the listed artist is clearly the performer of the original"
        " track (for example, a self-remix or when only one artist is"
        " mentioned), treat them as a source artist.\n"
        "- Always return source_artists as an array of one or more strings and"
        " source_title as a single string. If you know a title but not the"
        " artist (or vice versa), fill the unknown entry with the string"
        ' \"unknown\" and explain the uncertainty in parsing_notes.\n'
        "- Respond ONLY with valid JSON. Do not include natural-language text,"
        " backticks, comments, or any content outside the JSON.\n"
        "- The entire response MUST be a single JSON array with one object per"
        " input item.\n"
        "- Each object must have keys: id, source_artists, source_title,"
        " parsing_confidence, parsing_notes.\n"
        "- id must exactly match the id field of the corresponding input"
        " item.\n"
        "- parsing_confidence is a float between 0 and 1 representing your"
        " overall confidence in the correctness of the inferred source track.\n"
    )


def build_user_prompt(batch_items: List[BatchItem]) -> str:
    return json.dumps(
        {
            "description": (
                "Input is a list of remix metadata items. For each item, infer "
                "the original source song (title and artist(s)) being remixed. "
                "Return a JSON array of the same length as the input list, "
                "with one object per item following the specified schema."
            ),
            "items": batch_items,
            "expected_output_schema": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "id": {"type": "integer"},
                        "source_artists": {
                            "type": "array",
                            "items": {"type": "string"},
                        },
                        "source_title": {"type": "string"},
                        "parsing_confidence": {
                            "type": "number",
                            "description": (
                                "Float in [0, 1] representing overall "
                                "confidence in the correctness of the "
                                "inferred source track."
                            ),
                        },
                        "parsing_notes": {
                            "type": "string",
                            "description": (
                                "Free-text explanation of how the source was "
                                "inferred, including any uncertainties or "
                                "assumptions."
                            ),
                        },
                    },
                    "required": [
                        "id",
                        "source_artists",
                        "source_title",
                        "parsing_confidence",
                        "parsing_notes",
                    ],
                },
            },
        },
        ensure_ascii=False,
    )


def call_openai_for_batch(
    batch_items: List[BatchItem],
    model: str,
    client: Any,
    max_retries: int = 3,
    rpm: Optional[float] = None,
    last_request_ts: Optional[float] = None,
) -> Tuple[List[ModelResult], float]:
    if not batch_items:
        return [], last_request_ts or time.time()

    system_prompt = build_system_prompt()
    user_prompt = build_user_prompt(batch_items)

    attempt = 0
    while True:
        attempt += 1

        # Simple rate limiting based on requests-per-minute.
        if rpm is not None and last_request_ts is not None:
            min_interval = 60.0 / max(rpm, 1e-6)
            elapsed = time.time() - last_request_ts
            if elapsed < min_interval:
                time.sleep(min_interval - elapsed)

        try:
            completion = client.chat.completions.create(
                model=model,
                messages=[
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_prompt},
                ],
                temperature=0,
            )
            content = completion.choices[0].message.content or ""
            try:
                parsed = json.loads(content)
            except json.JSONDecodeError:
                # Sometimes the model may include extra text around the JSON.
                # Try to recover by extracting the first JSON array substring.
                stripped = content.strip()
                start = stripped.find("[")
                end = stripped.rfind("]")
                if start != -1 and end != -1 and end > start:
                    candidate = stripped[start : end + 1]
                    parsed = json.loads(candidate)
                else:
                    raise
            if not isinstance(parsed, list):
                raise ValueError("Model response is not a JSON array.")
            results: List[ModelResult] = []
            for item in parsed:
                if not isinstance(item, dict):
                    continue
                results.append(item)
            return results, time.time()
        except Exception as e:  # noqa: BLE001
            print(
                f"OpenAI call failed on attempt {attempt}/{max_retries}: {e}",
                file=sys.stderr,
            )
            if attempt >= max_retries:
                # Fallback: generate default low-confidence results for each item.
                fallback_results: List[ModelResult] = []
                for bi in batch_items:
                    fallback_results.append(
                        {
                            "id": bi.get("id"),
                            "source_artists": [],
                            "source_title": "",
                            "parsing_confidence": 0.0,
                            "parsing_notes": (
                                "Failed to parse via OpenAI API after retries; "
                                "no structured data extracted."
                            ),
                        }
                    )
                return fallback_results, time.time()
            # Exponential backoff before retrying.
            sleep_secs = min(2**attempt, 30)
            time.sleep(sleep_secs)


def merge_results_into_rows(
    batch_rows: List[Tuple[int, Dict[str, Any]]],
    results: List[ModelResult],
) -> List[Dict[str, Any]]:
    result_by_id: Dict[Any, ModelResult] = {r.get("id"): r for r in results}
    enriched_rows: List[Dict[str, Any]] = []
    for global_idx, row in batch_rows:
        r = result_by_id.get(global_idx)
        if r is None:
            # Default when model did not return anything for this id.
            row["parsing_confidence"] = 0.0
            row["parsing_notes"] = "No result returned for this row id."
        else:
            source_artists = r.get("source_artists") or []
            if not isinstance(source_artists, list):
                source_artists = [str(source_artists)]
            row["source_artists"] = [str(a) for a in source_artists]

            source_title = r.get("source_title")
            if source_title is None:
                source_title = ""
            row["source_title"] = str(source_title)

            # Ensure the confidence is a float in [0, 1] if possible.
            conf = r.get("parsing_confidence")
            try:
                conf_f = float(conf)
            except (TypeError, ValueError):
                conf_f = 0.0
            if conf_f < 0.0:
                conf_f = 0.0
            if conf_f > 1.0:
                conf_f = 1.0

            notes = (r.get("parsing_notes") or "").strip()

            row["parsing_confidence"] = conf_f
            row["parsing_notes"] = notes

        enriched_rows.append(row)
    return enriched_rows


def process_file(
    input_path: str,
    output_path: str,
    model: str,
    batch_size: int,
    max_rows: Optional[int],
    rpm: Optional[float],
    max_retries: int,
    num_workers: int,
) -> None:
    api_key = get_openai_api_key()
    ensure_openai_initialized(api_key)
    client = OpenAI(api_key=api_key)

    # If only one worker is requested, use a sequential batching logic.
    if num_workers <= 1:
        last_request_ts: Optional[float] = None
        batch_rows: List[Tuple[int, Dict[str, Any]]] = []
        enriched_by_idx: Dict[int, Dict[str, Any]] = {}
        total_rows = 0
        openai_rows = 0
        num_batches = 0

        for global_idx, row in tqdm(
            iter_jsonl(input_path, max_rows=max_rows),
            desc="Processing rows",
            unit="row",
        ):
            total_rows += 1
            # Use sources_parsed and data_source as gates for OpenAI calls.
            if row.get("sources_parsed"):
                enriched_by_idx[global_idx] = row
                continue

            batch_rows.append((global_idx, row))
            openai_rows += 1
            if len(batch_rows) >= batch_size:
                num_batches += 1
                batch_items = build_batch_items(batch_rows)
                results, last_request_ts = call_openai_for_batch(
                    batch_items=batch_items,
                    model=model,
                    client=client,
                    max_retries=max_retries,
                    rpm=rpm,
                    last_request_ts=last_request_ts,
                )
                merged_rows = merge_results_into_rows(batch_rows, results)
                for (idx, _), enriched in zip(batch_rows, merged_rows):
                    enriched_by_idx[idx] = enriched
                batch_rows = []

        # Process any remaining rows.
        if batch_rows:
            num_batches += 1
            batch_items = build_batch_items(batch_rows)
            results, last_request_ts = call_openai_for_batch(
                batch_items=batch_items,
                model=model,
                client=client,
                max_retries=max_retries,
                rpm=rpm,
                last_request_ts=last_request_ts,
            )
            merged_rows = merge_results_into_rows(batch_rows, results)
            for (idx, _), enriched in zip(batch_rows, merged_rows):
                enriched_by_idx[idx] = enriched

        # Write rows ordered by original index.
        ordered_indices = sorted(enriched_by_idx.keys())
        enriched_rows_out = [enriched_by_idx[i] for i in ordered_indices]
        write_jsonl(output_path, enriched_rows_out)
        print(
            "Finished processing.\n"
            f"  Total rows read: {total_rows}\n"
            f"  Rows sent to OpenAI: {openai_rows}\n"
            f"  OpenAI batches: {num_batches}\n"
            f"  Output written to: {output_path}",
            file=sys.stderr,
        )
        return

    # Parallel mode: use a thread pool to run multiple batch requests concurrently.
    batch_rows: List[Tuple[int, Dict[str, Any]]] = []
    enriched_by_idx: Dict[int, Dict[str, Any]] = {}
    total_rows = 0
    openai_rows = 0
    num_batches = 0
    futures: List[Any] = []

    with ThreadPoolExecutor(max_workers=num_workers) as executor:
        # First pass: read rows and submit OpenAI batch requests.
        for global_idx, row in iter_jsonl(input_path, max_rows=max_rows):
            total_rows += 1

            if row.get("sources_parsed"):
                enriched_by_idx[global_idx] = row
                continue

            batch_rows.append((global_idx, row))
            openai_rows += 1
            if len(batch_rows) >= batch_size:
                num_batches += 1
                local_batch = batch_rows
                batch_rows = []
                batch_items = build_batch_items(local_batch)
                future = executor.submit(
                    call_openai_for_batch,
                    batch_items=batch_items,
                    model=model,
                    client=client,
                    max_retries=max_retries,
                    rpm=None,  # rpm limiting disabled in parallel mode
                    last_request_ts=None,
                )
                futures.append((future, local_batch))

        # Process any remaining rows.
        if batch_rows:
            num_batches += 1
            local_batch = batch_rows
            batch_rows = []
            batch_items = build_batch_items(local_batch)
            future = executor.submit(
                call_openai_for_batch,
                batch_items=batch_items,
                model=model,
                client=client,
                max_retries=max_retries,
                rpm=None,
                last_request_ts=None,
            )
            futures.append((future, local_batch))

        # Collect results from all futures, tracking progress as batches complete.
        with tqdm(
            total=openai_rows,
            desc=f"Processing OpenAI batches (workers={num_workers})",
            unit="row",
        ) as pbar:
            for future, local_batch in futures:
                results, _ = future.result()
                merged_rows = merge_results_into_rows(local_batch, results)
                for (idx, _), enriched in zip(local_batch, merged_rows):
                    enriched_by_idx[idx] = enriched
                pbar.update(len(local_batch))

    # Write rows ordered by original index.
    ordered_indices = sorted(enriched_by_idx.keys())
    enriched_rows_out = [enriched_by_idx[i] for i in ordered_indices]
    write_jsonl(output_path, enriched_rows_out)
    print(
        "Finished processing.\n"
        f"  Total rows read: {total_rows}\n"
        f"  Rows sent to OpenAI: {openai_rows}\n"
        f"  OpenAI batches: {num_batches}\n"
        f"  OpenAI workers: {num_workers}\n"
        f"  Output written to: {output_path}",
        file=sys.stderr,
    )


def run_dry_run(
    model: str,
    batch_size: int,
    rpm: Optional[float],
    max_retries: int,
) -> None:
    """
    Run a small synthetic example batch for remix parsing.
    """
    api_key = get_openai_api_key()
    ensure_openai_initialized(api_key)
    client = OpenAI(api_key=api_key)

    example_rows: List[Tuple[int, Dict[str, Any]]] = []

    # Example remix row.
    example_rows.append(
        (
            0,
            {
                "title": "Artist X - Song Y (Artist Z Remix)",
                "artists": ["Artist Z"],
                "album_name": None,
                "genre": "electronic, house, remix",
                "key": None,
                "bpm": 124,
                "label": "Example Label",
                "release_date": "2020-01-01",
                "duration": 360,
                "data_source": "dry_run_example_remix",
                "source_artists": None,
                "source_title": None,
                "remix_confidence": None,
                "sources_parsed": False,
                "output_id": None,
                "source_id": None,
                "source_votes": None,
                "id": "example-0",
                "s3_filepath": None,
            },
        )
    )

    batch_items = build_batch_items(example_rows)
    results, _ = call_openai_for_batch(
        batch_items=batch_items,
        model=model,
        client=client,
        max_retries=max_retries,
        rpm=rpm,
        last_request_ts=None,
    )
    enriched = merge_results_into_rows(example_rows, results)
    print(json.dumps(enriched, ensure_ascii=False, indent=2))


def main() -> None:
    args = parse_args()

    if args.dry_run:
        if args.input or args.output:
            print(
                "Warning: --dry-run ignores --input/--output and uses "
                "in-memory examples only.",
                file=sys.stderr,
            )
        run_dry_run(
            model=args.model,
            batch_size=args.batch_size,
            rpm=args.rpm,
            max_retries=args.max_retries,
        )
        return

    if not args.input or not args.output:
        print(
            "Error: --input and --output are required unless --dry-run is specified.",
            file=sys.stderr,
        )
        sys.exit(1)

    process_file(
        input_path=args.input,
        output_path=args.output,
        model=args.model,
        batch_size=args.batch_size,
        max_rows=args.max_rows,
        rpm=args.rpm,
        max_retries=args.max_retries,
        num_workers=args.num_workers,
    )


if __name__ == "__main__":
    main()


