#!/usr/bin/env python3
"""Build the field-character speaker/name review dataset.

Speaker labels come from prompt rows already classified as ``speaker``.  CNS
associations remain candidates unless the asset identity is independently
grounded; sharing one scene/resource root is not a rect-level binding.
"""
from __future__ import annotations

import json
import re
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"

PRIMARY_ASSET_RE = re.compile(r"^cara_(?:[0-9][0-9a-f]?|at[123]|rs[12]|sm[12])$", re.IGNORECASE)

CONFIRMED_IDENTITIES = {
    "cara_at1": {
        "name": "아타호",
        "evidence": "주인공 필드 보행 CNS 역할 및 파일명 대응 확정",
    },
    "cara_at2": {
        "name": "아타호",
        "evidence": "아타호 필드 이벤트 CNS 역할 및 파일명 대응 확정",
    },
    "cara_at3": {
        "name": "아타호",
        "evidence": "아타호 필드 변형 CNS 역할 및 파일명 대응 확정",
    },
    "cara_rs1": {
        "name": "린샹",
        "evidence": "주인공 필드 보행 CNS 역할 및 파일명 대응 확정",
    },
    "cara_rs2": {
        "name": "린샹",
        "evidence": "린샹 필드 이벤트 CNS 역할 및 파일명 대응 확정",
    },
    "cara_sm1": {
        "name": "스마슈",
        "evidence": "주인공 필드 보행 CNS 역할 및 파일명 대응 확정",
    },
    "cara_sm2": {
        "name": "스마슈",
        "evidence": "스마슈 필드 이벤트 CNS 역할 및 파일명 대응 확정",
    },
}

KNOWN_PARTY_NAMES = {row["name"] for row in CONFIRMED_IDENTITIES.values()}


def load_json(path: Path) -> Any:
    return json.loads(path.read_text(encoding="utf-8"))


def speaker_name(prompt: dict[str, Any]) -> str:
    rows = prompt.get("lineRows") or []
    if not rows or rows[0].get("kind") != "speaker":
        return ""
    return str(rows[0].get("text") or "").strip()


def cara_assets(resources: list[str] | None) -> list[str]:
    result = []
    for resource in resources or []:
        name = str(resource).lower()
        if name.endswith(".cns"):
            name = name[:-4]
        if PRIMARY_ASSET_RE.fullmatch(name):
            result.append(name)
    return sorted(set(result))


def prompt_sample(prompt: dict[str, Any]) -> str:
    lines = [str(line).strip() for line in (prompt.get("lines") or []) if str(line).strip()]
    return " / ".join(lines[:3])


def clean_rect(rect: dict[str, Any], index: int) -> dict[str, Any]:
    return {
        "index": int(rect.get("index", index) or 0),
        "label": str(rect.get("label") or f"#{index}"),
        "x": int(rect.get("x", 0) or 0),
        "y": int(rect.get("y", 0) or 0),
        "w": int(rect.get("w", 0) or 0),
        "h": int(rect.get("h", 0) or 0),
        "sourceLabel": str(rect.get("sourceLabel") or ""),
    }


