#!/usr/bin/env python3
"""Validate, merge, and export accepted map render review data."""
from __future__ import annotations

import argparse
import json
from pathlib import Path

from render_map_preview import TILE_INDEX_MODES
from summarize_map_tiles import load_maps


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

STATES = {"accepted", "rejected"}
VISUAL_MODES = {
    "layer0",
    "bothSceneTilesets",
    "bothFirstTileset",
    "layer1UnderSceneTilesets",
    "layer1UnderFirstTileset",
}
DEFAULT_REVIEW_VISUAL = "layer1UnderSceneTilesets"


def review_key(map_name: str, visual: str, tilesets: list[str], tile_index_mode: str = "row40_zero") -> str:
    base = f"{map_name}|{visual}|{','.join(tilesets)}"
    return base if tile_index_mode == "row40_zero" else f"{base}|{tile_index_mode}"


def pairwise(values: list[str]) -> list[list[str]]:
    return [values[index : index + 2] for index in range(0, len(values) - 1, 2)]


def unique_variants(variants: list[tuple[str, list[str]]]) -> list[tuple[str, list[str]]]:
    seen = set()
    result = []
    for source, variant in variants:
        key = tuple(variant)
        if not key or key in seen:
            continue
        seen.add(key)
        result.append((source, variant))
    return result


def map_variants(name: str, info: dict, scene_links: dict, all_variants: bool = True) -> list[tuple[str, list[str]]]:
    variants: list[tuple[str, list[str]]] = []
    selected = info.get("layerTilesets") or []
    if selected:
        variants.append(("selected", selected))
    if not all_variants:
        return unique_variants(variants)

    suffix = name[-1] if name[-1:].isalpha() else ""
    if suffix:
        variants.append(("suffix", [f"map_{suffix}1", f"map_{suffix}2"]))

    observed = info.get("observedSceneTilesets") or []
    if observed:
        variants.append(("observed", observed[:2]))
    for index, variant in enumerate(info.get("sceneTilesetVariants") or [], start=1):
        variants.append((f"variant{index}", variant[:2]))

    condition_tilesets = scene_links.get(name, {}).get("tilesets", [])
    for index, variant in enumerate(pairwise(condition_tilesets), start=1):
        variants.append((f"condition{index}", variant))

    return unique_variants(variants)


def candidate_index(maps: dict, scene_links: dict) -> dict[str, dict]:
    index = {}
    for map_name, info in maps.items():
        for _source, tilesets in map_variants(map_name, info, scene_links):
            normalized = tilesets[:2]
            if len(normalized) == 1:
                normalized.append(normalized[0])
            if len(normalized) < 2:
                continue
            for visual in sorted(VISUAL_MODES):
                for tile_index_mode in TILE_INDEX_MODES:
                    index[review_key(map_name, visual, normalized, tile_index_mode)] = {
                        "map": map_name,
                        "visual": visual,
                        "tilesets": normalized,
                        "tileIndexMode": tile_index_mode,
                    }
    return index


def normalize(data: object, valid: dict[str, dict]) -> dict[str, dict]:
    if not isinstance(data, dict):
        raise ValueError("map render review data must be an object")
    normalized = {}
    for key, value in data.items():
        if not isinstance(key, str):
            raise ValueError("map render review keys must be strings")
        if key not in valid:
            raise ValueError(f"{key}: review does not match a map render candidate")
        if not isinstance(value, dict):
            raise ValueError(f"{key}: review entry must be an object")
        state = value.get("state")
        if state not in STATES:
            raise ValueError(f"{key}: state must be one of {sorted(STATES)}")
        expected = valid[key]
        tile_index_mode = value.get("tileIndexMode") or expected.get("tileIndexMode") or "row40_zero"
        if tile_index_mode != expected.get("tileIndexMode", "row40_zero"):
            raise ValueError(f"{key}: tileIndexMode must be {expected.get('tileIndexMode', 'row40_zero')!r}")
        if value.get("map") != expected["map"]:
            raise ValueError(f"{key}: map must be {expected['map']!r}")
        if value.get("visual") != expected["visual"]:
            raise ValueError(f"{key}: visual must be {expected['visual']!r}")
        if value.get("tilesets") != expected["tilesets"]:
            raise ValueError(f"{key}: tilesets must be {expected['tilesets']!r}")
        normalized[key] = {
            "map": expected["map"],
            "visual": expected["visual"],
            "tilesets": expected["tilesets"],
            "tileIndexMode": tile_index_mode,
            "state": state,
        }
        note = value.get("note")
        if isinstance(note, str) and note.strip():
            normalized[key]["note"] = note.strip()
        if value.get("familyDefault") is True:
            normalized[key]["familyDefault"] = True
    return dict(sorted(normalized.items()))


