#!/usr/bin/env python3
"""Smoke-test field_character_review.html character grid and foot-tile overlay."""
from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import time
from pathlib import Path
from urllib.parse import urlencode, urljoin

from browser_smoke_helpers import (
    WebDriverError,
    execute_js,
    free_port,
    request_json,
    wait_for_driver,
)


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


def state_script() -> str:
    return """
const marker = window.HWANSE_LAST_FIELD_CHARACTER_REVIEW || null;
return {
  readyState: document.readyState,
  title: document.title,
  marker,
  assetOptions: document.querySelectorAll('#assetSelect option').length,
  frameCards: document.querySelectorAll('#frameStrip .frame-card').length,
  transparentFrameCards: document.querySelectorAll('#transparentFrameStrip .transparent-frame-card').length,
  transparentFrameCanvases: Array.from(document.querySelectorAll('#transparentFrameStrip canvas')).map((canvas) => ({
    width: canvas.width,
    height: canvas.height,
    clean: canvas.dataset.cleanTransparentFrame,
    transparentPixels: (() => {
      const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
      let count = 0;
      for (let index = 3; index < pixels.length; index += 4) if (pixels[index] === 0) count += 1;
      return count;
    })(),
    visiblePixels: (() => {
      const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
      let count = 0;
      for (let index = 3; index < pixels.length; index += 4) if (pixels[index] > 0) count += 1;
      return count;
    })(),
  })),
  frameRows: document.querySelectorAll('#frameTable tr').length,
  specialRows: document.querySelectorAll('#specialTable tr').length,
  previewWidth: document.getElementById('previewCanvas')?.width || 0,
  previewHeight: document.getElementById('previewCanvas')?.height || 0,
  sheetWidth: document.getElementById('sheetCanvas')?.width || 0,
  sheetHeight: document.getElementById('sheetCanvas')?.height || 0,
  assetListClientHeight: document.getElementById('assetList')?.clientHeight || 0,
  assetListScrollHeight: document.getElementById('assetList')?.scrollHeight || 0,
  assetListOverflowY: getComputedStyle(document.getElementById('assetList')).overflowY,
  assetListSummary: document.getElementById('assetListSummary')?.textContent || '',
  gridStatus: document.getElementById('gridStatus')?.textContent || '',
  touchAction: getComputedStyle(document.getElementById('previewCanvas')).touchAction,
  bodyText: document.body?.innerText || '',
  bootError: window.HWANSE_BOOT_ERROR || null,
};
"""


def wait_for_review(port: int, session_id: str, timeout: float = 10) -> dict:
    deadline = time.monotonic() + timeout
    last_state: dict | None = None
    while time.monotonic() < deadline:
        last_state = execute_js(port, session_id, state_script(), timeout=3)
        marker = last_state.get("marker") or {}
        if (
            last_state.get("readyState") == "complete"
            and marker.get("browserFieldCharacterReviewImplemented") is True
            and marker.get("ready") is True
            and marker.get("assetCount", 0) >= 37
            and marker.get("frameWidth") == 48
            and marker.get("frameHeight") == 64
            and marker.get("directionFrameCount") == 5
            and last_state.get("previewWidth", 0) > 0
            and last_state.get("sheetWidth", 0) > 0
        ):
            return last_state
        time.sleep(0.2)
    raise WebDriverError(f"field character review did not become ready: {last_state!r}")


def wait_for_selection(port: int, session_id: str, asset: str, direction: str, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    last_state: dict | None = None
    while time.monotonic() < deadline:
        last_state = execute_js(port, session_id, state_script(), timeout=3)
        marker = last_state.get("marker") or {}
        if marker.get("asset") == asset and marker.get("direction") == direction:
            return last_state
        time.sleep(0.2)
    raise WebDriverError(f"field character review selection did not settle: {last_state!r}")


def wait_for_custom_sequence(port: int, session_id: str, first_index: int, timeout: float = 8) -> dict:
    deadline = time.monotonic() + timeout
    last_state: dict | None = None
    while time.monotonic() < deadline:
        last_state = execute_js(port, session_id, state_script(), timeout=3)
        marker = last_state.get("marker") or {}
        sequence = marker.get("animationSequence") or []
        if marker.get("animationSequenceMode") == "custom" and sequence and sequence[0].get("index") == first_index:
            return last_state
        time.sleep(0.2)
    raise WebDriverError(f"field character review custom sequence did not settle: {last_state!r}")


def write_report(payload: dict) -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "field_character_review_browser_smoke.json").write_text(
        json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )


