"""
Clean local Phutball player app.

This server intentionally keeps the public API small: game setup, legal actions,
custom positions, model catalog, and AI moves. The browser owns presentation and
the branching move tree; all rules and model inference stay here.
"""

from __future__ import annotations

import argparse
import json
import math
import os
import re
import urllib.error
import urllib.request
from collections import deque
from pathlib import Path
from types import SimpleNamespace
from typing import Optional

import numpy as np
from flask import Flask, Response, jsonify, request, send_from_directory
from flask_cors import CORS


APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
ONLINE_STATIC_DIR = APP_DIR / "online_static"
LOCAL_MODEL_DIR = APP_DIR / "models"
ONLINE_SERVER_BASE = "https://phutball.org"

from phutball_game_in_python import BALL, EMPTY, MAN, PhutballGame, PhutballState  # noqa: E402
from phutball_game_in_python.phutball_game import DIRECTIONS  # noqa: E402


app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="/static")
CORS(app, allow_private_network=True)


@app.after_request
def add_private_network_header(response):
    if request.method != "OPTIONS":
        response.headers["Access-Control-Allow-Private-Network"] = "true"
    return response

_NETS = {}
DEFAULT_AI_SIMS = 0
MAX_ANALYSIS_MOVES = 8
MIN_SIZE = 3
MAX_SIZE = 21


class SearchNode:
    def __init__(self, state: PhutballState, action_size: int, parent=None, move: int = -1):
        self.state = state
        self.parent = parent
        self.move = move
        self.is_expanded = False
        self.children = [None] * action_size
        self.child_N = np.zeros(action_size, dtype=np.int32)
        self.child_W = np.zeros(action_size, dtype=np.float32)
        self.child_Q_default = np.zeros(action_size, dtype=np.float32)
        self.child_P = np.zeros(action_size, dtype=np.float32)
        self.legal = np.zeros(action_size, dtype=bool)

    @property
    def child_Q(self) -> np.ndarray:
        out = self.child_Q_default.copy()
        np.divide(self.child_W, self.child_N, out=out, where=self.child_N > 0)
        return out


def make_game(rows: int, cols: int) -> PhutballGame:
    validate_size(rows, cols)
    return PhutballGame(rows=rows, cols=cols, max_rows=rows, max_cols=cols)


def validate_size(rows: int, cols: int) -> None:
    if rows < MIN_SIZE or cols < MIN_SIZE or rows > MAX_SIZE or cols > MAX_SIZE:
        raise ValueError(f"Board size must be between {MIN_SIZE} and {MAX_SIZE}.")
    if rows % 2 == 0 or cols % 2 == 0:
        raise ValueError("Board rows and columns must be odd.")


def ai_modules():
    import torch
    from neural_net_in_python import PhutballNet, PhutballNetConfig, encode_state

    return torch, PhutballNet, PhutballNetConfig, encode_state


def state_from_payload(data: dict) -> PhutballState:
    s = data["state"]
    return PhutballState(
        board=np.array(s["board"], dtype=np.int8),
        is_mid_jump=bool(s["is_mid_jump"]),
        current_player=int(s["current_player"]),
    )


def validate_state(game: PhutballGame, state: PhutballState) -> None:
    if state.board.shape != (game.rows, game.cols):
        raise ValueError(f"Expected board shape {(game.rows, game.cols)}, got {state.board.shape}.")
    values = set(int(v) for v in np.unique(state.board))
    if not values.issubset({EMPTY, MAN, BALL}):
        raise ValueError("Board contains an unknown piece value.")
    if int((state.board == BALL).sum()) != 1:
        raise ValueError("Custom positions must contain exactly one ball.")
    if state.current_player not in (1, -1):
        raise ValueError("current_player must be 1 or -1.")


def model_dirs() -> list[Path]:
    return [LOCAL_MODEL_DIR]


def model_name(path: Path) -> str:
    name = path.name
    if name.endswith(".torchscript.pt"):
        name = name[: -len(".torchscript.pt")]
    elif name.endswith(".pt"):
        name = name[:-3]
    name = re.sub(r"^phutball_\d+x\d+_?", "", name)
    name = re.sub(r"_step\d+$", "", name)
    name = re.sub(r"iter0*(\d+)", r"iter \1", name)
    return name.replace("_", " ")