def load_reviews(path: Path, valid: dict[str, dict]) -> dict[str, dict]:
    if not path.exists():
        return {}
    return normalize(json.loads(path.read_text(encoding="utf-8")), valid)


def load_scene_links(path: Path) -> dict:
    if not path.exists():
        return {}
    data = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError("scene links must be an object")
    return data


def merge(base: dict[str, dict], patch: dict[str, dict]) -> dict[str, dict]:
    merged = dict(base)
    merged.update(patch)
    return dict(sorted(merged.items()))


def accepted_by_map(reviews: dict[str, dict]) -> dict[str, dict]:
    return {
        review["map"]: review
        for review in reviews.values()
        if review.get("state") == "accepted"
    }


def review_counts(reviews: dict[str, dict]) -> dict[str, int]:
    accepted = sum(1 for review in reviews.values() if review.get("state") == "accepted")
    rejected = sum(1 for review in reviews.values() if review.get("state") == "rejected")
    family_defaults = sum(1 for review in reviews.values() if review.get("familyDefault") is True)
    return {
        "records": len(reviews),
        "accepted": accepted,
        "rejected": rejected,
        "familyDefaults": family_defaults,
        "acceptedMaps": len(accepted_by_map(reviews)),
    }


def print_merge_summary(base: dict[str, dict], patch: dict[str, dict], merged: dict[str, dict]) -> None:
    base_counts = review_counts(base)
    patch_counts = review_counts(patch)
    merged_counts = review_counts(merged)
    before = accepted_by_map(base)
    after = accepted_by_map(merged)
    newly_accepted = sorted(name for name in after if name not in before)
    changed_accepted = sorted(
        name
        for name in after
        if name in before and after[name] != before[name]
    )
    rejected_maps = sorted({
        review["map"]
        for review in patch.values()
        if review.get("state") == "rejected"
    })
    print(
        "merge summary: "
        f"patch {patch_counts['records']} records "
        f"({patch_counts['accepted']} accepted, {patch_counts['rejected']} rejected), "
        f"accepted maps {base_counts['acceptedMaps']} -> {merged_counts['acceptedMaps']}, "
        f"records {base_counts['records']} -> {merged_counts['records']}"
    )
    if newly_accepted:
        print(f"new accepted maps ({len(newly_accepted)}): {', '.join(newly_accepted[:24])}{' ...' if len(newly_accepted) > 24 else ''}")
    if changed_accepted:
        print(f"changed accepted maps ({len(changed_accepted)}): {', '.join(changed_accepted[:24])}{' ...' if len(changed_accepted) > 24 else ''}")
    if rejected_maps:
        print(f"patch rejected maps ({len(rejected_maps)}): {', '.join(rejected_maps[:24])}{' ...' if len(rejected_maps) > 24 else ''}")


def validate_single_accepted_per_map(reviews: dict[str, dict]) -> None:
    accepted_keys: dict[str, list[str]] = {}
    for key, review in reviews.items():
        if review.get("state") == "accepted":
            accepted_keys.setdefault(review["map"], []).append(key)
    duplicates = {
        map_name: keys
        for map_name, keys in accepted_keys.items()
        if len(keys) > 1
    }
    if duplicates:
        details = "; ".join(
            f"{map_name}: {', '.join(keys)}"
            for map_name, keys in sorted(duplicates.items())
        )
        raise ValueError(f"multiple accepted map render reviews after merge: {details}")


