#!/usr/bin/env python3
"""Smoke-test the monster review frame board."""
from __future__ import annotations

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

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


def wait_for_review(port: int, session_id: str, timeout: float = 10) -> dict:
    script = """
return {
  readyState: document.readyState,
  title: document.title,
  render: window.HWANSE_LAST_MONSTER_REVIEW_RENDER || null,
  frameRows: document.querySelectorAll('#frameStrip [data-frame-index]').length,
  frameDownloadButtons: document.querySelectorAll('#frameStrip [data-frame-png-download]').length,
  visibleFrameRows: document.querySelectorAll('#frameStrip [data-frame-empty="false"]').length,
  auditText: document.getElementById('frameAuditMeta')?.textContent || '',
  boardSummary: document.getElementById('frameBoardSummary')?.textContent || '',
  infoText: document.getElementById('monsterInfo')?.textContent || '',
  chipText: document.getElementById('attackChips')?.textContent || '',
  skillPayloadSummary: document.getElementById('skillPayloadSummary')?.textContent || '',
  skillPayloadStatus: document.getElementById('skillPayloadStatus')?.textContent || '',
  skillPayloadCards: document.querySelectorAll('#skillPayloadRows .skill-payload-card').length,
  skillPayloadMatchedCards: document.querySelectorAll('#skillPayloadRows .skill-payload-card[data-skill-payload-matched="true"]').length,
  skillPayloadCanvases: document.querySelectorAll('#skillPayloadRows canvas').length,
  skillPayloadParameterTables: document.querySelectorAll('#skillPayloadRows .skill-param-table').length,
  titleText: document.getElementById('monsterTitle')?.textContent || '',
  bodyText: document.body?.innerText || '',
  visibleUtilityPanelCount: Array.from(document.querySelectorAll('details.utility-panel'))
    .filter((node) => node.offsetParent !== null).length,
  bootError: window.HWANSE_BOOT_ERROR || null,
};
"""
    deadline = time.monotonic() + timeout
    last_state: dict | None = None
    while time.monotonic() < deadline:
        last_state = execute_js(port, session_id, script)
        render = last_state.get("render") or {}
        if (
            render.get("browserMonsterFrameBoardImplemented")
            and render.get("browserMonsterSkillPayloadPreviewImplemented")
            and render.get("browserMonsterSkillParameterReviewImplemented")
            and render.get("frameRectTablesLoaded")
            and last_state.get("frameRows", 0) > 0
            and last_state.get("skillPayloadParameterTables", 0) > 0
        ):
            return last_state
        time.sleep(0.1)
    raise WebDriverError(f"monster review did not become ready: {last_state!r}")


