from __future__ import annotations

import re
from collections import Counter
from dataclasses import dataclass
from typing import Any

from caption_profile import (
    CaptionProfile,
    display_unit_count,
    normalize_visible_spaces,
    restore_cue_wide_markup,
    split_text_by_display_capacity,
    wrap_text,
)
from language import normalize_language_tag
from operations import SentenceGroup, allocate_estimated_boundaries, form_sentence_groups
from timed_text import Cue, TimedTextDocument, TimingFidelity


FILENAME_UNSAFE_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
LANGUAGE_NAMES = {
    "ar": "Arabic",
    "cs": "Czech",
    "da": "Danish",
    "de": "German",
    "el": "Greek",
    "en": "English",
    "es": "Spanish",
    "fi": "Finnish",
    "fr": "French",
    "he": "Hebrew",
    "hi": "Hindi",
    "id": "Indonesian",
    "it": "Italian",
    "ja": "Japanese",
    "ko": "Korean",
    "nl": "Dutch",
    "no": "Norwegian",
    "pl": "Polish",
    "pt": "Portuguese",
    "ro": "Romanian",
    "ru": "Russian",
    "sv": "Swedish",
    "th": "Thai",
    "tr": "Turkish",
    "uk": "Ukrainian",
    "vi": "Vietnamese",
    "zh": "Chinese",
}
TRANSLATION_APPROACHES = {"FAITHFUL", "TRANSCREATION", "CUSTOM"}
APPROACH_PRESETS = {
    "FAITHFUL": (
        "Translate faithfully. Preserve meaning, tone, factual claims, names, terminology, "
        "and calls to action. Do not intentionally shorten, summarize, expand, or omit content."
    ),
    "TRANSCREATION": (
        "Create natural market-appropriate wording that preserves the original intent, brand "
        "voice, emotional effect, factual claims, and call to action. Idioms, syntax, humor, "
        "and phrasing may be adapted instead of translated literally."
    ),
    "CUSTOM": "Follow the editor's wording instructions without adding a hidden semantic preset.",
}


@dataclass(frozen=True)
class LinguisticContext:
    project_context: str = ""
    known_names_and_terminology: str = ""
    task_instructions: str = ""
    protected_terms: str = ""


@dataclass(frozen=True)
class LanguageSpec:
    tag: str
    display_name: str


def normalize_context_text(value: str, *, max_characters: int = 20_000) -> str:
    normalized = value.replace("\r\n", "\n").replace("\r", "\n").strip()
    lines: list[str] = []
    previous = object()
    for line in normalized.split("\n"):
        clean = line.rstrip()
        if clean == previous:
            continue
        lines.append(clean)
        previous = clean
    result = "\n".join(lines)
    if len(result) > max_characters:
        raise ValueError(
            f"Context contains {len(result)} characters; maximum is {max_characters}. "
            "Reduce the context instead of relying on silent truncation."
        )
    return result


def normalize_translation_approach(value: str) -> str:
    normalized = value.strip().upper()
    if normalized not in TRANSLATION_APPROACHES:
        raise ValueError(f"Unsupported translation approach: {value}")
    return normalized


def parse_protected_terms(value: str) -> list[str]:
    normalized = normalize_context_text(value)
    result: list[str] = []
    seen: set[str] = set()
    for line in normalized.splitlines():
        term = line.strip()
        if not term:
            continue
        key = term.casefold()
        if key not in seen:
            result.append(term)
            seen.add(key)
    return result


def _protected_term_pattern(term: str, *, ignore_case: bool) -> re.Pattern[str]:
    prefix = r"(?<!\w)" if term[0].isalnum() or term[0] == "_" else ""
    suffix = r"(?!\w)" if term[-1].isalnum() or term[-1] == "_" else ""
    return re.compile(prefix + re.escape(term) + suffix, re.IGNORECASE if ignore_case else 0)


def resolve_protected_terms(source: str, terms: list[str]) -> tuple[list[str], list[str]]:
    resolved: list[str] = []
    seen_resolved: set[str] = set()
    unused: list[str] = []
    for term in terms:
        matches = _protected_term_pattern(term, ignore_case=True).findall(source)
        if not matches:
            unused.append(term)
            continue
        for match in matches:
            if match not in seen_resolved:
                resolved.append(match)
                seen_resolved.add(match)
    return resolved, unused


