#!/usr/bin/env python3
"""Derive per-skill caster placement/motion rules for battle previews.

The lower-level movement review intentionally preserves opcode-level evidence.
This report turns that evidence into the semantic rule the browser runner needs:
where the caster starts, whether the caster moves, and which target-side anchor
should be used.  Target scope alone is not enough; a single-target skill can be
melee, ranged, dash, pierce, or side/orbit depending on display VM movement and
position writes.
"""
from __future__ import annotations

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


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


def esc(value: Any) -> str:
    return html.escape(str(value if value is not None else ""))


def key_for(row: dict[str, Any]) -> str:
    return f"{row.get('ownerKey')}:{row.get('skillIdHex')}"


def non_reset_movements(row: dict[str, Any]) -> list[dict[str, Any]]:
    result = []
    for item in row.get("movements") or []:
        mode = int(item.get("mode") or 0)
        selector = int(item.get("selector") or 0)
        if mode == 0 and selector == 0:
            continue
        result.append(item)
    return result


def first_non_reset(row: dict[str, Any]) -> dict[str, Any] | None:
    items = non_reset_movements(row)
    return items[0] if items else None


def has_hit_before_first_movement(row: dict[str, Any]) -> bool:
    contexts = row.get("movementContexts") or []
    for ctx in contexts:
        if ctx.get("role") == "return-home":
            continue
        return int(ctx.get("priorHitFlagCount") or 0) > 0
    return False


def evidence_summary(row: dict[str, Any]) -> dict[str, Any]:
    helper = row.get("helperEvidence") or {}
    writes = row.get("writeSummary") or {}
    return {
        "movementSignature": row.get("movementSignature") or "-",
        "positionClass": row.get("positionClass") or "-",
        "startPlacementPolicy": row.get("startPlacementPolicy") or "-",
        "writeCount": writes.get("count") or 0,
        "xValues": writes.get("xValues") or [],
        "yValues": writes.get("yValues") or [],
        "helperIds": helper.get("helperIds") or [],
        "effectWlkNos": helper.get("effectWlkNos") or row.get("effectWlkNos") or [],
        "resultWlkNos": helper.get("resultWlkNos") or row.get("soundWlkNos") or [],
        "spawnChildTargets": helper.get("spawnChildTargets") or [],
        "childTargets": helper.get("childTargets") or [],
        "hitFlagCount": helper.get("hitFlagCount") or 0,
        "frameHead": (row.get("frameSequence") or [])[:16],
    }


