#!/usr/bin/env python3
"""ASR Evaluation Script

Evaluates ASR model performance by calculating WER on audio samples.

Example call:
    CUDA_VISILBE_DEVICES=6 python scripts/asr_eval.py --n_songs 100 --sweep-cfg 1,1.5,2,3 --output sweep/eval.jsonl
"""

import argparse
import json
import os
import re
from pathlib import Path
from typing import Iterator, Literal, Optional
from collections import defaultdict
import tempfile
import io

import torch
import modal
from jiwer import wer
from openai import OpenAI
from google import genai
from google.genai import types

from suno_utils.audio import Audio
from suno_utils.diffusion.generation import encode_semantic
from suno_utils.gpt.bct.bct import Block, BlockType
from suno_utils.gpt.bct.bct_generation_simple import (
    BCTGenerationConfig,
    BlockSequence,
    generate_block,
)
from suno_utils.gpt.generation import GPT, load_model
from suno_utils.tasks.mert_25 import preload_models as preload_semantic_models_
from suno_utils.utils.s3 import download_s3_file_if_needed

import langcodes

# Constants
MAX_DURATION = 300  # 5 minutes
MIN_DURATION = 5  # 5 seconds

# Global model cache
_cached_model_container = None
_cached_cluster_model = None

TextBlockType = BlockType(
    name="text",
    is_causal=True,
)
NonCausalSemanticBlockType = BlockType(
    name="continuous_semantic",
    is_causal=False,
)


def get_lang_category(lang_str):
    lang_str = lang_str.lower()
    try:
        # Normalize to ISO 639-1 code (e.g., "english" -> "en", "japanese" -> "ja")
        lang_code = langcodes.find(lang_str).language
    except:
        # If can't normalize, use as-is
        lang_code = lang_str

    print(f"lang_code: {lang_code}")
    if lang_code in ["zh", "ja", "th", "lo", "my"]:  # Chinese, Japanese, Thai, Lao, Burmese
        return "space-less"  # space-less languages
    else:
        return "space-delimited"


def calculate_wer(target_text: str, generated_text: str, lang: str) -> float:
    """Calculate Word Error Rate (WER) between target and generated text.

    Normalizes text by removing brackets, punctuation, and lowercasing.

    Args:
        target_text: Reference/ground truth text
        generated_text: Hypothesis/generated text

    Returns:
        WER as a float (0.0 = perfect match, higher = more errors)
    """

    def normalize(text: str, lang: str) -> str:
        # Insert a space between every character for space-less languages
        if get_lang_category(lang) == "space-less":
            text = " ".join(text)
        # Remove everything inside square brackets
        text = re.sub(r"\[.*?\]", "", text)
        # Remove everything inside parentheses
        text = re.sub(r"\(.*?\)", "", text)
        # Lowercase
        text = text.lower()
        # Remove filler words: hmm, mm, mhm, mmm, uh, um (as whole words)
        text = re.sub(r"\b(hmm|mm|mhm|mmm|uh|um)\b", "", text)
        # Remove punctuation
        text = re.sub(r"[^\w\s]", "", text)
        # Collapse multiple spaces
        text = re.sub(r"\s+", " ", text)
        # Strip leading/trailing whitespace
        text = text.strip()
        return text

    target_clean = normalize(target_text, lang)
    generated_clean = normalize(generated_text, lang)

    return wer(target_clean, generated_clean)


def read_jsonl_filtered(
    filepath: Path | str,
    limit: int | None = None,
) -> Iterator[dict]:
    """Read a JSONL file line-by-line, filtering by duration.

    Args:
        filepath: Path to the JSONL file
        limit: Optional maximum number of entries to yield

    Yields:
        Parsed JSON objects that pass the filter
    """
    count = 0
    with open(filepath, "r") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue

            data = json.loads(line)

            if data.get("lang") is None:
                print(f"Skipping {data.get('id')} because lang is None")
                continue
            if data["duration_s"] > MAX_DURATION or data["duration_s"] < MIN_DURATION:
                continue

            yield data

            if limit is not None:
                count += 1
                if count >= limit:
                    break