def resolve_document_protected_terms(
    document: TimedTextDocument,
    profile: CaptionProfile,
    value: str,
) -> tuple[list[str], list[str]]:
    source = "\n".join(group.plain_text for group in form_sentence_groups(document, profile))
    return resolve_protected_terms(source, parse_protected_terms(value))


def parse_language_specs(value: str) -> list[LanguageSpec]:
    result: list[LanguageSpec] = []
    seen: set[str] = set()
    for raw in re.split(r"[,;\n]+", value):
        if not raw.strip():
            continue
        parts = [part.strip() for part in raw.split("|")]
        if len(parts) > 2 or any(not part for part in parts):
            raise ValueError(
                f"Invalid target language {raw!r}. Use an ISO tag such as de-DE, "
                "or an unambiguous Language name | tag value such as Klingon | tlh."
            )
        supplied_name = parts[0] if len(parts) == 2 else ""
        tag = normalize_language_tag(parts[-1])
        mapped_name = LANGUAGE_NAMES.get(tag.split("-", 1)[0])
        if not mapped_name and not supplied_name:
            raise ValueError(
                f"Unsupported or ambiguous target language tag {tag!r}. "
                "Use an unambiguous Language name | tag value such as Klingon | tlh."
            )
        if supplied_name:
            if len(supplied_name) > 80 or re.search(r"[\x00-\x1f]", supplied_name):
                raise ValueError(f"Invalid target language name: {supplied_name!r}")
            display_name = supplied_name
        else:
            assert mapped_name is not None
            display_name = mapped_name
        key = tag.casefold()
        if key not in seen:
            result.append(LanguageSpec(tag, f"{display_name} ({tag})"))
            seen.add(key)
    if not result:
        raise ValueError("At least one target language is required.")
    return result


def parse_language_list(value: str) -> list[str]:
    return [spec.tag for spec in parse_language_specs(value)]


def language_display_name(tag: str) -> str:
    normalized = normalize_language_tag(tag)
    base = normalized.split("-", 1)[0]
    name = LANGUAGE_NAMES.get(base)
    if not name:
        raise ValueError(f"No reviewed language-name mapping exists for {normalized!r}.")
    return f"{name} ({normalized})"


def safe_output_stem(value: str) -> str:
    normalized = FILENAME_UNSAFE_RE.sub("_", value).strip(" .")
    normalized = re.sub(r"\s+", " ", normalized)
    return normalized or "subtitles"


def translated_output_name(source_name: str, target_language: str) -> str:
    target = normalize_language_tag(target_language)
    stem = safe_output_stem(PathLikeStem(source_name))
    parts = stem.rsplit(".", 1)
    if len(parts) == 2:
        try:
            normalize_language_tag(parts[1])
        except ValueError:
            pass
        else:
            stem = parts[0]
    return f"{stem}.{target}.srt"


def PathLikeStem(value: str) -> str:
    name = value.replace("\\", "/").rsplit("/", 1)[-1]
    return name[:-4] if name.casefold().endswith(".srt") else name


def _group_request(
    group: SentenceGroup,
    cues_by_id: dict[str, Cue],
    mode: str,
) -> dict[str, Any]:
    source_cues = [
        {"id": cue_id, "text": cues_by_id[cue_id].text.replace("\n", " ")}
        for cue_id in group.cue_ids
    ]
    return {
        "id": group.id,
        "sourceCueIds": list(group.cue_ids),
        "sourceText": group.plain_text,
        "sourceCues": source_cues,
        "translationMode": mode,
    }


