import json
import os
import random
import shutil
import subprocess
from functools import lru_cache
from pathlib import Path

import pytest

from gungi.fen import ADVANCED_POSITION, BEGINNER_POSITION, INTERMEDIATE_POSITION, INTRO_POSITION
from gungi.gungi import Gungi
from gungi.pgn import PGNOptions
from gungi.utils import piece

BETRAYAL_FEN = "8m/9/4d4/5T3/9/9/9/9/M8 D2/- w 1 - 1"
CHECKMATE_FEN = (
    "3img3/1ra1|n:G|1as1/d1fwdwf2/9/8d/9/D1FWDWF1D/1SA1N1AR1/4MI3 J2N2S1R1D1/j2n2s1r1d1 b 1 - 3"
)
STALEMATE_FEN = "8m/9/7DD/7C1/9/9/9/9/M8 -/- b 3 - 1"
INSUFFICIENT_FEN = "m8/9/9/9/9/9/9/9/8M -/- w 3 - 1"
TACTICIAN_ARATA_FEN = "8m/9/9/9/4|d:W|4/9/9/9/M8 T1D1/- w 3 - 1"
TACTICIAN_ONBOARD_FEN = "4m4/9/9/3|s:D|5/4|f:w:T|4/9/9/9/4M4 D1S1T1/- w 3 - 1"


def _js_dir() -> Path:
    configured = os.environ.get("GUNGI_JS_DIR")
    if configured:
        path = Path(configured)
    else:
        path = Path(__file__).resolve().parents[1].parent / "gungi.js"

    if not path.exists():
        raise FileNotFoundError(
            f"gungi.js checkout not found at {path}. Set GUNGI_JS_DIR to the JS repo path."
        )
    return path


def _js_test_environment_error() -> str | None:
    try:
        _js_dir()
    except FileNotFoundError as exc:
        return str(exc)

    missing_tools = [tool for tool in ("node", "pnpm") if shutil.which(tool) is None]
    if missing_tools:
        return "Cross-language parity tests require: " + ", ".join(sorted(missing_tools))

    return None


def _env_int(name: str, default: int, *, minimum: int | None = None) -> int:
    raw = os.environ.get(name)
    if raw is None or raw == "":
        return default

    try:
        value = int(raw)
    except ValueError as exc:
        raise ValueError(f"{name} must be an integer, received {raw!r}") from exc

    if minimum is not None and value < minimum:
        raise ValueError(f"{name} must be >= {minimum}, received {value}")

    return value


pytestmark = pytest.mark.skipif(
    _js_test_environment_error() is not None,
    reason=_js_test_environment_error() or "",
)


@lru_cache
def _ensure_js_build() -> None:
    subprocess.run(
        ["pnpm", "build"],
        cwd=_js_dir(),
        check=True,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="ignore",
    )


def _run_js_sequence(moves, start_fen=BEGINNER_POSITION):
    _ensure_js_build()
    script = f"""
    import {{ Gungi }} from './dist/index.js';
    const moves = {json.dumps(moves)};
    const startFen = {json.dumps(start_fen)};
    const g = new Gungi(startFen);
    const snapshot = () => {{
      const boardIds = [];
      for (let rank = 1; rank <= 9; rank++) {{
        for (let file = 1; file <= 9; file++) {{
          const tower = g.get(`${{rank}}-${{file}}`);
          if (tower) {{
            for (const piece of tower) {{
              if (piece?.id) boardIds.push(piece.id);
            }}
          }}
        }}
      }}
      const handIds = [];
      for (const hp of g.hand()) {{
        if (hp.id) handIds.push(hp.id);
      }}
      return {{ fen: g.fen(), history: g.history(), boardIds, handIds }};
    }};
    const snapshots = [];
    for (const mv of moves) {{
      g.move(mv);
      snapshots.push(snapshot());
    }}
    const finalState = snapshot();
    console.log(JSON.stringify({{ ...finalState, snapshots }}));
    """
    result = subprocess.run(
        ["node", "--input-type=module", "-e", script],
        cwd=_js_dir(),
        check=True,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="ignore",
    )
    return json.loads(result.stdout.strip())