def asr_custom(audio: Audio, gpt_model_path: Optional[str] = None, cfg_scale: float = 1.0) -> str:
    """Perform ASR on audio using custom model.

    Loads the model internally on first call.

    Args:
        audio: Audio object to transcribe
        gpt_model_path: Path to GPT model checkpoint
        cfg_scale: Classifier-free guidance scale

    Returns:
        Generated text transcription
    """
    global _cached_model_container, _cached_cluster_model

    # Load models only on first call
    if _cached_model_container is None:
        print("Loading models for the first time...")
        gpt_model_path = (
            gpt_model_path or "/app2/suno/checkpoints/2025-09-25_18-16-42/last_ckpt_infer.pt"
        )
        tokenizer_path = "s3://suno-data/georg/models/tokenizers/tokenizer_60k.json"

        _cached_model_container = load_model(
            ckpt_path=download_s3_file_if_needed(gpt_model_path),
            tokenizer_path=download_s3_file_if_needed(tokenizer_path),
        )

        _cached_cluster_model = preload_semantic_models_(
            checkpoint_filepath="s3://suno-data/georg/models/semantic/mert_25.pt",
            centroids_filepath="s3://suno-data/georg/models/semantic/mert_25_2x4k.npy",
        )["cluster_model"].cpu()
        print("Models loaded and cached.")

    model_container = _cached_model_container
    model: GPT = model_container["model"]
    cfg = model.config

    codes = torch.from_numpy(
        encode_semantic(audio, pad_to_chunksize=True, batch_size=48, do_clustering=False).T
    ).unsqueeze(0)

    sem_block = Block(
        NonCausalSemanticBlockType,
        inputs={
            "continuous_semantic_input": codes.to(torch.bfloat16),
        },
    )
    prompt_text = "[no tags]"
    text_codes = [cfg.text_infer_token] + model_container["tokenizer"].encode(prompt_text)

    text_block = Block(
        TextBlockType,
        inputs={
            "text_input": torch.tensor(text_codes).reshape(1, 1, -1),
        },
    )
    blocks = BlockSequence([sem_block, text_block])
    no_sem_blocks = BlockSequence([text_block])
    gconf = BCTGenerationConfig(
        [(cfg_scale, blocks), (1.0 - cfg_scale, no_sem_blocks)],  # classifier-free guidance
        max_autoregressive_steps=1000,
        eos_token=model.config.text_pad_token,
        top_k=1,
    )

    block = generate_block(model, gconf)
    text_codes = block.inputs["text_input"][0, 0, : len(block)]
    tok = model_container["tokenizer"]

    text_output = tok.decode(text_codes, clean_up_tokenization_spaces=True)

    return text_output


def asr_api_dummy(audio: Audio) -> str:
    """Dummy ASR function for API-based models.

    Args:
        audio: Audio object to transcribe

    Returns:
        Placeholder text
    """
    return "meow"


def asr_whisper(audio: Audio, model: Optional[str] = None) -> str:
    """ASR using OpenAI Whisper API.

    Args:
        audio: Audio object to transcribe

    Returns:
        Transcribed text
    """
    client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

    wav_path = "tmp_whisper.wav"
    audio.write_wav(wav_path)

    try:
        with open(wav_path, "rb") as f:
            transcript = client.audio.transcriptions.create(
                file=f,
                model=model or "gpt-4o-mini-transcribe",
                response_format="text",
            )
    finally:
        os.remove(wav_path)

    return transcript


def asr_gemini(audio: Audio, model: Optional[str] = None) -> str:
    """ASR using Google Gemini API.

    Args:
        audio: Audio object to transcribe

    Returns:
        Transcribed text
    """
    # Load Google credentials
    creds_path = os.getenv(
        "GOOGLE_APPLICATION_CREDENTIALS", "/home/vibert/data/credentials/google-3.json"
    )
    os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = creds_path

    # Initialize Gemini client with Vertex AI
    client = genai.Client(vertexai=True, project="ml-gemini-455703", location="us-central1")

    wav_path = "tmp_gemini.wav"
    audio.write_wav(wav_path)

    try:
        with open(wav_path, "rb") as f:
            audio_bytes = f.read()

        response = client.models.generate_content(
            model=model or "gemini-2.5-flash",
            contents=[
                types.Part(inline_data=types.Blob(data=audio_bytes, mime_type="audio/wav")),
                types.Part(
                    text="Please transcribe all the lyrics or speech in this audio file. Write them in their original script rather than using any romanized forms such as pinyin or romanization."
                ),
            ],
        )
        return response.text
    finally:
        os.remove(wav_path)