def accepted_family_defaults(reviews: dict[str, dict]) -> dict[tuple[str, ...], dict]:
    defaults: dict[tuple[str, ...], dict] = {}
    for review in sorted(
        reviews.values(),
        key=lambda item: (",".join(item.get("tilesets") or []), item.get("map", ""), item.get("visual", "")),
    ):
        if review.get("state") != "accepted" or review.get("familyDefault") is not True:
            continue
        tilesets = review.get("tilesets")
        if not isinstance(tilesets, list) or not tilesets:
            continue
        defaults.setdefault(tuple(tilesets), review)
    return defaults


def coverage_rows(maps: dict, candidates: dict[str, dict], reviews: dict[str, dict]) -> list[dict]:
    accepted = accepted_by_map(reviews)
    rejected_count: dict[str, int] = {}
    candidate_count: dict[str, int] = {}
    for candidate in candidates.values():
        candidate_count[candidate["map"]] = candidate_count.get(candidate["map"], 0) + 1
    for review in reviews.values():
        if review.get("state") == "rejected":
            rejected_count[review["map"]] = rejected_count.get(review["map"], 0) + 1
    family_defaults = accepted_family_defaults(reviews)

    rows = []
    for map_name, info in sorted(maps.items()):
        review = accepted.get(map_name)
        default_tilesets = info.get("layerTilesets") or [info.get("tileset")]
        family_default = family_defaults.get(tuple(default_tilesets))
        render_source = "accepted" if review else "family default" if family_default else "fallback"
        source_review = review or family_default
        rows.append({
            "map": map_name,
            "state": "accepted" if review else "unreviewed",
            "renderSource": render_source,
            "renderSourceMap": source_review.get("map") if source_review else "",
            "visual": review.get("visual") if review else source_review.get("visual") if source_review else DEFAULT_REVIEW_VISUAL,
            "reviewVisual": source_review.get("visual") if source_review else DEFAULT_REVIEW_VISUAL,
            "tileIndexMode": (source_review.get("tileIndexMode") if source_review else "row40_zero") or "row40_zero",
            "tilesets": (source_review.get("tilesets") if source_review else default_tilesets),
            "size": f"{info['width']}x{info['height']}",
            "candidateCount": candidate_count.get(map_name, 0),
            "rejectedCount": rejected_count.get(map_name, 0),
        })
    return rows


def write_outputs(reviews: dict[str, dict], out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "map_render_reviews.json").write_text(
        json.dumps(reviews, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "map_render_reviews.js").write_text(
        "window.HWANSE_MAP_RENDER_REVIEWS = "
        + json.dumps(reviews, ensure_ascii=False, separators=(",", ":"))
        + ";\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--src", type=Path, default=DATA / "map_render_reviews.json")
    parser.add_argument("--maps", type=Path, default=OUT / "maps.js")
    parser.add_argument("--scene-links", type=Path, default=OUT / "scene_links.json")
    parser.add_argument("--out", type=Path, default=OUT)
    parser.add_argument("--merge", type=Path, help="merge browser-exported map render review JSON into --src")
    parser.add_argument("--write-src", action="store_true", help="write merged reviews back to --src")
    parser.add_argument("--dry-run", action="store_true", help="validate and summarize --merge without writing files")
    parser.add_argument("--reuse-gap-metrics", action="store_true", help=argparse.SUPPRESS)
    args = parser.parse_args()

    maps = load_maps(args.maps)
    valid = candidate_index(maps, load_scene_links(args.scene_links))
    reviews = load_reviews(args.src, valid)
    if args.merge:
        patch = load_reviews(args.merge, valid)
        merged = normalize(merge(reviews, patch), valid)
        validate_single_accepted_per_map(merged)
        print_merge_summary(reviews, patch, merged)
        reviews = merged
    else:
        validate_single_accepted_per_map(reviews)
    if args.dry_run:
        if not args.merge:
            raise ValueError("--dry-run requires --merge")
        print("dry run: no files written")
        return
    if args.merge and args.write_src:
        args.src.write_text(json.dumps(reviews, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print(f"updated source map render reviews -> {args.src}")
    write_outputs(reviews, args.out)
    print(f"wrote {len(reviews)} map render reviews -> {args.out}")


if __name__ == "__main__":
    main()