def _inspect_js_position(fen: str, square: str | None = None):
    _ensure_js_build()
    script = f"""
    import {{ Gungi }} from './dist/index.js';
    const g = new Gungi({json.dumps(fen)});
    const square = {json.dumps(square)};
    const moves = square ? g.moves({{ square }}) : g.moves();
    console.log(JSON.stringify({{
      fen: g.fen(),
      turn: g.turn(),
      drafting: g.getDraftingRights(),
      moves,
      inCheck: g.inCheck(),
      isCheckmate: g.isCheckmate(),
      isStalemate: g.isStalemate(),
      isInsufficientMaterial: g.isInsufficientMaterial(),
      isDraw: g.isDraw(),
      isGameOver: g.isGameOver(),
    }}));
    """
    result = subprocess.run(
        ["node", "--input-type=module", "-e", script],
        cwd=_js_dir(),
        check=True,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="ignore",
    )
    return json.loads(result.stdout.strip())


def _run_js_cases(cases: list[dict[str, object]]) -> list[dict[str, object]]:
    _ensure_js_build()
    script = f"""
    import {{ Gungi }} from './dist/index.js';
    const cases = {json.dumps(cases)};

    const compactMove = (move) => ({{
      san: move.san,
      piece: move.piece,
      color: move.color,
      from: move.from ?? '',
      to: move.to,
      type: move.type,
      draftFinished: move.draftFinished ?? null,
      captured: (move.captured ?? [])
        .map((piece) => `${{piece.square}}|${{piece.tier}}|${{piece.color}}|${{piece.type}}`)
        .sort(),
      before: move.before,
      after: move.after,
    }});

    const collectIds = (g) => {{
      const ids = [];
      for (let rank = 1; rank <= 9; rank++) {{
        for (let file = 1; file <= 9; file++) {{
          const tower = g.get(`${{rank}}-${{file}}`);
          if (!tower) continue;
          for (const piece of tower) {{
            if (piece?.id) ids.push(piece.id);
          }}
        }}
      }}
      for (const handPiece of g.hand()) {{
        if (handPiece.id) ids.push(handPiece.id);
      }}
      return ids.sort();
    }};

    const compactState = (g) => ({{
      fen: g.fen(),
      turn: g.turn(),
      moveNumber: g.moveNumber(),
      drafting: g.getDraftingRights(),
      history: g.history(),
      captured: g.captured().map((hp) => `${{hp.color}}|${{hp.type}}|${{hp.count}}`).sort(),
      flags: {{
        inCheck: g.inCheck(),
        isCheckmate: g.isCheckmate(),
        isStalemate: g.isStalemate(),
        isInsufficientMaterial: g.isInsufficientMaterial(),
        isFourfoldRepetition: g.isFourfoldRepetition(),
        isDraw: g.isDraw(),
        isGameOver: g.isGameOver(),
      }},
      ids: collectIds(g),
    }});

    const results = cases.map((testCase) => {{
      const g = new Gungi(testCase.startFen);
      const beforeMoves = [];
      const beforeVerboseMoves = [];
      const appliedMoves = [];
      const afterStates = [];
      for (const move of testCase.moves) {{
        const legalMoves = g.moves({{ verbose: true }});
        beforeMoves.push(legalMoves.map((candidate) => candidate.san));
        beforeVerboseMoves.push(legalMoves.map(compactMove));
        const appliedMove = g.move(move);
        appliedMoves.push(compactMove(appliedMove));
        afterStates.push(compactState(g));
      }}

      const undoStates = [];
      while (g.history().length) {{
        const undoneMove = g.undo();
        undoStates.push({{
          undoneMove: compactMove(undoneMove),
          state: compactState(g),
        }});
      }}

      return {{
        beforeMoves,
        beforeVerboseMoves,
        appliedMoves,
        afterStates,
        undoStates,
        finalState: compactState(g),
      }};
    }});

    console.log(JSON.stringify(results));
    """
    result = subprocess.run(
        ["node", "--input-type=module", "-"],
        cwd=_js_dir(),
        check=True,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="ignore",
        input=script,
    )
    return json.loads(result.stdout.strip())