def derive_rule(row: dict[str, Any]) -> dict[str, Any]:
    target = row.get("targetClass") or "unknown"
    movement = first_non_reset(row)
    selector = int(movement.get("selector") or 0) if movement else None
    mode = int(movement.get("mode") or 0) if movement else None
    writes = int((row.get("writeSummary") or {}).get("count") or 0)
    helper = row.get("helperEvidence") or {}
    has_helper_effect = bool(
        helper.get("helperIds")
        or helper.get("effectWlkNos")
        or helper.get("clearDisplayLists")
        or helper.get("spawnChildTargets")
        or helper.get("childTargets")
    )
    notes: list[str] = []
    rule = {
        "recordKey": key_for(row),
        "ownerKey": row.get("ownerKey"),
        "ownerName": row.get("ownerName"),
        "skillIdHex": row.get("skillIdHex"),
        "skillName": row.get("skillName"),
        "familyName": row.get("familyName") or row.get("skillName"),
        "levelOrFixed": row.get("levelOrFixed"),
        "targetClass": target,
        "casterRule": "unknown",
        "startAnchor": "home",
        "motionKind": "none",
        "motionTarget": "none",
        "directPlacementSelector": selector if mode == 0 else None,
        "confidence": "low",
        "notes": notes,
        "evidence": evidence_summary(row),
    }

    if movement:
        if mode == 0 and selector == 3:
            rule.update(
                casterRule="target-front-direct",
                startAnchor="target-front",
                motionKind="teleport",
                motionTarget="target-front",
                confidence="high",
            )
            notes.append("display VM direct placement selector 3 fixes the actor at target-front before frames")
            return rule
        if mode == 0 and selector == 2:
            rule.update(
                casterRule="target-side-direct",
                startAnchor="target-side",
                motionKind="teleport",
                motionTarget="target-side",
                confidence="high",
            )
            notes.append("display VM direct placement selector 2 fixes the actor at target-side/orbit position")
            return rule
        if mode == 0 and selector == 5:
            if target == "self-then-enemy-all":
                rule.update(
                    casterRule="self-prep-screen-all-target-effect",
                    startAnchor="home",
                    motionKind="self-prep/screen-effect",
                    motionTarget="all-targets/screen",
                    confidence="high",
                )
                notes.append("display VM selector 5 appears after self-prep for an all-enemy drink skill; treat as screen/all-target effect placement, not a clone actor.")
                return rule
            rule.update(
                casterRule="clone-spread-direct",
                startAnchor="home",
                motionKind="clone-spread",
                motionTarget="screen/targets",
                confidence="high",
            )
            notes.append("display VM direct placement selector 5 is clone/spread setup")
            return rule
        if mode == 0 and selector == 4:
            rule.update(
                casterRule="target-fixed-side-direct",
                startAnchor="target-fixed-side",
                motionKind="teleport",
                motionTarget="target-fixed-side",
                confidence="high",
            )
            notes.append("display VM direct placement selector 4 uses fixed screen X with target-relative Y; this is a target-side staging anchor.")
            return rule
        if mode != 0 and selector == 1:
            rule.update(
                casterRule="home-approach-target",
                startAnchor="home",
                motionKind="dash",
                motionTarget="target-front",
                confidence="high",
            )
            notes.append("nonzero movement selector 1 moves the actor from home toward target-front")
            return rule
        if mode != 0 and selector == 2:
            if has_hit_before_first_movement(row) or row.get("startPlacementPolicy") == "target-front-then-pierce":
                rule.update(
                    casterRule="target-front-then-pierce",
                    startAnchor="target-front",
                    motionKind="pierce",
                    motionTarget="target-through",
                    confidence="high",
                )
                notes.append("selector 2 occurs after a hit/target-front phase; actor pierces through from target-front")
            else:
                rule.update(
                    casterRule="home-pierce-through",
                    startAnchor="home",
                    motionKind="pierce",
                    motionTarget="target-through",
                    confidence="high",
                )
                notes.append("nonzero movement selector 2 moves actor from current/home anchor through target")
            return rule
        if mode != 0 and selector == 3:
            rule.update(
                casterRule="target-opposite-side-motion",
                startAnchor="home",
                motionKind="special-target-side-motion",
                motionTarget="target-opposite-side",
                confidence="high",
            )
            notes.append("nonzero movement selector 3 uses the opposite target-side edge formula; used by special all-target motion rows.")
            return rule

        rule.update(
            casterRule=f"unclassified-movement-mode-{mode}-selector-{selector}",
            motionKind="special",
            motionTarget="unknown",
            confidence="low",
        )
        notes.append("movement opcode exists but selector is not promoted yet")
        return rule

    if target == "enemy-single":
        if writes:
            rule.update(
                casterRule="target-front-implicit-local-combo",
                startAnchor="target-front",
                motionKind="local-pose",
                motionTarget="target-front",
                confidence="medium",
            )
            notes.append("single-target row has actor/display local x/y writes but no movement selector; treat as implicit close target-front combo")
            if has_helper_effect:
                notes.append("helper/effect cues are attached effects, not enough by themselves to classify the caster as ranged")
            return rule
        if has_helper_effect:
            rule.update(
                casterRule="caster-ranged-effect",
                startAnchor="enemy-ranged",
                motionKind="cast/projectile",
                motionTarget="target",
                confidence="medium",
            )
            notes.append("single-target row has helper/effect cues without caster movement/write; treat as ranged cast/effect")
            return rule
        if row.get("positionClass") == "single-target-front-basic-implicit":
            rule.update(
                casterRule="target-front-basic-implicit",
                startAnchor="target-front",
                motionKind="local-pose",
                motionTarget="target-front",
                confidence="medium",
            )
            notes.append("display stream has hit/result evidence but no explicit movement; surrounding battle placement supplies the close target-front anchor for basic/local strikes")
            return rule
        rule.update(
            casterRule="single-target-no-promoted-position",
            startAnchor="home",
            motionKind="engine-bound",
            motionTarget="target",
            confidence="low",
        )
        notes.append("no movement/write/helper evidence promoted for caster placement")
        return rule

    if target == "self-then-enemy-single":
        if writes:
            rule.update(
                casterRule="self-prep-target-front-local-combo",
                startAnchor="target-front",
                motionKind="self-prep/local-pose",
                motionTarget="target-front",
                confidence="medium",
            )
            notes.append("self-prep plus enemy-single payload has local actor/display writes; preview the attack phase at target-front")
            return rule
        if has_helper_effect:
            rule.update(
                casterRule="self-prep-ranged-effect",
                startAnchor="enemy-ranged",
                motionKind="self-prep/cast",
                motionTarget="target",
                confidence="medium",
            )
            notes.append("self-prep plus enemy-single payload has helper/effect evidence but no actor writes; preview as ranged effect")
            return rule
        rule.update(
            casterRule="self-prep-no-promoted-position",
            startAnchor="home",
            motionKind="self-prep/engine-bound",
            motionTarget="target",
            confidence="low",
        )
        notes.append("self-prep plus enemy-single payload has no promoted placement evidence")
        return rule

    if target == "self-then-enemy-all":
        rule.update(
            casterRule="self-prep-screen-all-target-effect",
            startAnchor="home",
            motionKind="self-prep/screen-effect",
            motionTarget="all-targets/screen",
            confidence="medium",
        )
        notes.append("self-prep plus enemy-all payload stays on caster side and resolves helper/result effects across all enemies")
        return rule

    if target == "enemy-all":
        if writes:
            rule.update(
                casterRule="caster-local-all-target-action",
                startAnchor="home",
                motionKind="local-pose/screen-hits",
                motionTarget="all-targets",
                confidence="medium",
            )
            notes.append("all-target row has actor/display local writes; actor remains on caster side while hits/effects resolve across targets")
            return rule
        if has_helper_effect:
            rule.update(
                casterRule="screen-or-ranged-all-target-effect",
                startAnchor="home",
                motionKind="screen-effect",
                motionTarget="all-targets/screen",
                confidence="medium",
            )
            notes.append("all-target row is driven by helper/effect cues rather than actor placement")
            return rule
        rule.update(
            casterRule="all-target-no-promoted-position",
            startAnchor="home",
            motionKind="engine-bound",
            motionTarget="all-targets",
            confidence="medium",
        )
        notes.append("all-target row has no promoted actor movement or helper placement")
        return rule

    if target in {"self", "ally-single", "ally-all"}:
        rule.update(
            casterRule=f"{target}-support",
            startAnchor="home",
            motionKind="support",
            motionTarget=target,
            confidence="medium",
        )
        notes.append("support/self scope keeps caster on allied side")
        return rule

    notes.append("target class is not interpreted")
    return rule