def build() -> dict[str, Any]:
    story = load_json(OUT / "story_prompts.json")
    rect_data = load_json(OUT / "cns_rect_review_data.json")
    player = load_json(OUT / "scene_script_player_data.json")
    resource_links = load_json(OUT / "scene_seq_resource_record_link_review.json")
    monster_catalog = load_json(OUT / "battle_monster_action_catalog.json")

    prompts = story.get("prompts") or []
    prompts_by_id = {prompt["id"]: prompt for prompt in prompts}
    speaker_prompts = [prompt for prompt in prompts if speaker_name(prompt)]
    first_kind_counts = Counter(
        str((prompt.get("lineRows") or [{}])[0].get("kind") or "missing")
        for prompt in prompts
    )

    monster_names = {
        str(row.get("enemyName") or "").strip()
        for row in monster_catalog.get("enemies") or []
        if str(row.get("enemyName") or "").strip()
    }

    direct_links: dict[str, dict[str, set[str]]] = defaultdict(lambda: defaultdict(set))
    scene_links: dict[str, dict[str, dict[str, set[str]]]] = defaultdict(
        lambda: defaultdict(lambda: {"prompts": set(), "groups": set(), "contexts": set(), "evidence": set()})
    )
    ambiguous_direct_bundle_count = 0
    ambiguous_scene_root_count = 0

    # A prompt-local resource list is still a bundle, but is a tighter candidate
    # than a whole selector/root range.
    for prompt in speaker_prompts:
        name = speaker_name(prompt)
        if name in KNOWN_PARTY_NAMES:
            continue
        unresolved_assets = [
            asset for asset in cara_assets(prompt.get("resourceNames"))
            if asset not in CONFIRMED_IDENTITIES
        ]
        if len(unresolved_assets) == 1:
            direct_links[unresolved_assets[0]][name].add(prompt["id"])
        elif len(unresolved_assets) > 1:
            ambiguous_direct_bundle_count += 1

    resource_group_by_id = {
        group["id"]: group for group in (resource_links.get("groups") or [])
    }
    for group in player.get("groups") or []:
        resource_group = resource_group_by_id.get(group.get("id"), {})
        unresolved_assets = [
            asset for asset in cara_assets(resource_group.get("resources"))
            if asset not in CONFIRMED_IDENTITIES
        ]
        if not unresolved_assets:
            continue
        if len(unresolved_assets) > 1:
            ambiguous_scene_root_count += 1
            continue
        asset = unresolved_assets[0]
        prompt_ids = set()
        for sequence in group.get("sequences") or []:
            for row in sequence.get("prompts") or []:
                prompt_ids.add(str(row.get("id") or ""))
        for prompt_id in prompt_ids:
            prompt = prompts_by_id.get(prompt_id)
            if not prompt:
                continue
            name = speaker_name(prompt)
            if not name or name in KNOWN_PARTY_NAMES:
                continue
            link = scene_links[asset][name]
            link["prompts"].add(prompt_id)
            link["groups"].add(str(group.get("id") or ""))
            link["contexts"].add(str(group.get("contextLabel") or ""))
            link["evidence"].add(str(group.get("evidenceStatus") or ""))

    rect_rows = {
        str(row.get("asset") or "").lower(): row
        for row in rect_data.get("rows") or []
        if str(row.get("group") or "") == "character"
    }

    assets = []
    for asset, row in sorted(rect_rows.items()):
        if not PRIMARY_ASSET_RE.fullmatch(asset):
            continue
        exact = CONFIRMED_IDENTITIES.get(asset)
        names = set(direct_links.get(asset, {})) | set(scene_links.get(asset, {}))
        candidates = []
        for name in names:
            direct_prompt_ids = direct_links.get(asset, {}).get(name, set())
            scene = scene_links.get(asset, {}).get(name) or {
                "prompts": set(), "groups": set(), "contexts": set(), "evidence": set()
            }
            candidates.append(
                {
                    "name": name,
                    "directPromptCount": len(direct_prompt_ids),
                    "scenePromptCount": len(scene["prompts"]),
                    "sceneGroupCount": len(scene["groups"]),
                    "promptIds": sorted(direct_prompt_ids | scene["prompts"]),
                    "sceneGroups": sorted(scene["groups"]),
                    "contexts": sorted(value for value in scene["contexts"] if value),
                    "evidenceStatuses": sorted(value for value in scene["evidence"] if value),
                    "evidenceLevel": "dialogue-block-resource-candidate" if direct_prompt_ids else "scene-root-co-load-candidate",
                    "monsterCatalogNameOverlap": name in monster_names,
                }
            )
        candidates.sort(
            key=lambda item: (
                -item["directPromptCount"],
                -item["sceneGroupCount"],
                -item["scenePromptCount"],
                item["name"],
            )
        )
        rects = [clean_rect(rect, index) for index, rect in enumerate(row.get("rects") or [])]
        assets.append(
            {
                "asset": asset,
                "cns": str(row.get("cns") or f"{asset}.cns"),
                "width": int(row.get("width") or 0),
                "height": int(row.get("height") or 0),
                "rectStatus": str(row.get("status") or ""),
                "rectStatusLabel": str(row.get("statusLabel") or ""),
                "rectSource": str(row.get("selectedRectSource") or ""),
                "rectCount": len(rects),
                "rects": rects,
                "identityStatus": "confirmed-cns-identity" if exact else "rect-identity-unbound",
                "confirmedIdentity": exact,
                "candidateCount": len(candidates),
                "speakerCandidates": candidates,
                "reviewHref": f"field_character_review.html?asset={asset}",
            }
        )

    asset_candidate_index: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for asset in assets:
        if asset["confirmedIdentity"]:
            name = asset["confirmedIdentity"]["name"]
            asset_candidate_index[name].append(
                {"asset": asset["asset"], "evidenceLevel": "confirmed-cns-identity", "directPromptCount": 0, "scenePromptCount": 0}
            )
        for candidate in asset["speakerCandidates"]:
            asset_candidate_index[candidate["name"]].append(
                {
                    "asset": asset["asset"],
                    "evidenceLevel": candidate["evidenceLevel"],
                    "directPromptCount": candidate["directPromptCount"],
                    "scenePromptCount": candidate["scenePromptCount"],
                }
            )

    speaker_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for prompt in speaker_prompts:
        speaker_groups[speaker_name(prompt)].append(prompt)

    speakers = []
    for name, rows in speaker_groups.items():
        candidates = asset_candidate_index.get(name, [])
        candidates.sort(
            key=lambda item: (
                item["evidenceLevel"] != "confirmed-cns-identity",
                -item["directPromptCount"],
                -item["scenePromptCount"],
                item["asset"],
            )
        )
        first = min(rows, key=lambda item: int(item.get("startVa") or 0))
        speakers.append(
            {
                "name": name,
                "promptCount": len(rows),
                "blockCount": len({str(row.get("blockId") or "") for row in rows}),
                "firstPromptId": first.get("id"),
                "firstStartVaHex": first.get("startVaHex"),
                "firstSample": prompt_sample(first),
                "classifications": dict(sorted(Counter(str(row.get("classification") or "") for row in rows).items())),
                "cnsCandidates": candidates,
                "monsterCatalogNameOverlap": name in monster_names,
            }
        )
    speakers.sort(key=lambda item: (-item["promptCount"], item["name"]))

    utility_assets = sorted(
        asset for asset in rect_rows
        if asset.startswith("cara_") and not PRIMARY_ASSET_RE.fullmatch(asset)
    )
    exact_rect_count = sum(asset["rectCount"] for asset in assets if asset["confirmedIdentity"])

    return {
        "version": 1,
        "kind": "hwanse-field-character-identity-review",
        "source": [
            "out/story_prompts.json",
            "out/scene_script_player_data.json",
            "out/scene_seq_resource_record_link_review.json",
            "out/cns_rect_review_data.json",
            "out/active_object_resource_slot_review.json",
        ],
        "summary": {
            "promptCount": len(prompts),
            "speakerPromptCount": len(speaker_prompts),
            "speakerLabelCount": len(speakers),
            "nonSpeakerFirstLineCount": len(prompts) - len(speaker_prompts),
            "firstLineKindCounts": dict(sorted(first_kind_counts.items())),
            "fieldCnsCount": len(assets),
            "confirmedCnsIdentityCount": sum(bool(asset["confirmedIdentity"]) for asset in assets),
            "confirmedIdentityRectCount": exact_rect_count,
            "rectIdentityUnboundCnsCount": sum(not asset["confirmedIdentity"] for asset in assets),
            "utilityCaraAssetsExcluded": utility_assets,
            "directActiveObjectCharacterRectBindingCount": 0,
            "ambiguousDialogueResourceBundlesNotLinked": ambiguous_direct_bundle_count,
            "ambiguousSceneRootsNotLinked": ambiguous_scene_root_count,
        },
        "evidencePolicy": [
            "Only lineRows[0].kind == speaker is accepted as a speaker label; continuation text is excluded.",
            "Monster sprite CNS assets are not included in this field-character asset list.",
            "A shared dialogue/resource block or scene root is a CNS-level candidate, not a rect-level identity proof.",
            "A candidate is emitted only when exactly one unconfirmed field-character CNS exists in that dialogue/resource scope; multi-CNS scopes are left unlinked.",
            "The 15 direct active-object non-map bindings resolve only to cara_etc interaction objects, so none is promoted as a field-character identity.",
            "Unbound cara_01..cara_25 rects remain unnamed until an object draw selector and prompt producer are linked directly or runtime tracing supplies the binding.",
        ],
        "assets": assets,
        "speakers": speakers,
    }


def main() -> None:
    payload = build()
    path = OUT / "field_character_identity_review.json"
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(
        f"wrote {path.relative_to(ROOT)}: "
        f"{payload['summary']['fieldCnsCount']} CNS, "
        f"{payload['summary']['speakerLabelCount']} speaker labels"
    )


if __name__ == "__main__":
    main()