def verify_browser(base: str, asset: str) -> None:
    driver_path = shutil.which("WebKitWebDriver")
    if not driver_path:
        raise WebDriverError("WebKitWebDriver is not installed; install webkit2gtk-driver and run under Xvfb")

    port = free_port()
    log_path = OUT / "field_character_review_webkitdriver.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with log_path.open("wb") as log:
        proc = subprocess.Popen(
            [
                driver_path,
                "--host=127.0.0.1",
                f"--port={port}",
                "--replace-on-new-session",
            ],
            stdout=log,
            stderr=subprocess.STDOUT,
        )
        session_id = ""
        try:
            wait_for_driver(port, proc)
            session = request_json(
                port,
                "POST",
                "/session",
                {"capabilities": {"alwaysMatch": {"browserName": "MiniBrowser"}}},
                timeout=30,
            )
            session_id = str((session or {}).get("value", {}).get("sessionId") or "")
            if not session_id:
                raise WebDriverError(f"could not create WebKit session: {session!r}")
            request_json(
                port,
                "POST",
                f"/session/{session_id}/window/rect",
                {"x": 0, "y": 0, "width": 1360, "height": 900},
                timeout=8,
            )
            query = urlencode({"asset": asset, "direction": "down"})
            url = urljoin(base.rstrip("/") + "/", f"web/field_character_review.html?{query}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": url}, timeout=30)
            initial_state = wait_for_review(port, session_id)
            marker = initial_state["marker"]
            if marker.get("asset") != asset:
                raise WebDriverError(f"unexpected asset loaded: {initial_state!r}")
            if marker.get("footprintRule") != "bottom-3x1-horizontal-footprint":
                raise WebDriverError(f"footprint marker mismatch: {initial_state!r}")
            if marker.get("footRect") != {"x": 0, "y": 48, "w": 48, "h": 16}:
                raise WebDriverError(f"foot rect mismatch: {initial_state!r}")
            if marker.get("spriteTileWidth") != 3:
                raise WebDriverError(f"sprite tile width mismatch: {initial_state!r}")
            if marker.get("customSequenceImplemented") is not True or marker.get("customSequenceFreeStart") is not True:
                raise WebDriverError(f"custom sequence marker missing: {initial_state!r}")
            if marker.get("canonicalCnsRectDataApplied") is not True:
                raise WebDriverError(f"canonical CNS rect marker missing: {initial_state!r}")
            if marker.get("opaqueBboxOverlayRemoved") is not True:
                raise WebDriverError(f"opaque bbox removal marker missing: {initial_state!r}")
            if marker.get("allCaraRowsLoadedFromCnsRectReview") is not True:
                raise WebDriverError(f"all cara rect load marker missing: {initial_state!r}")
            if marker.get("manualGridControlsImplemented") is not True:
                raise WebDriverError(f"manual grid controls marker missing: {initial_state!r}")
            if (
                marker.get("transparentFrameExportImplemented") is not True
                or marker.get("transparentFrameExportNativeSize") is not True
                or marker.get("transparentFrameExportOverlaysExcluded") is not True
            ):
                raise WebDriverError(f"transparent frame export marker missing: {initial_state!r}")
            if marker.get("assetListScrollApplied") is not True or marker.get("hiddenAssetFilterImplemented") is not True:
                raise WebDriverError(f"asset list scroll/filter marker missing: {initial_state!r}")
            if marker.get("confirmedCharacterCount", 0) < 36 or marker.get("reviewRequiredCharacterCount", 0) > 1:
                raise WebDriverError(f"confirmed/review-required character counts mismatch: {initial_state!r}")
            if marker.get("forcedInitialFrameZero") is not False:
                raise WebDriverError(f"field character review should not force #0 as first animation frame: {initial_state!r}")
            if "하단 48x16 충돌 발판" not in initial_state.get("bodyText", ""):
                raise WebDriverError(f"foot tile label missing: {initial_state!r}")
            if "pan" not in str(initial_state.get("touchAction") or ""):
                raise WebDriverError(f"canvas touch-action should favor scroll: {initial_state!r}")
            if initial_state.get("assetOptions", 0) < 37 or initial_state.get("frameCards") != 5:
                raise WebDriverError(f"asset/frame UI did not render: {initial_state!r}")
            if initial_state.get("transparentFrameCards") != 5:
                raise WebDriverError(f"transparent frame export cards did not render: {initial_state!r}")
            transparent_canvases = initial_state.get("transparentFrameCanvases") or []
            if not transparent_canvases or any(
                row.get("width") != 48
                or row.get("height") != 64
                or row.get("clean") != "true"
                or row.get("transparentPixels", 0) <= 0
                or row.get("visiblePixels", 0) <= 0
                for row in transparent_canvases
            ):
                raise WebDriverError(f"transparent frame canvases are not native clean rects: {initial_state!r}")
            if initial_state.get("assetListOverflowY") not in {"auto", "scroll"}:
                raise WebDriverError(f"asset list should be independently scrollable: {initial_state!r}")
            if initial_state.get("assetListScrollHeight", 0) <= initial_state.get("assetListClientHeight", 0):
                raise WebDriverError(f"asset list should have scrollable overflow with 37 rows: {initial_state!r}")
            if initial_state.get("specialRows", 0) <= 0:
                raise WebDriverError(f"special frame table did not render: {initial_state!r}")

            changed = execute_js(
                port,
                session_id,
                """
const dir = document.getElementById('directionSelect');
dir.value = 'left';
dir.dispatchEvent(new Event('change', { bubbles: true }));
const asset = document.getElementById('assetSelect');
asset.value = 'cara_rs1';
asset.dispatchEvent(new Event('change', { bubbles: true }));
return true;
""",
                timeout=5,
            )
            if changed is not True:
                raise WebDriverError("could not change character review selection")
            changed_state = wait_for_selection(port, session_id, "cara_rs1", "left")
            changed_marker = changed_state["marker"]
            if changed_marker.get("frameWidth") != 48 or changed_marker.get("directionFrameCount") != 5:
                raise WebDriverError(f"selection change did not preserve frame metadata: {changed_state!r}")

            custom = execute_js(
                port,
                session_id,
                """
const seq = document.getElementById('customSequenceInput');
seq.value = '2,12,2,14';
document.getElementById('applySequenceButton')?.click();
return true;
""",
                timeout=5,
            )
            if custom is not True:
                raise WebDriverError("could not apply custom sequence")
            custom_state = wait_for_custom_sequence(port, session_id, 2)
            custom_marker = custom_state["marker"]
            if custom_marker.get("forcedInitialFrameZero") is not False:
                raise WebDriverError(f"custom sequence should allow non-zero first frame: {custom_state!r}")
            if [step.get("index") for step in custom_marker.get("animationSequence", [])[:4]] != [2, 12, 2, 14]:
                raise WebDriverError(f"custom sequence order mismatch: {custom_state!r}")

            rect_review = execute_js(
                port,
                session_id,
                """
const seq = document.getElementById('customSequenceInput');
seq.value = '';
const asset = document.getElementById('assetSelect');
asset.value = 'cara_01';
asset.dispatchEvent(new Event('change', { bubbles: true }));
return true;
""",
                timeout=5,
            )
            if rect_review is not True:
                raise WebDriverError("could not select canonical character rect row")
            candidate_state = wait_for_selection(port, session_id, "cara_01", "T0")
            candidate_marker = candidate_state["marker"]
            if candidate_marker.get("candidate") is not False or candidate_marker.get("confirmed") is not True:
                raise WebDriverError(f"canonical character rect row did not render as confirmed: {candidate_state!r}")
            if candidate_marker.get("classification") != "exe-source-rect-table-union":
                raise WebDriverError(f"canonical rect classification mismatch: {candidate_state!r}")
            if candidate_marker.get("confidence") != "confirmed":
                raise WebDriverError(f"canonical rect row confidence mismatch: {candidate_state!r}")
            if candidate_marker.get("frameWidth") != 48 or candidate_marker.get("directionFrameCount", 0) <= 0:
                raise WebDriverError(f"canonical character frame count mismatch: {candidate_state!r}")

            manual_grid = execute_js(
                port,
                session_id,
                """
document.getElementById('gridWInput').value = '32';
document.getElementById('gridHInput').value = '64';
document.getElementById('gridColsInput').value = '2';
document.getElementById('gridRowsInput').value = '1';
document.getElementById('applyGridButton')?.click();
return true;
""",
                timeout=5,
            )
            if manual_grid is not True:
                raise WebDriverError("could not apply manual candidate grid")
            manual_state = wait_for_selection(port, session_id, "cara_01", "row0")
            manual_marker = manual_state["marker"]
            if manual_marker.get("frameWidth") != 32 or manual_marker.get("directionFrameCount") != 2:
                raise WebDriverError(f"manual candidate grid did not apply: {manual_state!r}")
            if manual_marker.get("classification") != "confirmed-overridden-manual-grid":
                raise WebDriverError(f"manual grid classification mismatch: {manual_state!r}")

            report = {
                "status": "ok",
                "initial": initial_state,
                "changed": changed_state,
                "custom": custom_state,
                "candidate": candidate_state,
                "manual": manual_state,
            }
            write_report(report)
            print(report)
        finally:
            if session_id:
                try:
                    request_json(port, "DELETE", f"/session/{session_id}", timeout=5)
                except Exception:
                    pass
            proc.terminate()
            try:
                proc.wait(timeout=3)
            except subprocess.TimeoutExpired:
                proc.kill()


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--base", default="http://127.0.0.1:8013")
    parser.add_argument("--asset", default="cara_at1")
    args = parser.parse_args()
    verify_browser(args.base, args.asset)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