POSITION_BUCKETS = {
    "home": ("제자리/아군측", "시전자 기본 위치 또는 아군측에서 시전"),
    "target-front": ("몬스터 앞", "대상 몬스터 바로 앞 기준점에서 시전"),
    "target-side": ("몬스터 측면", "대상 몬스터 측면/둘레 기준점에서 시전"),
    "target-fixed-side": ("고정 측면", "화면 고정 X와 대상 Y를 섞은 측면 기준점에서 시전"),
    "enemy-ranged": ("떨어져서 쏨", "대상과 수평 거리를 둔 원거리/광선/투사체 시전"),
    "screen": ("맵/화면 전체", "시전자 이동보다 화면/전체 대상 효과가 중심"),
    "clone-spread": ("사방팔방/분신", "분신 또는 화면 분산 배치"),
    "pierce": ("관통", "대상을 지나 화면 반대쪽으로 관통 이동"),
    "approach": ("돌진/접근", "기본 위치에서 대상 앞으로 이동"),
    "support": ("자기/아군 지원", "적 위치가 아닌 자기/아군측 지원 동작"),
}


def enrich_rule(rule: dict[str, Any]) -> dict[str, Any]:
    """Attach user-facing placement buckets and evidence tiers.

    The display VM has two different evidence strengths:
    - explicit: 0xbc direct/interpolated movement gives battle-map placement math.
    - implicit: frame/write/hit streams have no 0xbc non-reset, but the target
      scope and actor VM prove the standard battle engine anchor to use.

    Both are usable by the runner, but they should not be described as the same
    kind of proof.
    """
    caster_rule = rule.get("casterRule") or ""
    motion = rule.get("motionKind") or ""
    target = rule.get("targetClass") or ""
    direct_selector = rule.get("directPlacementSelector")
    evidence = rule.get("evidence") or {}
    movement_sig = evidence.get("movementSignature") or "-"
    has_non_reset_movement = movement_sig not in {"-", "direct/s0"}
    write_count = int(evidence.get("writeCount") or 0)
    helper_count = len(evidence.get("helperIds") or [])
    hit_count = int(evidence.get("hitFlagCount") or 0)

    if caster_rule in {"home-approach-target"}:
        bucket = "approach"
        summary = "기본 위치에서 출발해 몬스터 앞까지 돌진한다."
    elif motion == "pierce" or "pierce" in caster_rule:
        bucket = "pierce"
        summary = "대상 기준선을 지나 화면 반대쪽으로 관통한다."
    elif caster_rule in {"clone-spread-direct"}:
        bucket = "clone-spread"
        summary = "시전자 본체보다 분신/분산 배치가 중심이다."
    elif caster_rule in {"screen-or-ranged-all-target-effect", "self-prep-screen-all-target-effect"}:
        bucket = "screen"
        summary = "시전자는 아군측에 머물고 화면/전체 대상 이펙트가 해결한다."
    elif caster_rule in {"caster-local-all-target-action", "all-target-no-promoted-position"}:
        bucket = "home"
        summary = "시전자 위치는 제자리이며 전체 대상 판정만 화면에 적용된다."
    elif caster_rule in {"caster-ranged-effect", "self-prep-ranged-effect"}:
        bucket = "enemy-ranged"
        summary = "몬스터 바로 앞이 아니라 수평 거리를 둔 원거리 시전으로 처리한다."
    elif caster_rule in {"target-side-direct", "target-opposite-side-motion"}:
        bucket = "target-side"
        summary = "몬스터 측면/반대측 기준점에 배치되거나 그쪽으로 이동한다."
    elif caster_rule == "target-fixed-side-direct":
        bucket = "target-fixed-side"
        summary = "화면 고정 X와 대상 Y를 결합한 측면 기준점에 배치된다."
    elif caster_rule in {
        "target-front-direct",
        "target-front-basic-implicit",
        "target-front-implicit-local-combo",
        "self-prep-target-front-local-combo",
    }:
        bucket = "target-front"
        summary = "몬스터 앞 기준점에서 로컬 프레임/타격을 재생한다."
    elif target in {"self", "ally-single", "ally-all"} or caster_rule.endswith("-support"):
        bucket = "support"
        summary = "적 기준점이 아니라 자기/아군측 지원 위치에서 처리한다."
    else:
        bucket = "home"
        summary = "별도 위치 opcode가 없어 보수적으로 기본 위치를 사용한다."

    if has_non_reset_movement or direct_selector is not None:
        evidence_tier = "explicit-0xbc-position"
        evidence_label = "0xbc 위치 opcode 확정"
    elif target in {"self", "ally-single", "ally-all"}:
        evidence_tier = "scope-support-position"
        evidence_label = "지원 범위 확정"
    elif write_count or hit_count:
        evidence_tier = "implicit-actor-vm-position"
        evidence_label = "actor VM 암시 확정"
    elif helper_count:
        evidence_tier = "helper-effect-position"
        evidence_label = "helper/effect 근거"
    else:
        evidence_tier = "scope-default-position"
        evidence_label = "scope 기본값"

    label, description = POSITION_BUCKETS[bucket]
    rule.update(
        {
            "positionBucket": bucket,
            "positionBucketLabel": label,
            "positionBucketDescription": description,
            "battleMapPlacementSummary": summary,
            "evidenceTier": evidence_tier,
            "evidenceTierLabel": evidence_label,
            "hasExplicitMovementOpcode": bool(has_non_reset_movement or direct_selector is not None),
        }
    )
    return rule