def _run_js_lifecycle_case(
    *,
    start_fen: str,
    moves: list[str],
    comments: list[str],
    load_fen: str,
) -> dict[str, object]:
    _ensure_js_build()
    script = f"""
    import {{ Gungi }} from './dist/index.js';
    const startFen = {json.dumps(start_fen)};
    const moves = {json.dumps(moves)};
    const comments = {json.dumps(comments)};
    const loadFen = {json.dumps(load_fen)};

    const normalizeComments = (g) =>
      g.getComments()
        .map((entry) => ({{ fen: entry.fen, comment: entry.comment }}))
        .sort((left, right) =>
          left.fen.localeCompare(right.fen) || left.comment.localeCompare(right.comment)
        );

    const snapshot = (g) => ({{
      fen: g.fen(),
      turn: g.turn(),
      moveNumber: g.moveNumber(),
      drafting: g.getDraftingRights(),
      history: g.history(),
      currentComment: g.getComment() ?? null,
      comments: normalizeComments(g),
      pgn: g.pgn(),
      pgnWithComments: g.pgn({{ withComments: true }}),
    }});

    const game = new Gungi(startFen);
    moves.forEach((move, index) => {{
      game.move(move);
      const comment = comments[index];
      if (comment) {{
        game.setComment(comment);
      }}
    }});

    const roundtrip = new Gungi(startFen);
    roundtrip.loadPgn(game.pgn({{ withComments: true }}), startFen);

    const resetGame = new Gungi(startFen);
    resetGame.move(moves[0]);
    resetGame.setComment('temporary');
    resetGame.reset();

    const clearGame = new Gungi(startFen);
    clearGame.move(moves[0]);
    clearGame.setComment('temporary');
    clearGame.clear();

    const loadGame = new Gungi(startFen);
    loadGame.move(moves[0]);
    loadGame.setComment('persisted through load');
    loadGame.load(loadFen);

    console.log(JSON.stringify({{
      afterMoves: snapshot(game),
      afterLoadPgn: snapshot(roundtrip),
      afterReset: snapshot(resetGame),
      afterClear: snapshot(clearGame),
      afterLoad: snapshot(loadGame),
    }}));
    """
    result = subprocess.run(
        ["node", "--input-type=module", "-e", script],
        cwd=_js_dir(),
        check=True,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="ignore",
    )
    return json.loads(result.stdout.strip())


def test_python_matches_js_for_sample_sequence():
    moves = _build_sample_sequence()
    _assert_sequences_match(moves, BEGINNER_POSITION)


def test_capture_stack_and_arata_sequence_matches_js():
    moves = _build_capture_stack_sequence()
    _assert_sequences_match(moves, BEGINNER_POSITION)


def test_betrayal_conversion_sequence_matches_js():
    game = Gungi(BETRAYAL_FEN)
    betrayal_move = next(m.san for m in game.moves(verbose=True) if m.type == "betray")
    _assert_sequences_match([betrayal_move], BETRAYAL_FEN)


def test_terminal_state_flags_match_js_for_tricky_positions():
    for fen in [CHECKMATE_FEN, STALEMATE_FEN, INSUFFICIENT_FEN]:
        py_game = Gungi(fen)
        js_state = _inspect_js_position(fen)

        assert py_game.fen() == js_state["fen"]
        assert py_game.turn() == js_state["turn"]
        assert py_game.get_drafting_rights() == js_state["drafting"]
        assert py_game.moves() == js_state["moves"]
        assert py_game.in_check() == js_state["inCheck"]
        assert py_game.is_checkmate() == js_state["isCheckmate"]
        assert py_game.is_stalemate() == js_state["isStalemate"]
        assert py_game.is_insufficient_material() == js_state["isInsufficientMaterial"]
        assert py_game.is_draw() == js_state["isDraw"]
        assert py_game.is_game_over() == js_state["isGameOver"]


def test_tactician_move_lists_match_js():
    for fen in [TACTICIAN_ARATA_FEN, TACTICIAN_ONBOARD_FEN]:
        py_game = Gungi(fen)
        js_state = _inspect_js_position(fen)
        assert py_game.moves() == js_state["moves"]


def test_archer_move_targets_match_js():
    fen = "8m/9/9/9/4A4/5d3/9/9/M8 -/- w 3 - 1"
    py_game = Gungi(fen)
    js_state = _inspect_js_position(fen, square="5-5")
    assert py_game.moves(square="5-5") == js_state["moves"]