def model_size(name: str) -> tuple[Optional[int], Optional[int]]:
    match = re.search(r"phutball_(\d+)x(\d+)", name)
    if not match:
        return None, None
    return int(match.group(1)), int(match.group(2))


def list_models() -> list[dict]:
    seen = set()
    models = []
    for directory in model_dirs():
        if not directory.is_dir():
            continue
        for path in directory.iterdir():
            if not path.name.endswith(".pt"):
                continue
            if not path.is_file() or path.name in seen:
                continue
            seen.add(path.name)
            rows, cols = model_size(path.name)
            models.append(
                {
                    "id": path.name,
                    "name": model_name(path),
                    "rows": rows,
                    "cols": cols,
                    "source": "local",
                }
            )
    models.sort(key=lambda m: (m["rows"] or 999, m["cols"] or 999, m["name"]))
    return models


def model_path_for_id(model_id: Optional[str], rows: int, cols: int) -> Path:
    if model_id:
        raw = str(model_id)
        clean = os.path.basename(raw)
        if clean != raw or "/" in raw or "\\" in raw:
            raise FileNotFoundError("Invalid model id.")
        candidates = [directory / clean for directory in model_dirs()]
    else:
        size_models = [m for m in list_models() if m["rows"] == rows and m["cols"] == cols]
        if not size_models:
            raise FileNotFoundError(f"No compatible {rows}x{cols} model. Add a raw .pt checkpoint to phutball-simplified/models.")
        candidates = [directory / size_models[0]["id"] for directory in model_dirs()]

    for path in candidates:
        if path.is_file() and path.name.endswith(".pt"):
            return path
    raise FileNotFoundError(f"Model not found: {model_id or f'{rows}x{cols} default'}")


def torchscript_metadata(path: Path) -> dict:
    metadata_path = path.with_suffix(".json")
    if not metadata_path.is_file():
        return {}
    return json.loads(metadata_path.read_text())


def state_dict_from_checkpoint(checkpoint):
    if not isinstance(checkpoint, dict):
        return checkpoint
    for key in ("model_state_dict", "state_dict", "model_state"):
        if key in checkpoint:
            return checkpoint[key]
    return checkpoint


