"""
play.py -- play against any level of the ladder in the terminal.

    python play.py                       # you are White vs L2
    python play.py --level L3 --color black
    python play.py --level L1 --export-pgn mygame.pgn
    python play.py --ascii               # plain letters instead of chess glyphs

Enter moves in SAN (e4, Nf3, O-O, exd5, e8=Q) or UCI (e2e4, e7e8q).
Commands:  undo  (take back your last move),  moves  (list legal moves),
           hint  (what would the bot play for you),  flip,  help,  quit.
After each bot move you see its evaluation (in pawns, from White's point of
view), search depth, node count and principal variation.
"""
from __future__ import annotations

import argparse
import datetime
import os
import random
import sys
from typing import Callable, Optional

import chess
import chess.pgn

from engine import MATE, is_mate_score, pv_to_san
from ladder import EnginePlayer, RandomPlayer, make_level, WEIGHTS_DIR

GLYPHS = {"P": "♙", "N": "♘", "B": "♗", "R": "♖", "Q": "♕", "K": "♔",
          "p": "♟", "n": "♞", "b": "♝", "r": "♜", "q": "♛", "k": "♚"}


def render(board: chess.Board, white_bottom: bool = True, unicode: bool = True) -> str:
    """Board as text with rank/file labels; last move's squares marked with ( )."""
    last = board.peek() if board.move_stack else None
    marked = {last.from_square, last.to_square} if last else set()
    ranks = range(7, -1, -1) if white_bottom else range(8)
    files = range(8) if white_bottom else range(7, -1, -1)
    lines = []
    for r in ranks:
        row = [f"{r + 1} "]
        for f in files:
            sq = chess.square(f, r)
            p = board.piece_at(sq)
            ch = (GLYPHS[p.symbol()] if unicode else p.symbol()) if p else "·"
            row.append(f"({ch})" if sq in marked else f" {ch} ")
        lines.append("".join(row))
    lines.append("  " + "".join(f" {'abcdefgh'[f]} " for f in files))
    return "\n".join(lines)


def format_score(score_white: float, pawn_unit: float) -> str:
    """Evaluation in pawns (White's view), or 'mate in N'."""
    if is_mate_score(score_white):
        plies = int(MATE - abs(score_white))
        moves = (plies + 1) // 2
        side = "White" if score_white > 0 else "Black"
        return f"{side} mates in {moves}"
    return f"{score_white / pawn_unit:+.2f}"


def parse_move(board: chess.Board, text: str) -> Optional[chess.Move]:
    """Accept SAN first, then UCI.  Returns None if neither parses to a legal move."""
    text = text.strip()
    try:
        return board.parse_san(text)
    except ValueError:
        pass
    try:
        m = chess.Move.from_uci(text.lower())
        return m if m in board.legal_moves else None
    except ValueError:
        return None


def export_pgn(board: chess.Board, path: str, white: str, black: str) -> None:
    game = chess.pgn.Game.from_board(board)
    game.headers["Event"] = "selfplay-chess terminal game"
    game.headers["Date"] = datetime.date.today().strftime("%Y.%m.%d")
    game.headers["White"] = white
    game.headers["Black"] = black
    outcome = board.outcome(claim_draw=True)
    game.headers["Result"] = outcome.result() if outcome else "*"
    with open(path, "a") as fh:
        print(game, file=fh, end="\n\n")


def play(level: str = "L2", color: str = "white", weights_dir: str = WEIGHTS_DIR,
         export: Optional[str] = None, unicode: bool = True, fen: Optional[str] = None,
         input_fn: Callable[[str], str] = input, out: Callable[[str], None] = print,
         seed: Optional[int] = None) -> chess.Board:
    """The interactive loop.  input_fn/out are injectable for testing."""
    rng = random.Random(seed)
    bot = make_level(level, weights_dir, seed=rng.randrange(1 << 30))
    if color == "random":
        color = rng.choice(["white", "black"])
    human = chess.WHITE if color == "white" else chess.BLACK
    board = chess.Board(fen) if fen else chess.Board()
    white_bottom = human == chess.WHITE
    pawn_unit = bot.evaluator.pawn_unit if isinstance(bot, EnginePlayer) else 1.0
    out(f"You play {'White' if human == chess.WHITE else 'Black'} against {bot.name}. "
        f"Type 'help' for commands.")

    while not board.is_game_over(claim_draw=True):
        out("\n" + render(board, white_bottom, unicode))
        if board.turn == human:
            try:
                text = input_fn(f"[{board.fullmove_number}{'.' if board.turn else '...'}] your move: ")
            except EOFError:
                text = "quit"
            cmd = text.strip().lower()
            if cmd in ("quit", "exit", "q"):
                out("Bye.")
                break
            if cmd in ("help", "?"):
                out(__doc__)
                continue
            if cmd == "flip":
                white_bottom = not white_bottom
                continue
            if cmd == "moves":
                out(" ".join(sorted(board.san(m) for m in board.legal_moves)))
                continue
            if cmd == "hint":
                if isinstance(bot, EnginePlayer):
                    _, r = bot.choose(board)
                    out(f"hint: {board.san(r.move)}  (eval {format_score(r.white_score(board), pawn_unit)})")
                else:
                    out("L0 has no opinion.")
                continue
            if cmd == "undo":
                # take back the bot's reply and your move
                n = 2 if len(board.move_stack) >= 2 else len(board.move_stack)
                if board.turn == human and n:
                    for _ in range(n):
                        board.pop()
                    out("Took back your last move.")
                else:
                    out("Nothing to undo.")
                continue
            move = parse_move(board, text)
            if move is None:
                out(f"Not a legal move: {text!r}.  Try SAN (Nf3) or UCI (g1f3); 'moves' lists them.")
                continue
            board.push(move)
        else:
            move, r = bot.choose(board)
            san = board.san(move)
            if r is None:
                out(f"{bot.name} plays {san}")
            else:
                out(f"{bot.name} plays {san}   eval {format_score(r.white_score(board), pawn_unit)} "
                    f"(White's view)   depth {r.depth}, {r.nodes} nodes, {r.seconds:.2f}s")
                out(f"   PV: {pv_to_san(board, r.pv)}")
            board.push(move)

    outcome = board.outcome(claim_draw=True)
    if outcome is not None:
        out("\n" + render(board, white_bottom, unicode))
        out(f"Game over: {outcome.result()} ({outcome.termination.name.lower()})")
    if export:
        names = ("You", bot.name) if human == chess.WHITE else (bot.name, "You")
        export_pgn(board, export, *names)
        out(f"Game appended to {export}")
    return board


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--level", default="L2", choices=["L0", "L1", "L2", "L3", "l0", "l1", "l2", "l3"])
    ap.add_argument("--color", default="white", choices=["white", "black", "random"])
    ap.add_argument("--weights", default=WEIGHTS_DIR, help="directory with material.json/positional.json")
    ap.add_argument("--export-pgn", default=None, help="append the finished game to this PGN file")
    ap.add_argument("--ascii", action="store_true", help="letters instead of Unicode chess glyphs")
    ap.add_argument("--fen", default=None, help="start from this position")
    args = ap.parse_args(argv)
    play(args.level.upper(), args.color, args.weights, args.export_pgn, not args.ascii, args.fen)


if __name__ == "__main__":
    main()