def plan_translation_requests(
    document: TimedTextDocument,
    *,
    target_language: str,
    mode: str,
    context: LinguisticContext,
    profile: CaptionProfile,
    max_groups_per_batch: int = 25,
    target_language_name: str = "",
    approach: str = "FAITHFUL",
) -> list[dict[str, Any]]:
    normalized_mode = mode.upper()
    if normalized_mode not in {"PRESERVE_CUES", "OPTIMIZE_TARGET"}:
        raise ValueError(f"Unsupported translation mode: {mode}")
    target_language = normalize_language_tag(target_language)
    normalized_approach = normalize_translation_approach(approach)
    groups = form_sentence_groups(document, profile)
    cues_by_id = {cue.id: cue for cue in document.cues}
    normalized_context = {
        "projectContext": normalize_context_text(context.project_context),
        "knownNamesAndTerminology": normalize_context_text(context.known_names_and_terminology),
        "translationInstructions": normalize_context_text(context.task_instructions),
        "protectedTerms": resolve_document_protected_terms(
            document, profile, context.protected_terms
        )[0],
    }
    batches: list[dict[str, Any]] = []
    for start in range(0, len(groups), max(1, max_groups_per_batch)):
        selected = groups[start : start + max(1, max_groups_per_batch)]
        before = groups[max(0, start - 1) : start]
        after = groups[
            start + len(selected) : min(len(groups), start + len(selected) + 1)
        ]
        batches.append(
            {
                "version": 1,
                "batchId": f"batch_{len(batches) + 1:04d}",
                "targetLanguage": target_language,
                "targetLanguageName": (
                    target_language_name.strip()
                    if target_language_name.strip()
                    else language_display_name(target_language)
                ),
                "translationMode": normalized_mode,
                "translationApproach": normalized_approach,
                "context": normalized_context,
                "semanticTask": {
                    "selectedApproach": normalized_approach,
                    "visibleApproachPreset": APPROACH_PRESETS[normalized_approach],
                    "editorInstructions": normalized_context["translationInstructions"],
                    "authority": (
                        "Editor instructions take priority over the visible approach preset for "
                        "wording and language. The immutable response structure still applies."
                    ),
                },
                "instructions": [
                    "Process only the supplied target sentence groups; contextBefore and contextAfter are context only.",
                    "Return exactly one result for each group id in the same order.",
                    "Do not return timing, file paths, source references, comments, or markdown.",
                    (
                        "For PRESERVE_CUES, return every source cue id exactly once with non-empty translated text."
                        if normalized_mode == "PRESERVE_CUES"
                        else "For OPTIMIZE_TARGET, return one complete non-empty translation string per group."
                    ),
                    "Keep every protectedTerms entry that occurs in a target group exactly unchanged.",
                ],
                "contextBefore": [
                    {"id": group.id, "text": group.plain_text} for group in before
                ],
                "groups": [_group_request(group, cues_by_id, normalized_mode) for group in selected],
                "contextAfter": [
                    {"id": group.id, "text": group.plain_text} for group in after
                ],
            }
        )
    return batches