def test_randomized_playouts_and_undo_match_js_across_setup_modes():
    start_positions = [
        INTRO_POSITION,
        BEGINNER_POSITION,
        INTERMEDIATE_POSITION,
        ADVANCED_POSITION,
    ]
    cases_per_mode = _env_int("GUNGI_PARITY_CASES_PER_MODE", 1, minimum=1)
    plies = _env_int("GUNGI_PARITY_PLIES", 20, minimum=1)
    seed_base = _env_int("GUNGI_PARITY_SEED_BASE", 1337)

    cases = [
        _build_random_case(
            start_fen,
            seed=seed_base + start_index * 100 + case_index,
            plies=plies,
        )
        for start_index, start_fen in enumerate(start_positions)
        for case_index in range(cases_per_mode)
    ]

    js_cases = _run_js_cases(cases)

    for index, (py_case, js_case) in enumerate(zip(cases, js_cases, strict=True)):
        assert py_case["beforeMoves"] == js_case["beforeMoves"], (
            f"move list mismatch in case {index}"
        )
        assert py_case["beforeVerboseMoves"] == js_case["beforeVerboseMoves"], (
            f"verbose move mismatch in case {index}"
        )
        assert py_case["appliedMoves"] == js_case["appliedMoves"], (
            f"move result mismatch in case {index}"
        )
        assert py_case["afterStates"] == js_case["afterStates"], f"state mismatch in case {index}"
        assert py_case["undoStates"] == js_case["undoStates"], f"undo mismatch in case {index}"
        assert py_case["finalState"] == js_case["finalState"], (
            f"final state mismatch in case {index}"
        )


def test_comments_pgn_and_lifecycle_methods_match_js():
    start_fen = INTRO_POSITION
    moves = [
        f"{piece['soldier']}(7-1-1)(6-1-1)",
        f"{piece['warrior']}(3-4-1)(4-4-1)",
    ]
    comments = ["Opening move", "Response"]

    game = Gungi(start_fen)
    for move, comment in zip(moves, comments, strict=True):
        game.move(move)
        game.set_comment(comment)

    roundtrip = Gungi(start_fen)
    roundtrip.load_pgn(game.pgn(PGNOptions(with_comments=True)), start_fen)

    reset_game = Gungi(start_fen)
    reset_game.move(moves[0])
    reset_game.set_comment("temporary")
    reset_game.reset()

    clear_game = Gungi(start_fen)
    clear_game.move(moves[0])
    clear_game.set_comment("temporary")
    clear_game.clear()

    load_game = Gungi(start_fen)
    load_game.move(moves[0])
    load_game.set_comment("persisted through load")
    load_game.load(ADVANCED_POSITION)

    js_case = _run_js_lifecycle_case(
        start_fen=start_fen,
        moves=moves,
        comments=comments,
        load_fen=ADVANCED_POSITION,
    )

    assert _snapshot_public_api(game) == js_case["afterMoves"]
    assert _snapshot_public_api(roundtrip) == js_case["afterLoadPgn"]
    assert _snapshot_public_api(reset_game) == js_case["afterReset"]
    assert _snapshot_public_api(clear_game) == js_case["afterClear"]
    assert _snapshot_public_api(load_game) == js_case["afterLoad"]


def _collect_ids(game: Gungi):
    board_ids = []
    for rank in range(1, 10):
        for file in range(1, 10):
            tower = game.get(f"{rank}-{file}")
            if tower:
                for piece_on_board in tower:
                    if piece_on_board and piece_on_board.id:
                        board_ids.append(piece_on_board.id)
    hand_ids = [hp.id for hp in game.hand() if hp.id]
    return board_ids + hand_ids


def _compact_move(move) -> dict[str, object]:
    return {
        "san": move.san,
        "piece": move.piece,
        "color": move.color,
        "from": move.from_,
        "to": move.to,
        "type": move.type,
        "draftFinished": move.draft_finished,
        "captured": sorted(
            f"{captured.square}|{captured.tier}|{captured.color}|{captured.type}"
            for captured in (move.captured or [])
        ),
        "before": move.before,
        "after": move.after,
    }


def _compact_state(game: Gungi) -> dict[str, object]:
    return {
        "fen": game.fen(),
        "turn": game.turn(),
        "moveNumber": game.move_number(),
        "drafting": game.get_drafting_rights(),
        "history": game.history(),
        "captured": sorted(f"{hp.color}|{hp.type}|{hp.count}" for hp in game.captured()),
        "flags": {
            "inCheck": game.in_check(),
            "isCheckmate": game.is_checkmate(),
            "isStalemate": game.is_stalemate(),
            "isInsufficientMaterial": game.is_insufficient_material(),
            "isFourfoldRepetition": game.is_fourfold_repetition(),
            "isDraw": game.is_draw(),
            "isGameOver": game.is_game_over(),
        },
        "ids": sorted(identifier for identifier in _collect_ids(game)),
    }


def _sorted_comments(game: Gungi) -> list[dict[str, str]]:
    return sorted(game.get_comments(), key=lambda item: (item["fen"], item["comment"]))