def group_rules(rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
    groups: dict[tuple[str, str, str], list[dict[str, Any]]] = defaultdict(list)
    for rule in rules:
        groups[(rule["ownerKey"], rule["familyName"], rule["skillName"])].append(rule)
    result = []
    for (owner, family, skill), items in sorted(groups.items()):
        result.append(
            {
                "ownerKey": owner,
                "ownerName": items[0]["ownerName"],
                "familyName": family,
                "skillName": skill,
                "levels": [
                    {
                        "skillIdHex": item["skillIdHex"],
                        "levelOrFixed": item["levelOrFixed"],
                        "casterRule": item["casterRule"],
                        "startAnchor": item["startAnchor"],
                        "motionKind": item["motionKind"],
                        "motionTarget": item["motionTarget"],
                        "confidence": item["confidence"],
                    }
                    for item in sorted(items, key=lambda row: (row.get("levelOrFixed") is None, row.get("levelOrFixed") or 0, row.get("skillIdHex") or ""))
                ],
                "ruleSet": sorted({item["casterRule"] for item in items}),
                "startAnchors": sorted({item["startAnchor"] for item in items}),
                "motionKinds": sorted({item["motionKind"] for item in items}),
                "positionBuckets": sorted({item["positionBucketLabel"] for item in items}),
                "evidenceTiers": sorted({item["evidenceTierLabel"] for item in items}),
                "confidenceCounts": dict(Counter(item["confidence"] for item in items)),
            }
        )
    return result


def build() -> dict[str, Any]:
    source = json.loads(SRC.read_text(encoding="utf-8"))
    rules = [enrich_rule(derive_rule(row)) for row in source.get("rows") or []]
    groups = group_rules(rules)
    return {
        "version": 1,
        "kind": "hwanse-battle-caster-position-rule-review",
        "source": ["out/battle_movement_pattern_review.json"],
        "status": "per-skill-caster-position-rules",
        "summary": {
            "ruleRows": len(rules),
            "familyGroups": len(groups),
            "casterRuleCounts": dict(Counter(rule["casterRule"] for rule in rules)),
            "startAnchorCounts": dict(Counter(rule["startAnchor"] for rule in rules)),
            "motionKindCounts": dict(Counter(rule["motionKind"] for rule in rules)),
            "confidenceCounts": dict(Counter(rule["confidence"] for rule in rules)),
            "positionBucketCounts": dict(Counter(rule["positionBucketLabel"] for rule in rules)),
            "evidenceTierCounts": dict(Counter(rule["evidenceTierLabel"] for rule in rules)),
            "explicitMovementOpcodeRows": sum(1 for rule in rules if rule.get("hasExplicitMovementOpcode")),
            "correctedFromMovementReview": [
                "single-target local writes plus helper/effect cues are no longer automatically classified as ranged; 호격권 is promoted to implicit target-front combo",
            ],
        },
        "interpretationNotes": [
            "This is a runner-facing semantic layer over battle_movement_pattern_review.",
            "Direct placement selector 3 means target-front. Selector 2 means target-side/orbit. Selector 5 means clone/spread.",
            "Nonzero selector 1 means approach target-front. Nonzero selector 2 means pierce through target; if it follows a hit phase, the start is target-front.",
            "For single-target rows with no movement selector, actor/display local x/y writes are treated as implicit close combo placement at target-front.",
            "Helper/effect cues alone do not force ranged placement. They only imply ranged/effect placement when actor movement/write evidence is absent.",
            "All-target rows with local writes keep the caster on the allied side and resolve hits/effects across enemies; all-target helper-only rows are screen/effect driven.",
            "positionBucket is the user-facing battle-map placement class: 제자리/아군측, 몬스터 앞, 몬스터 측면, 떨어져서 쏨, 맵/화면 전체, 사방팔방/분신, 관통, 돌진/접근, 자기/아군 지원.",
            "evidenceTier separates explicit 0xbc movement math from implicit actor-VM placement. Both are runner-usable, but only explicit-0xbc rows contain direct battle-map coordinate formulas.",
        ],
        "rules": rules,
        "familyGroups": groups,
    }


def md(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Caster Position Rule Review",
        "",
        f"- status: `{report['status']}`",
        f"- rule rows: `{report['summary']['ruleRows']}`",
        f"- family groups: `{report['summary']['familyGroups']}`",
        "",
        "## Notes",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend([
        "",
        "## Family Groups",
        "",
        "| owner | skill | bucket | evidence | rules | anchors | motions | levels |",
        "| --- | --- | --- | --- | --- | --- | --- | --- |",
    ])
    for group in report["familyGroups"]:
        levels = "; ".join(
            f"{item['skillIdHex']} L{item['levelOrFixed']} {item['casterRule']}->{item['startAnchor']}/{item['motionKind']}"
            for item in group["levels"]
        )
        lines.append(
            f"| {group['ownerName']} | {group['skillName']} | `{', '.join(group['positionBuckets'])}` | "
            f"`{', '.join(group['evidenceTiers'])}` | `{', '.join(group['ruleSet'])}` | "
            f"`{', '.join(group['startAnchors'])}` | `{', '.join(group['motionKinds'])}` | {levels} |"
        )
    return "\n".join(lines) + "\n"


def html_page(report: dict[str, Any]) -> str:
    summary = "".join(f"<tr><td>{esc(k)}</td><td><code>{esc(v)}</code></td></tr>" for k, v in report["summary"].items())
    notes = "".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])
    group_rows = []
    for group in report["familyGroups"]:
        levels = "<br>".join(
            f"<code>{esc(item['skillIdHex'])}</code> L{esc(item['levelOrFixed'])} "
            f"<b>{esc(item['casterRule'])}</b> / {esc(item['startAnchor'])} / {esc(item['motionKind'])}"
            for item in group["levels"]
        )
        group_rows.append(
            "<tr>"
            f"<td>{esc(group['ownerName'])}</td>"
            f"<td>{esc(group['skillName'])}</td>"
            f"<td>{esc(', '.join(group['positionBuckets']))}</td>"
            f"<td>{esc(', '.join(group['evidenceTiers']))}</td>"
            f"<td>{esc(', '.join(group['ruleSet']))}</td>"
            f"<td>{esc(', '.join(group['startAnchors']))}</td>"
            f"<td>{esc(', '.join(group['motionKinds']))}</td>"
            f"<td>{levels}</td>"
            "</tr>"
        )
    rule_rows = []
    for rule in report["rules"]:
        ev = rule["evidence"]
        evidence = "<br>".join([
            f"movement: <code>{esc(ev['movementSignature'])}</code>",
            f"old class: <code>{esc(ev['positionClass'])}</code> / <code>{esc(ev['startPlacementPolicy'])}</code>",
            f"writes: {esc(ev['writeCount'])} x={esc(ev['xValues'])} y={esc(ev['yValues'])}",
            f"helpers: {esc(ev['helperIds'])}",
            f"effect WLK: {esc(ev['effectWlkNos'])}",
            f"result WLK: {esc(ev['resultWlkNos'])}",
            f"frames: {esc(ev['frameHead'])}",
        ])
        rule_rows.append(
            "<tr>"
            f"<td>{esc(rule['ownerName'])}</td>"
            f"<td>{esc(rule['skillName'])}<br><code>{esc(rule['skillIdHex'])}</code> L{esc(rule['levelOrFixed'])}</td>"
            f"<td><code>{esc(rule['targetClass'])}</code></td>"
            f"<td><b>{esc(rule['positionBucketLabel'])}</b><br>{esc(rule['battleMapPlacementSummary'])}</td>"
            f"<td><b>{esc(rule['casterRule'])}</b><br>{esc(rule['confidence'])}</td>"
            f"<td>{esc(rule['startAnchor'])}</td>"
            f"<td>{esc(rule['motionKind'])}<br>{esc(rule['motionTarget'])}</td>"
            f"<td>{esc(rule['evidenceTierLabel'])}<br><small>{esc(rule['positionBucketDescription'])}</small></td>"
            f"<td>{evidence}</td>"
            f"<td>{'<br>'.join(esc(note) for note in rule['notes'])}</td>"
            "</tr>"
        )
    css = """
body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;margin:24px;background:#f6f7f9;color:#1f2933}
a{color:#0f5ea8} table{border-collapse:collapse;width:100%;background:white;margin:16px 0}
th,td{border:1px solid #d9dee7;padding:8px;vertical-align:top;font-size:13px} th{background:#eef2f7;text-align:left}
code{background:#eef2f7;padding:1px 4px;border-radius:3px}.wrap{max-width:1500px;margin:0 auto}.muted{color:#667085}
"""
    return f"""<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Battle Caster Position Rule Review</title><style>{css}</style></head>
<body><div class="wrap">
<h1>Battle Caster Position Rule Review</h1>
<p class="muted">기술별 시전자 위치/이동 규칙. runner가 참조하는 최종 semantic layer.</p>
<p><a href="../web/battle_simulator.html">전투 기술 실행 러너</a> · <a href="battle_movement_pattern_review.html">원본 movement pattern</a></p>
<h2>Summary</h2><table><tbody>{summary}</tbody></table>
<h2>Interpretation</h2><ul>{notes}</ul>
<h2>Family Groups</h2><table><thead><tr><th>owner</th><th>skill</th><th>bucket</th><th>evidence</th><th>rules</th><th>anchors</th><th>motions</th><th>levels</th></tr></thead><tbody>{''.join(group_rows)}</tbody></table>
<h2>Rows</h2><table><thead><tr><th>owner</th><th>skill</th><th>target</th><th>bucket</th><th>rule</th><th>start</th><th>motion</th><th>evidence tier</th><th>opcode evidence</th><th>notes</th></tr></thead><tbody>{''.join(rule_rows)}</tbody></table>
</div></body></html>"""


def main() -> None:
    report = build()
    (OUT / "battle_caster_position_rule_review.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    (OUT / "battle_caster_position_rule_review.md").write_text(md(report), encoding="utf-8")
    (OUT / "battle_caster_position_rule_review.html").write_text(html_page(report), encoding="utf-8")
    print(json.dumps(report["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