def config_from_checkpoint(checkpoint, state_dict, rows: int, cols: int):
    _, _, PhutballNetConfig, _ = ai_modules()
    train_config = checkpoint.get("train_config", {}) if isinstance(checkpoint, dict) else {}

    input_channels = int(state_dict["input.board_conv.weight"].shape[1]) + 2
    trunk_ids = [
        int(parts[1])
        for key in state_dict
        if (parts := key.split(".")) and len(parts) > 2 and parts[0] == "trunk" and parts[1].isdigit()
    ]
    blocks = max(trunk_ids) + 1 if trunk_ids else int(train_config.get("blocks", 10))
    channels = int(state_dict["trunk.0.conv1.weight"].shape[0])
    value_conv_channels = int(state_dict["value_conv.weight"].shape[0])
    value_head_hidden = int(state_dict["value_fc1.weight"].shape[0])
    jump_head_hidden = int(state_dict["jump_head.0.weight"].shape[0])
    gpool_weight = state_dict.get("placement_gpool.fc.weight")
    gpool_channels = int(gpool_weight.shape[1] // 2) if gpool_weight is not None else 0
    gpool_interval = int(train_config.get("gpool_interval", 3))

    return PhutballNetConfig(
        rows=rows,
        cols=cols,
        blocks=blocks,
        channels=channels,
        use_batch_norm=bool(train_config.get("use_batch_norm", True)),
        value_head_hidden=value_head_hidden,
        jump_head_hidden=jump_head_hidden,
        gpool_interval=gpool_interval,
        gpool_channels=gpool_channels,
        value_conv_channels=value_conv_channels,
        value_output="logit" if train_config.get("value_loss") == "bce" else "tanh",
        predict_moves_remaining="moves_remaining_fc.weight" in state_dict,
        predict_next_move="next_move_placement_head.weight" in state_dict,
        predict_reachable_ball="reachable_ball_head.weight" in state_dict,
        predict_jump_final_state="jump_final_ball_head.weight" in state_dict,
        use_bfs_input=input_channels == 6,
    )


def net_for_model(rows: int, cols: int, model_id: Optional[str] = None):
    torch, PhutballNet, _, _ = ai_modules()
    path = model_path_for_id(model_id, rows, cols)
    key = (rows, cols, str(path.resolve()))
    if key not in _NETS:
        if path.name.endswith(".torchscript.pt"):
            metadata = torchscript_metadata(path)
            net = torch.jit.load(str(path), map_location="cpu")
            net.eval()
            net.config = SimpleNamespace(
                use_bfs_input=bool(metadata.get("use_bfs_input")),
                bfs_node_limit=int(metadata.get("bfs_node_limit", 10000)),
                bfs_ordering=str(metadata.get("bfs_ordering", "novelty")),
                value_output="tanh",
            )
            _NETS[key] = net
            return _NETS[key]
        checkpoint = torch.load(path, map_location="cpu")
        state_dict = state_dict_from_checkpoint(checkpoint)
        config = config_from_checkpoint(checkpoint, state_dict, rows, cols)
        net = PhutballNet(config)
        net.load_state_dict(state_dict, strict=True)
        net.eval()
        _NETS[key] = net
    return _NETS[key]


def evaluate_net(game: PhutballGame, states: list[PhutballState], model_id: Optional[str]):
    torch, _, _, encode_state = ai_modules()
    net = net_for_model(game.rows, game.cols, model_id)

    encoded = []
    for state in states:
        if getattr(net.config, "use_bfs_input", False):
            encoded.append(encode_state(state, bfs_reachable_for_state(game, state, int(getattr(net.config, "bfs_node_limit", 10000)))))
        else:
            encoded.append(encode_state(state))
    x = torch.stack([torch.from_numpy(item) for item in encoded], dim=0)

    with torch.no_grad():
        outputs = net(x)
        policy_logits = outputs[0]
        value = outputs[1]
        if getattr(net.config, "value_output", "tanh") == "logit":
            value = 2.0 * torch.sigmoid(value) - 1.0

    logits = policy_logits.detach().cpu().numpy()
    values = value.detach().cpu().numpy()
    return [logits[i] for i in range(len(states))], [float(values[i]) for i in range(len(states))]


def bfs_reachable_for_state(game: PhutballGame, state: PhutballState, node_limit: int) -> np.ndarray:
    rows, cols = game.rows, game.cols
    board = state.board
    ball_positions = np.argwhere(board == BALL)
    if len(ball_positions) != 1 or node_limit <= 0:
        return np.zeros((rows, cols), dtype=np.float32)

    start = tuple(int(x) for x in ball_positions[0])
    start_men = frozenset(
        (int(r), int(c))
        for r, c in np.argwhere(board == MAN)
    )
    reachable = np.zeros((rows, cols), dtype=np.float32)
    reachable[start] = 1.0
    queue = deque([(start, start_men)])
    seen = {(start, start_men)}
    nodes = 0

    while queue and nodes < node_limit:
        (br, bc), men = queue.popleft()
        nodes += 1
        for dr, dc in DIRECTIONS:
            r, c = br + dr, bc + dc
            if not (0 <= r < rows and 0 <= c < cols) or (r, c) not in men:
                continue

            jumped = []
            while 0 <= r < rows and 0 <= c < cols and (r, c) in men:
                jumped.append((r, c))
                r += dr
                c += dc
            if not (0 <= r < rows and 0 <= c < cols):
                continue

            next_ball = (r, c)
            next_men = frozenset(cell for cell in men if cell not in jumped)
            reachable[next_ball] = 1.0
            key = (next_ball, next_men)
            if key not in seen:
                seen.add(key)
                queue.append(key)
    return reachable


def policy_from_logits(logits: np.ndarray, legal: np.ndarray) -> np.ndarray:
    masked = logits.astype(np.float64, copy=True)
    masked[~legal] = -np.inf
    if not np.isfinite(masked).any():
        prior = legal.astype(np.float32)
        total = prior.sum()
        return prior / total if total > 0 else prior
    max_logit = np.max(masked[np.isfinite(masked)])
    exp = np.zeros_like(masked, dtype=np.float64)
    exp[legal] = np.exp(masked[legal] - max_logit)
    total = exp.sum()
    if total <= 0:
        prior = legal.astype(np.float32)
        total = prior.sum()
        return prior / total if total > 0 else prior
    return (exp / total).astype(np.float32)


def expand_node(game: PhutballGame, node: SearchNode, model_id: Optional[str]) -> float:
    legal = game.legal_moves(node.state).astype(bool)
    node.legal = legal
    if not legal.any():
        node.is_expanded = True
        return game.terminal_value(node.state) if game.is_terminal(node.state) else 0.0
    logits, values = evaluate_net(game, [node.state], model_id)
    node.child_Q_default[:] = 0.0
    node.child_Q_default[legal] = float(values[0])
    node.child_P = policy_from_logits(logits[0], legal)
    node.is_expanded = True
    return values[0]


def backup(node: SearchNode, value: float) -> None:
    while node.parent is not None:
        parent = node.parent
        parent_value = value if parent.state.current_player == node.state.current_player else -value
        parent.child_N[node.move] += 1
        parent.child_W[node.move] += parent_value
        value = parent_value
        node = parent


def select_child(game: PhutballGame, node: SearchNode, c_puct: float) -> SearchNode:
    total_n = max(1, int(node.child_N.sum()))
    scores = node.child_Q + c_puct * node.child_P * math.sqrt(total_n) / (1.0 + node.child_N)
    scores[~node.legal] = -np.inf
    action = int(np.argmax(scores))
    child = node.children[action]
    if child is None:
        child = SearchNode(game.next_state(node.state, action), game.action_size, parent=node, move=action)
        node.children[action] = child
    return child


def analysis_from_policy(
    game: PhutballGame,
    state: PhutballState,
    policy: np.ndarray,
    value: float,
    label: str,
    q_values: Optional[np.ndarray] = None,
) -> dict:
    legal = game.legal_moves(state).astype(bool)
    ranked = sorted(np.flatnonzero(legal), key=lambda action: policy[action], reverse=True)[:MAX_ANALYSIS_MOVES]
    return {
        "available": True,
        "value": float(value),
        "label": label,
        "moves": [
            {
                "action": int(action),
                "label": describe_action(game, int(action), state),
                "policy": float(policy[action]),
                "q": float(q_values[action]) if q_values is not None else None,
            }
            for action in ranked
        ],
    }


def analysis_for_state(game: PhutballGame, state: PhutballState, model_id: Optional[str]) -> dict:
    try:
        legal = game.legal_moves(state).astype(bool)
        if not legal.any():
            return {"available": False, "error": "No legal moves."}
        logits, values = evaluate_net(game, [state], model_id)
        policy = policy_from_logits(logits[0], legal)
        return analysis_from_policy(game, state, policy, values[0], "Policy head")
    except FileNotFoundError as exc:
        return {"available": False, "error": str(exc)}
    except Exception as exc:
        return {"available": False, "error": f"AI analysis failed: {exc}"}


def run_mcts(game: PhutballGame, state: PhutballState, sims: int, model_id: Optional[str]) -> tuple[int, dict]:
    root = SearchNode(state, game.action_size)
    root_value = expand_node(game, root, model_id)
    if not root.legal.any():
        raise ValueError("AI was asked to move in a state with no legal moves.")
    for _ in range(max(0, sims)):
        node = root
        depth = 0
        while node.is_expanded and not game.is_terminal(node.state):
            if depth >= 2000:
                backup(node, 0.0)
                break
            node = select_child(game, node, 1.4)
            depth += 1
        else:
            value = game.terminal_value(node.state) if game.is_terminal(node.state) else expand_node(game, node, model_id)
            backup(node, value)

    visits = np.where(root.legal, root.child_N, 0).astype(np.float32)
    action = int(np.argmax(np.where(root.legal, root.child_N, -1)))
    total = float(visits.sum())
    search_policy = visits / total if total > 0 else root.child_P
    visited = root.child_N > 0
    search_value = (
        float(np.sum(root.child_Q[visited] * root.child_N[visited]) / np.sum(root.child_N[visited]))
        if visited.any()
        else float(root_value)
    )
    return action, analysis_from_policy(game, state, search_policy, search_value, f"MCTS {sims} sims", root.child_Q)


def choose_ai_action(game: PhutballGame, state: PhutballState, sims: int, model_id: Optional[str]) -> tuple[int, dict]:
    if sims <= 0:
        legal = game.legal_moves(state).astype(bool)
        logits, values = evaluate_net(game, [state], model_id)
        policy = policy_from_logits(logits[0], legal)
        action = int(np.argmax(np.where(legal, logits[0], -np.inf)))
        return action, analysis_from_policy(game, state, policy, values[0], "Policy head")
    return run_mcts(game, state, sims, model_id)


def disp_row(canonical_r: int, player: int, rows: int) -> int:
    return rows - 1 - canonical_r if player == -1 else canonical_r


def display_direction_name(dir_idx: int, player: int) -> str:
    names = {
        (-1, 0): "N",
        (-1, 1): "NE",
        (0, 1): "E",
        (1, 1): "SE",
        (1, 0): "S",
        (1, -1): "SW",
        (0, -1): "W",
        (-1, -1): "NW",
    }
    dr, dc = DIRECTIONS[dir_idx]
    if player == -1:
        dr = -dr
    return names[(dr, dc)]


def describe_action(game: PhutballGame, action: int, state: PhutballState) -> str:
    player = state.current_player
    if action < game.n_placements:
        cr, c = divmod(action, game.cols)
        return f"Placed ({disp_row(cr, player, game.rows)},{c})"
    if action < game.end_turn_action:
        ball_r, ball_c = game._find_ball(state.board)
        dr, dc = DIRECTIONS[action - game.jump_offset]
        land_r, land_c, _, off_board = game._can_jump(state.board, ball_r, ball_c, dr, dc)
        if off_board:
            return "Winning jump"
        return f"Jumped ({disp_row(land_r, player, game.rows)},{land_c})"
    return "Ended jump"


def build_response(game: PhutballGame, state: PhutballState, model_id: Optional[str] = None, include_ai: bool = True) -> dict:
    validate_state(game, state)
    player = state.current_player
    display_board = np.flip(state.board, axis=0).copy() if player == -1 else state.board
    valid = game.legal_moves(state)
    ball_r, ball_c = game._find_ball(state.board)

    placements = []
    for action in range(game.n_placements):
        if valid[action]:
            cr, c = divmod(action, game.cols)
            placements.append({"row": disp_row(cr, player, game.rows), "col": c, "action": action})

    jumps = []
    for i, (dr, dc) in enumerate(DIRECTIONS):
        action = game.jump_offset + i
        if valid[action]:
            land_r, land_c, _, off_board = game._can_jump(state.board, ball_r, ball_c, dr, dc)
            jumps.append(
                {
                    "to_row": disp_row(land_r, player, game.rows),
                    "to_col": max(-1, min(game.cols, land_c)),
                    "action": action,
                    "off_board": off_board,
                }
            )

    terminal = game.is_terminal(state)
    winner = None
    if terminal:
        terminal_value = game.terminal_value(state)
        canonical_winner = player if terminal_value > 0 else -player
        winner = 1 if canonical_winner == 1 else 2

    return {
        "state": {
            "board": state.board.tolist(),
            "is_mid_jump": state.is_mid_jump,
            "current_player": player,
        },
        "display_board": display_board.tolist(),
        "legal_placements": placements,
        "legal_jumps": jumps,
        "can_end_turn": bool(valid[game.end_turn_action]),
        "end_turn_action": game.end_turn_action,
        "is_mid_jump": state.is_mid_jump,
        "is_terminal": terminal,
        "winner": winner,
        "current_player": player,
        "rows": game.rows,
        "cols": game.cols,
        "ai_analysis": analysis_for_state(game, state, model_id) if include_ai else {"available": False, "error": "AI analysis was not requested."},
    }


def attach_visual_ball_landing(game: PhutballGame, response: dict, before_state: PhutballState, action: int) -> None:
    if action < game.jump_offset or action >= game.jump_offset + len(DIRECTIONS):
        return
    direction_index = action - game.jump_offset
    ball_r, ball_c = game._find_ball(before_state.board)
    land_r, land_c, _, off_board = game._can_jump(
        before_state.board,
        ball_r,
        ball_c,
        *DIRECTIONS[direction_index],
    )
    if not off_board:
        return
    display_row = disp_row(land_r, before_state.current_player, game.rows)
    response["visual_ball"] = {
        "row": -1 if display_row < 0 else game.rows,
        "col": max(-1, min(game.cols, land_c)),
    }


@app.errorhandler(ValueError)
def handle_value_error(exc):
    return jsonify({"error": str(exc)}), 400


@app.errorhandler(FileNotFoundError)
def handle_not_found(exc):
    return jsonify({"error": str(exc)}), 404


@app.route("/")
def index():
    return send_from_directory(STATIC_DIR, "start.html")


@app.route("/local")
@app.route("/local/")
def local_index():
    return send_from_directory(STATIC_DIR, "index.html")


@app.route("/online")
@app.route("/online/")
def online_index():
    return send_from_directory(ONLINE_STATIC_DIR, "index.html")


@app.route("/online-static/<path:filename>")
def online_static(filename):
    return send_from_directory(ONLINE_STATIC_DIR, filename)


@app.route("/online/<path:remote_path>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
def online_proxy(remote_path):
    if request.method == "OPTIONS":
        return Response(status=204)
    query = f"?{request.query_string.decode('utf-8')}" if request.query_string else ""
    url = f"{ONLINE_SERVER_BASE}/online/{remote_path}{query}"
    headers = {"User-Agent": "phutball-simplified-local-companion"}
    if request.content_type:
        headers["Content-Type"] = request.content_type
    req = urllib.request.Request(url, data=request.get_data() or None, headers=headers, method=request.method)
    try:
        with urllib.request.urlopen(req, timeout=20) as res:
            body = res.read()
            content_type = res.headers.get("Content-Type", "application/json")
            return Response(body, status=res.status, content_type=content_type)
    except urllib.error.HTTPError as exc:
        body = exc.read()
        content_type = exc.headers.get("Content-Type", "application/json")
        return Response(body, status=exc.code, content_type=content_type)
    except urllib.error.URLError as exc:
        return jsonify({"error": f"Could not reach phutball.org: {exc.reason}"}), 502


@app.route("/models")
def models():
    return jsonify({"models": list_models()})


@app.route("/health")
def health():
    return jsonify({"ok": True, "service": "phutball-simplified"})


@app.route("/new_game", methods=["POST"])
def new_game():
    data = request.get_json() or {}
    rows = int(data.get("rows", 19))
    cols = int(data.get("cols", 19))
    model_id = data.get("model_id")
    include_ai = bool(data.get("include_ai", True))
    game = make_game(rows, cols)
    return jsonify(build_response(game, game.initial_state(), model_id, include_ai=include_ai))


@app.route("/action", methods=["POST"])
def action():
    data = request.get_json() or {}
    game = make_game(int(data["rows"]), int(data["cols"]))
    state = state_from_payload(data)
    validate_state(game, state)
    new_state = game.next_state(state, int(data["action"]))
    include_ai = bool(data.get("include_ai", True))
    return jsonify(build_response(game, new_state, data.get("model_id"), include_ai=include_ai))


@app.route("/set_state", methods=["POST"])
def set_state():
    data = request.get_json() or {}
    game = make_game(int(data["rows"]), int(data["cols"]))
    state = state_from_payload(data)
    validate_state(game, state)
    include_ai = bool(data.get("include_ai", True))
    return jsonify(build_response(game, state, data.get("model_id"), include_ai=include_ai))


@app.route("/ai_move", methods=["POST"])
def ai_move():
    data = request.get_json() or {}
    game = make_game(int(data["rows"]), int(data["cols"]))
    state = state_from_payload(data)
    validate_state(game, state)
    sims = max(0, int(data.get("mcts_sims", DEFAULT_AI_SIMS)))
    model_id = data.get("model_id")

    if game.is_terminal(state):
        return jsonify(build_response(game, state, model_id))

    try:
        action_id, analysis = choose_ai_action(game, state, sims, model_id)
    except FileNotFoundError as exc:
        return jsonify({"error": str(exc)}), 404
    except Exception as exc:
        return jsonify({"error": f"AI model failed: {exc}"}), 500

    new_state = game.next_state(state, action_id)
    response = build_response(game, new_state, model_id)
    response["ai_action"] = action_id
    response["ai_search_analysis"] = analysis
    response["mcts_sims"] = sims
    return jsonify(response)


@app.route("/ai_analysis", methods=["POST"])
def ai_analysis():
    data = request.get_json() or {}
    game = make_game(int(data["rows"]), int(data["cols"]))
    state = state_from_payload(data)
    validate_state(game, state)
    sims = max(0, int(data.get("mcts_sims", 0)))
    model_id = data.get("model_id")
    if sims > 0:
        try:
            _, analysis = choose_ai_action(game, state, sims, model_id)
        except FileNotFoundError as exc:
            analysis = {"available": False, "error": str(exc)}
        except Exception as exc:
            analysis = {"available": False, "error": f"AI analysis failed: {exc}"}
        return jsonify({"analysis": analysis, "mcts_sims": sims})
    return jsonify({"analysis": analysis_for_state(game, state, model_id), "mcts_sims": 0})


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8080)
    args = parser.parse_args()
    app.run(host=args.host, port=args.port, debug=False, use_reloader=False)