def _snapshot_public_api(game: Gungi) -> dict[str, object]:
    return {
        "fen": game.fen(),
        "turn": game.turn(),
        "moveNumber": game.move_number(),
        "drafting": game.get_drafting_rights(),
        "history": game.history(),
        "currentComment": game.get_comment(),
        "comments": _sorted_comments(game),
        "pgn": game.pgn(),
        "pgnWithComments": game.pgn(PGNOptions(with_comments=True)),
    }


def _build_random_case(start_fen: str, seed: int, plies: int) -> dict[str, object]:
    rng = random.Random(seed)
    game = Gungi(start_fen)
    moves: list[str] = []
    before_moves: list[list[str]] = []
    before_verbose_moves: list[list[dict[str, object]]] = []
    applied_moves: list[dict[str, object]] = []
    after_states: list[dict[str, object]] = []

    for _ in range(plies):
        legal_moves_verbose = game.moves(verbose=True)
        legal_moves = [move.san for move in legal_moves_verbose]
        before_moves.append(legal_moves)
        before_verbose_moves.append([_compact_move(move) for move in legal_moves_verbose])
        if not legal_moves:
            break

        chosen_move = rng.choice(legal_moves)
        moves.append(chosen_move)
        applied_moves.append(_compact_move(game.move(chosen_move)))
        after_states.append(_compact_state(game))

        if game.is_game_over():
            break

    undo_states: list[dict[str, object]] = []
    while game.history():
        undone_move = game.undo()
        assert undone_move is not None
        undo_states.append(
            {
                "undoneMove": _compact_move(undone_move),
                "state": _compact_state(game),
            }
        )

    return {
        "startFen": start_fen,
        "moves": moves,
        "beforeMoves": before_moves[: len(moves)],
        "beforeVerboseMoves": before_verbose_moves[: len(moves)],
        "appliedMoves": applied_moves,
        "afterStates": after_states,
        "undoStates": undo_states,
        "finalState": _compact_state(game),
    }


def _build_capture_stack_sequence() -> list[str]:
    game = Gungi(BEGINNER_POSITION)
    sequence = [
        f"{piece['soldier']}(7-5-1)(6-5-1)",
        f"{piece['soldier']}(3-5-1)(4-5-1)",
        f"{piece['soldier']}(6-5-1)(5-5-1)",
    ]
    for mv in sequence:
        game.move(mv)

    capture_move = next(m.san for m in game.moves(verbose=True) if m.type == "capture")
    sequence.append(capture_move)
    game.move(capture_move)

    tsuke_move = sorted(m.san for m in game.moves(verbose=True) if m.type == "tsuke")[0]
    sequence.append(tsuke_move)
    game.move(tsuke_move)

    arata_move = sorted(m.san for m in game.moves(verbose=True) if m.type == "arata")[0]
    sequence.append(arata_move)
    return sequence


def _build_sample_sequence() -> list[str]:
    game = Gungi(BEGINNER_POSITION)
    sequence = []

    first_route = sorted(m.san for m in game.moves(verbose=True) if m.type == "route")[0]
    sequence.append(first_route)
    game.move(first_route)

    second_route = sorted(m.san for m in game.moves(verbose=True) if m.type == "route")[0]
    sequence.append(second_route)
    game.move(second_route)

    first_arata = sorted(m.san for m in game.moves(verbose=True) if m.type == "arata")[0]
    sequence.append(first_arata)

    return sequence


def _assert_sequences_match(moves: list[str], start_fen: str) -> None:
    py_game = Gungi(start_fen)
    py_snapshots = []
    for mv in moves:
        py_game.move(mv)
        py_snapshots.append(
            {
                "fen": py_game.fen(),
                "history": py_game.history(),
                "ids": sorted(identifier for identifier in _collect_ids(py_game)),
            }
        )

    js_state = _run_js_sequence(moves, start_fen)

    assert py_game.fen() == js_state["fen"]
    assert py_game.history() == js_state["history"]
    assert sorted(identifier for identifier in _collect_ids(py_game)) == sorted(
        js_state["boardIds"] + js_state["handIds"]
    )

    assert len(py_snapshots) == len(js_state["snapshots"])
    for py_step, js_step in zip(py_snapshots, js_state["snapshots"], strict=True):
        assert py_step["fen"] == js_step["fen"]
        assert py_step["history"] == js_step["history"]
        assert py_step["ids"] == sorted(js_step["boardIds"] + js_step["handIds"])