def wait_for_selected_asset(port: int, session_id: str, asset: 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,
            """
return {
  render: window.HWANSE_LAST_MONSTER_REVIEW_RENDER || null,
  titleText: document.getElementById('monsterTitle')?.textContent || '',
  bodyText: document.body?.innerText || '',
  bootError: window.HWANSE_BOOT_ERROR || null,
};
""",
        )
        render = last_state.get("render") or {}
        if render.get("monsterCatalogLoaded") and render.get("selectedKey") == asset:
            return last_state
        time.sleep(0.1)
    raise WebDriverError(f"monster review did not select {asset}: {last_state!r}")


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

    driver = shutil.which("WebKitWebDriver")
    if not driver:
        raise WebDriverError("WebKitWebDriver is not installed")

    port = free_port()
    proc = subprocess.Popen([driver, f"--port={port}"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    session_id = ""
    try:
        wait_for_driver(port, proc)
        session = request_json(
            port,
            "POST",
            "/session",
            {"capabilities": {"alwaysMatch": {"browserName": "MiniBrowser"}}},
            timeout=30,
        )
        session_id = str(session["value"]["sessionId"])
        request_json(port, "POST", f"/session/{session_id}/window/rect", {"width": 1360, "height": 900}, timeout=10)
        query = urlencode({"asset": args.asset, "attack": "special-or-reaction"})
        request_json(
            port,
            "POST",
            f"/session/{session_id}/url",
            {"url": urljoin(args.base.rstrip("/") + "/", f"web/monster_review.html?{query}")},
            timeout=30,
        )
        state = wait_for_review(port, session_id)
        render = state["render"]
        if render.get("originalEnemyStatRowCount") != 80:
            raise WebDriverError(f"stat table count missing from review: {state!r}")
        if render.get("manualMonsterGuideEntryCount", 0) < 70:
            raise WebDriverError(f"manual guide count missing from review: {state!r}")
        if render.get("frameBoardSkillFrameBound") is not False:
            raise WebDriverError(f"frame board should remain skill-frame unbound: {state!r}")
        if not render.get("frameRectTablesLoaded"):
            raise WebDriverError(f"latest frame rect tables did not load: {state!r}")
        if render.get("frameBoardGeometryMode") != "rect":
            raise WebDriverError(f"monster review is still using fallback frame grid: {state!r}")
        if render.get("browserMonsterFrameBoardUsesLatestGridReviewData") is not True:
            raise WebDriverError(f"latest monster grid review data is not reflected: {state!r}")
        if state.get("visibleFrameRows", 0) <= 0:
            raise WebDriverError(f"no visible frame cells found: {state!r}")
        if render.get("framePngDownloadImplemented") is not True:
            raise WebDriverError(f"frame PNG download controls are missing: {state!r}")
        if state.get("frameDownloadButtons") != state.get("frameRows"):
            raise WebDriverError(f"each frame should have one PNG download button: {state!r}")
        if render.get("framePngDownloadButtonCount") != state.get("frameRows"):
            raise WebDriverError(f"download marker count does not match the frame board: {state!r}")
        png_export = execute_js(
            port,
            session_id,
            """
const button = document.querySelector('#frameStrip [data-frame-png-download]');
const card = button?.closest('[data-frame-index]');
const detail = card?.querySelectorAll('span')[1]?.textContent || '';
let capture = null;
const originalClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function () {
  capture = { href: this.href, download: this.download, target: this.target };
};
try {
  button?.click();
} finally {
  HTMLAnchorElement.prototype.click = originalClick;
}
let pngWidth = 0;
let pngHeight = 0;
if (capture?.href?.startsWith('data:image/png;base64,')) {
  const bytes = atob(capture.href.split(',', 2)[1]);
  const uint32 = (offset) => (
    ((bytes.charCodeAt(offset) << 24) >>> 0)
    | (bytes.charCodeAt(offset + 1) << 16)
    | (bytes.charCodeAt(offset + 2) << 8)
    | bytes.charCodeAt(offset + 3)
  ) >>> 0;
  pngWidth = uint32(16);
  pngHeight = uint32(20);
}
return { capture, detail, pngWidth, pngHeight };
""",
        )
        if not png_export.get("capture"):
            raise WebDriverError(f"PNG download button did not create a link: {png_export!r}")
        if not png_export["capture"].get("download", "").endswith(".png"):
            raise WebDriverError(f"PNG download filename is invalid: {png_export!r}")
        if png_export["capture"].get("target") != "_blank":
            raise WebDriverError(f"mobile fallback target is missing: {png_export!r}")
        native_size = f"{png_export.get('pngWidth')}x{png_export.get('pngHeight')}"
        if native_size not in png_export.get("detail", ""):
            raise WebDriverError(f"PNG export is not using the native rect size: {png_export!r}")
        if "스킬" not in state.get("auditText", "") and "skill" not in state.get("auditText", ""):
            raise WebDriverError(f"frame audit does not expose unbound state: {state!r}")
        if not render.get("browserMonsterSkillPayloadPreviewImplemented"):
            raise WebDriverError(f"skill payload preview did not render: {state!r}")
        if not render.get("browserMonsterSkillParameterReviewImplemented"):
            raise WebDriverError(f"skill parameter review did not render: {state!r}")
        if render.get("battleSkillRecordCount", 0) < 100:
            raise WebDriverError(f"battle skill records are missing from review: {state!r}")
        if render.get("skillPayloadAnimationCanvasCount", 0) != 0 or state.get("skillPayloadCanvases", 0) != 0:
            raise WebDriverError(f"skill payload animation canvases should be removed: {state!r}")
        if render.get("skillPayloadParameterTableCount", 0) <= 0 or state.get("skillPayloadParameterTables", 0) <= 0:
            raise WebDriverError(f"skill parameter tables missing: {state!r}")
        if render.get("skillPayloadProjectionMode") != "removed-animation-parameter-decode":
            raise WebDriverError(f"skill payload projection mode not replaced: {state!r}")
        if render.get("originalMonsterSkillBinding") is not False:
            raise WebDriverError(f"skill preview should remain unpromoted: {state!r}")
        if render.get("battleSkillConsumerStatus") != "consumer-narrowed-pointer-base-indexed-skill-records":
            raise WebDriverError(f"skill consumer trace status missing: {state!r}")
        if render.get("monsterCatalogLoaded") is not True:
            raise WebDriverError(f"monster action catalog did not load: {state!r}")
        if render.get("originalEnemyRowBound") is not True:
            raise WebDriverError(f"selected CNS is not bound to its original enemy row: {state!r}")
        if render.get("originalAttackSequenceBound") is not True:
            raise WebDriverError(f"selected CNS is not bound to its action scripts: {state!r}")
        if render.get("monsterBindingCount") != 1 or render.get("monsterStatRowIndexes") != [58]:
            raise WebDriverError(f"zky_ao should bind to enemy row 58: {state!r}")
        if render.get("monsterNames") != ["창룡성령"]:
            raise WebDriverError(f"canonical monster name is missing: {state!r}")
        if render.get("monsterCoverageBoundStatRowCount") != 80:
            raise WebDriverError(f"all 80 enemy rows should be CNS-bound: {state!r}")
        visible_text = state.get("bodyText", "")
        if "창룡성령" not in visible_text or "몬스터 row 1개 연결" not in visible_text:
            raise WebDriverError(f"bound monster identity is not visible: {state!r}")
        if "몬스터명 미연결" in visible_text or "기술명 미연결" in visible_text:
            raise WebDriverError(f"stale global unbound labels are still visible: {state!r}")
        if "기술 payload 파라미터 해석" not in visible_text:
            raise WebDriverError(f"skill payload parameter panel is not visible: {state!r}")
        if "대상" not in visible_text or "계열" not in visible_text or "상태" not in visible_text:
            raise WebDriverError(f"skill parameter labels are not visible: {state!r}")

        shared_query = urlencode({"asset": "zky_gen"})
        request_json(
            port,
            "POST",
            f"/session/{session_id}/url",
            {"url": urljoin(args.base.rstrip("/") + "/", f"web/monster_review.html?{shared_query}")},
            timeout=30,
        )
        shared_state = wait_for_review(port, session_id)
        shared_render = shared_state["render"]
        if shared_render.get("monsterBindingCount") != 2:
            raise WebDriverError(f"zky_gen shared CNS binding was flattened: {shared_state!r}")
        if shared_render.get("monsterNames") != ["현무성령", "지옥의마인"]:
            raise WebDriverError(f"zky_gen shared monster names are incorrect: {shared_state!r}")
        if shared_render.get("monsterStatRowIndexes") != [59, 66]:
            raise WebDriverError(f"zky_gen stat rows are incorrect: {shared_state!r}")

        unbound_query = urlencode({"asset": "zky_nmk", "scope": "unbound"})
        request_json(
            port,
            "POST",
            f"/session/{session_id}/url",
            {"url": urljoin(args.base.rstrip("/") + "/", f"web/monster_review.html?{unbound_query}")},
            timeout=30,
        )
        unbound_state = wait_for_selected_asset(port, session_id, "zky_nmk")
        unbound_render = unbound_state["render"]
        if unbound_render.get("originalEnemyRowBound") is not False or unbound_render.get("monsterBindingCount") != 0:
            raise WebDriverError(f"review-only zky_nmk should remain unbound: {unbound_state!r}")
        if unbound_render.get("visibleRowCount") != 1:
            raise WebDriverError(f"unbound scope should contain only zky_nmk: {unbound_state!r}")
        print(state)
        return 0
    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()


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