def _response_map(responses: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
    result: dict[str, dict[str, Any]] = {}
    for response in responses:
        groups = response.get("groups")
        if not isinstance(groups, list):
            raise ValueError('Translation response must contain a "groups" array.')
        for entry in groups:
            if not isinstance(entry, dict) or not isinstance(entry.get("id"), str):
                raise ValueError("Every translation group response needs a string id.")
            group_id = entry["id"]
            if group_id in result:
                raise ValueError(f"Duplicate translation group id: {group_id}")
            result[group_id] = entry
    return result


def _semantic_comparison_text(value: str) -> str:
    return " ".join(re.findall(r"\w+", normalize_visible_spaces(value).casefold()))


def _looks_untranslated(source: str, target: str) -> bool:
    normalized_source = _semantic_comparison_text(source)
    normalized_target = _semantic_comparison_text(target)
    if not normalized_source or normalized_source != normalized_target:
        return False
    return _is_meaningful_source(source)


def _is_meaningful_source(value: str) -> bool:
    normalized_source = _semantic_comparison_text(value)
    if not normalized_source:
        return False
    tokens = normalized_source.split()
    signal_characters = sum(len(token) for token in tokens)
    return len(tokens) >= 2 or signal_characters >= 16


AUTO_LITERAL_RE = re.compile(
    r"https?://[^\s<>]+|[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}|(?<!\w)[@#][\w.-]+|"
    r"(?<!\w)(?=[A-Za-z0-9._/-]*[A-Za-z])(?=[A-Za-z0-9._/-]*\d)[A-Za-z0-9][A-Za-z0-9._/-]*[A-Za-z0-9](?!\w)"
)
NUMBER_RE = re.compile(
    r"(?<!\w)(?:[$€£¥]\s*)?\d+(?:(?:[.,:/-]\d+)|(?:[ \u00a0\u202f]\d{3}))*"
    r"(?:\s*(?:%|[$€£¥]))?(?!\w)"
)


def _number_identity(value: str) -> str:
    digits = "".join(character for character in value if character.isdigit())
    return digits


def _currency_symbols(value: str) -> Counter[str]:
    return Counter(
        character
        for item in NUMBER_RE.findall(value)
        for character in item
        if character in "$€£¥"
    )


def _format_symbol_counts(value: Counter[str]) -> str:
    return ", ".join(f"{symbol} ({count})" for symbol, count in sorted(value.items())) or "none"


def _literal_tokens(value: str) -> list[str]:
    return [item.rstrip(".,;:!?)]}") for item in AUTO_LITERAL_RE.findall(value)]


def protected_content_warnings(
    source: str,
    target: str,
    protected_terms: list[str],
    group_id: str,
) -> list[str]:
    missing_terms: list[str] = []
    for term in protected_terms:
        source_count = len(_protected_term_pattern(term, ignore_case=False).findall(source))
        target_count = len(_protected_term_pattern(term, ignore_case=False).findall(target))
        if source_count > target_count:
            missing_terms.extend([term] * (source_count - target_count))
    source_literals = Counter(_literal_tokens(source))
    target_literals = Counter(_literal_tokens(target))
    missing_literals = list((source_literals - target_literals).elements())
    source_numbers = Counter(_number_identity(item) for item in NUMBER_RE.findall(source))
    target_numbers = Counter(_number_identity(item) for item in NUMBER_RE.findall(target))
    missing_numbers = list((source_numbers - target_numbers).elements())
    source_currencies = _currency_symbols(source)
    target_currencies = _currency_symbols(target)
    if missing_terms:
        raise ValueError(
            f"{group_id} changed or omitted text the editor marked as exact: "
            + ", ".join(repr(item) for item in missing_terms)
        )
    warnings: list[str] = []
    if missing_literals:
        warnings.append(
            f"{group_id} may have changed or omitted a URL, handle, email, or product code: "
            + ", ".join(missing_literals)
        )
    if missing_numbers:
        warnings.append(
            f"{group_id} may have changed or omitted a number or price: "
            + ", ".join(missing_numbers)
        )
    if source_currencies != target_currencies:
        warnings.append(
            f"{group_id} may have changed or omitted a currency symbol: "
            f"source={_format_symbol_counts(source_currencies)}, "
            f"target={_format_symbol_counts(target_currencies)}"
        )
    return warnings


def merge_translation_responses(
    document: TimedTextDocument,
    responses: list[dict[str, Any]],
    *,
    target_language: str,
    mode: str,
    profile: CaptionProfile,
    approach: str = "FAITHFUL",
    protected_terms: str = "",
) -> tuple[TimedTextDocument, list[str]]:
    normalized_mode = mode.upper()
    normalized_approach = normalize_translation_approach(approach)
    normalized_protected_terms, unused_protected_terms = resolve_document_protected_terms(
        document, profile, protected_terms
    )
    groups = form_sentence_groups(document, profile)
    by_id = _response_map(responses)
    expected_ids = [group.id for group in groups]
    if set(by_id) != set(expected_ids):
        missing = sorted(set(expected_ids) - set(by_id))
        extra = sorted(set(by_id) - set(expected_ids))
        raise ValueError(f"Translation group IDs do not match. Missing={missing}; extra={extra}")
    source_by_id = {cue.id: cue for cue in document.cues}
    output: list[Cue] = []
    warnings: list[str] = []
    if unused_protected_terms:
        warnings.append(
            "Protected text was not found in the source: "
            + ", ".join(repr(item) for item in unused_protected_terms)
        )
    meaningful_group_count = 0
    unchanged_group_count = 0
    for group in groups:
        if _is_meaningful_source(group.plain_text):
            meaningful_group_count += 1
        entry = by_id[group.id]
        if normalized_mode == "PRESERVE_CUES":
            translated_cues = entry.get("cues")
            if not isinstance(translated_cues, list):
                raise ValueError(f"{group.id} must contain a cues array.")
            translated_by_id: dict[str, str] = {}
            returned_ids: list[str] = []
            for translated in translated_cues:
                if not isinstance(translated, dict):
                    raise ValueError(f"{group.id} cue result must be an object.")
                cue_id = translated.get("id")
                text = translated.get("text")
                if not isinstance(cue_id, str) or not isinstance(text, str) or not text.strip():
                    raise ValueError(f"{group.id} cue result needs a valid id and non-empty text.")
                if cue_id in translated_by_id:
                    raise ValueError(f"Duplicate translated cue id: {cue_id}")
                returned_ids.append(cue_id)
                translated_by_id[cue_id] = text.strip()
            if returned_ids != list(group.cue_ids):
                raise ValueError(
                    f"{group.id} must preserve exact cue id order {list(group.cue_ids)}; got {returned_ids}."
                )
            translated_group_text = " ".join(
                translated_by_id[cue_id] for cue_id in group.cue_ids
            )
            warnings.extend(
                protected_content_warnings(
                    group.plain_text,
                    translated_group_text,
                    normalized_protected_terms,
                    group.id,
                )
            )
            if _looks_untranslated(group.plain_text, translated_group_text):
                unchanged_group_count += 1
                warnings.append(f"{group.id} output appears unchanged from the source text.")
            for cue_id in group.cue_ids:
                source = source_by_id[cue_id]
                wrapped, wrap_warnings = wrap_text(
                    translated_by_id[cue_id],
                    profile.max_units_per_line,
                    profile.max_lines,
                )
                warnings.extend(wrap_warnings)
                output.append(
                    Cue(
                        id=f"cue_{len(output) + 1:06d}",
                        srt_index=len(output) + 1,
                        start_ms=source.start_ms,
                        end_ms=source.end_ms,
                        text=restore_cue_wide_markup(wrapped, group.wrappers),
                    )
                )
        elif normalized_mode == "OPTIMIZE_TARGET":
            translation = entry.get("translation")
            if not isinstance(translation, str) or not translation.strip():
                raise ValueError(f"{group.id} must contain a non-empty translation.")
            warnings.extend(
                protected_content_warnings(
                    group.plain_text,
                    translation,
                    normalized_protected_terms,
                    group.id,
                )
            )
            if _looks_untranslated(group.plain_text, translation):
                unchanged_group_count += 1
                warnings.append(f"{group.id} output appears unchanged from the source text.")
            segments: list[str] = []
            capacity = profile.max_units_per_line * profile.max_lines
            if re.search(r"[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]", translation):
                segments = split_text_by_display_capacity(translation.strip(), capacity)
            else:
                words = translation.strip().split()
                current: list[str] = []
                for word in words:
                    candidate = " ".join(current + [word])
                    if current and display_unit_count(candidate) > capacity:
                        segments.append(" ".join(current))
                        current = [word]
                    else:
                        current.append(word)
                if current:
                    segments.append(" ".join(current))
            if not segments:
                raise ValueError(f"{group.id} produced no target text.")
            times = allocate_estimated_boundaries(segments, group.start_ms, group.end_ms)
            for text, (start_ms, end_ms) in zip(segments, times):
                wrapped, wrap_warnings = wrap_text(text, profile.max_units_per_line, profile.max_lines)
                warnings.extend(wrap_warnings)
                output.append(
                    Cue(
                        id=f"cue_{len(output) + 1:06d}",
                        srt_index=len(output) + 1,
                        start_ms=start_ms,
                        end_ms=end_ms,
                        text=restore_cue_wide_markup(wrapped, group.wrappers),
                    )
                )
        else:
            raise ValueError(f"Unsupported translation mode: {mode}")
        warnings.extend(group.warnings)
    if (
        normalized_approach == "FAITHFUL"
        and meaningful_group_count > 0
        and unchanged_group_count == meaningful_group_count
    ):
        raise ValueError(
            "The complete meaningful output appears unchanged from the source text. "
            "Review the target language and Workflow AI response."
        )
    result = TimedTextDocument(
        language=normalize_language_tag(target_language),
        source_kind="srt",
        source_name=document.source_name,
        timing_fidelity=(
            TimingFidelity.CUE_ALIGNED_ONLY
            if normalized_mode == "PRESERVE_CUES"
            else TimingFidelity.ESTIMATED_WITHIN_GROUP
        ),
        cues=output,
    )
    result.validate(allow_overlaps=False)
    return result, list(dict.fromkeys(warnings))