def asr_qwen_omni(audio: Audio, model: Optional[str] = None, s3_id: Optional[str] = None) -> str:
    """ASR using Qwen-Omni Instruct via Modal API.

    Args:
        audio: Audio object to transcribe
        model: Model deployment name (default: "qwen-omni-captioner-dev")
        s3_id: S3 file ID if audio is already in S3 (studio/uploads/{s3_id})

    Returns:
        Transcribed text
    """
    # Get Modal stub for Qwen-Omni Instruct
    deployment = model or "qwen-omni-captioner-dev"
    QwenOmniInstruct = modal.Cls.from_name(deployment, "QwenOmniInstruct")
    qwen_stub = QwenOmniInstruct()

    # If no S3 ID provided, we need to upload the audio to S3
    if s3_id is None:
        import boto3
        import uuid

        # Generate a unique ID for S3 upload
        audio_id = str(uuid.uuid4())

        # Upload audio to S3
        wav_path = f"tmp_qwen_{audio_id}.mp3"
        audio.write_mp3(wav_path)

        try:
            s3_client = boto3.client("s3")
            s3_path = f"studio/uploads/{audio_id}.mp3"
            with open(wav_path, "rb") as f:
                s3_client.upload_fileobj(f, "suno-data-uploads", s3_path)

            s3_id = f"{audio_id}.mp3"
        finally:
            os.remove(wav_path)

    # Create QueueItem JSON with S3 ID
    qwen_args = json.dumps(
        {
            "id": s3_id,
            "metadata": {},
        }
    )

    # Use the same prompt as Gemini for consistency
    text_prompt = "Please transcribe all the lyrics or speech in this audio file. Write them in their original script rather than using any romanized forms such as pinyin or romanization."

    # Call Modal API
    result = qwen_stub.instruct_audio.remote(qwen_args, text_prompt=text_prompt)
    return result


def perform_asr(
    audio: Audio, model_type: str, model: Optional[str] = None, cfg_scale: float = 1.0
) -> str:
    """Dispatch ASR call based on model type.

    Args:
        audio: Audio object to transcribe
        model: Model to use
        model: Model name or path to a model checkpoint
        cfg_scale: Classifier-free guidance scale

    Returns:
        Generated text transcription
    """
    if model_type == "custom":
        return asr_custom(audio, gpt_model_path=model, cfg_scale=cfg_scale)
    elif model_type == "whisper":
        return asr_whisper(audio, model=model)
    elif model_type == "gemini":
        return asr_gemini(audio, model=model)
    elif model_type == "qwen-omni":
        return asr_qwen_omni(audio, model=model)
    else:
        return asr_api_dummy(audio)


def main():
    import time

    parser = argparse.ArgumentParser(description="Evaluate ASR model and calculate WER")
    parser.add_argument(
        "--model",
        type=str,
        default="custom",
        help="Model type to use for ASR (custom for local model, anything else for API)",
    )
    parser.add_argument("--n_songs", type=int, required=True, help="Number of songs to evaluate")
    parser.add_argument("--output", type=str, required=True, help="Output JSONL file path for results")
    parser.add_argument(
        "--input",
        type=str,
        default="/app2/suno/data/asr/metas_val.jsonl",
        help="Input JSONL file with audio metadata",
    )
    parser.add_argument(
        "--model-name",
        type=str,
        default=None,
        help="Either a model name or a path to a model checkpoint",
    )
    parser.add_argument(
        "--sweep-cfg",
        type=str,
        default=None,
        help="Comma-separated CFG values to sweep (e.g., '1,1.5,2,3'). If not provided, uses default CFG=1",
    )

    args = parser.parse_args()

    # Parse CFG sweep values
    if args.sweep_cfg:
        cfg_values = [float(x.strip()) for x in args.sweep_cfg.split(",")]
    else:
        cfg_values = [1.0]  # Default CFG

    print(f"Using model: {args.model}")
    print(f"CFG sweep values: {cfg_values}")

    print(f"Reading entries from: {args.input}")
    entries = list(read_jsonl_filtered(args.input, limit=args.n_songs))
    print(f"Processing {len(entries)} entries")

    # languages = defaultdict(int)
    # for entry in entries:
    #     languages[entry["lang"]] += 1
    # print(f"Languages: {languages}")

    # Prepare output directory and filename
    output_path = Path(args.output)
    output_dir = output_path.parent
    output_filename = output_path.name
    output_dir.mkdir(parents=True, exist_ok=True)

    # Store results for each CFG
    all_cfg_results = {}

    # Loop over CFG values
    for cfg_scale in cfg_values:
        print(f"\n{'=' * 60}")
        print(f"Running evaluation with CFG={cfg_scale}")
        print(f"{'=' * 60}\n")

        # Create output path for this CFG
        if args.sweep_cfg:
            cfg_output_path = output_dir / f"cfg-{cfg_scale}-{output_filename}"
        else:
            cfg_output_path = output_path

        results = []
        per_item_times = []
        start_time = time.time()
        for i, entry in enumerate(entries, 1):
            print(f"\nProcessing {i}/{len(entries)}: {entry.get('id', 'unknown')}")
            item_start = time.time()

            # Load audio and generate transcription
            audio = Audio.from_file(entry["local_filepath"])
            generated_text = perform_asr(audio, args.model, model=args.model_name, cfg_scale=cfg_scale)

            # Calculate WER
            reference_text = entry.get("text", "")
            lang = entry.get("lang")
            assert lang is not None
            wer_score = calculate_wer(reference_text, generated_text, lang)

            # Store results
            result = {
                "id": entry.get("id"),
                "local_filepath": entry.get("local_filepath"),
                "duration_s": entry.get("duration_s"),
                "lang": lang,
                "reference_text": reference_text,
                "generated_text": generated_text,
                "wer": wer_score,
            }
            results.append(result)

            item_end = time.time()
            elapsed = item_end - item_start
            per_item_times.append(elapsed)

            print(f"WER: {wer_score:.4f}")
            print(f"Reference: {reference_text[:100]}...")
            print(f"Generated: {generated_text[:100]}...")
            print(f"Time for this item: {elapsed:.2f} seconds")

            if i % 100 == 0:
                print(f"\nWriting intermediate results to: {cfg_output_path}")
                with open(cfg_output_path, "w", encoding="utf-8") as f:
                    for result in results:
                        json.dump(result, f, ensure_ascii=False)
                        f.write("\n")

        end_time = time.time()
        total_time = end_time - start_time
        avg_time = total_time / len(results) if results else 0.0

        # Write results to output file
        print(f"\nWriting results to: {cfg_output_path}")
        with open(cfg_output_path, "w", encoding="utf-8") as f:
            for result in results:
                json.dump(result, f, ensure_ascii=False)
                f.write("\n")

        # Calculate and print summary statistics
        wer_values = torch.tensor([r["wer"] for r in results])
        print("\n" + "=" * 50)
        if args.sweep_cfg:
            print(f"Summary Statistics for CFG={cfg_scale}:")
        else:
            print("Summary Statistics:")
        print(f"Mean WER: {wer_values.mean():.4f}")
        print(f"Median WER: {wer_values.median():.4f}")
        print(f"50th percentile: {wer_values.quantile(0.5):.4f}")
        print(f"90th percentile: {wer_values.quantile(0.9):.4f}")
        print(f"95th percentile: {wer_values.quantile(0.95):.4f}")
        print("=" * 50)
        print(f"Total time: {total_time:.2f} seconds")
        print(f"Average time per item: {avg_time:.2f} seconds")

        # Store statistics for this CFG
        all_cfg_results[cfg_scale] = {
            "mean": wer_values.mean().item(),
            "median": wer_values.median().item(),
            "p50": wer_values.quantile(0.5).item(),
            "p90": wer_values.quantile(0.9).item(),
            "p95": wer_values.quantile(0.95).item(),
        }

    # Print and save summary of all CFG sweeps
    if args.sweep_cfg:
        print("\n\n" + "=" * 60)
        print("SUMMARY OF ALL CFG SWEEPS")
        print("=" * 60)

        for cfg_scale in cfg_values:
            stats = all_cfg_results[cfg_scale]
            print(f"\nCFG={cfg_scale}:")
            print(f"  Mean WER:   {stats['mean']:.4f}")
            print(f"  Median WER: {stats['median']:.4f}")
            print(f"  P50:        {stats['p50']:.4f}")
            print(f"  P90:        {stats['p90']:.4f}")
            print(f"  P95:        {stats['p95']:.4f}")

        print("\n" + "=" * 60)

        # Save summary to file
        summary_path = output_dir / "results"
        print(f"\nSaving summary to: {summary_path}")
        with open(summary_path, "w", encoding="utf-8") as f:
            for cfg_scale in cfg_values:
                stats = all_cfg_results[cfg_scale]
                f.write(f"CFG={cfg_scale}:\n")
                f.write(f"  Mean WER:   {stats['mean']:.4f}\n")
                f.write(f"  Median WER: {stats['median']:.4f}\n")
                f.write(f"  P50:        {stats['p50']:.4f}\n")
                f.write(f"  P90:        {stats['p90']:.4f}\n")
                f.write(f"  P95:        {stats['p95']:.4f}\n")
                f.write("\n")


if __name__ == "__main__":
    main()
