#!/usr/bin/env python3
"""
AleFraJav Engine Arena — motor de EJEMPLO en Python (protocolo AJE v1)

La diferencia con `bot_template.py`: aquel elige al azar entre las jugadas legales; este **piensa**.
Busca en el árbol de jugadas con minimax y poda alfa-beta, y evalúa la posición.

Está deliberadamente frenado: mira DOS jugadas hacia adelante y para. Medido en series de cuatro
partidas contra los motores de la casa, pierde contra el Veterano, Imperium y Bastión, empata con
el Maestro, y solo le gana al Aprendiz —que es el bot fácil, pensado para que gane un principiante—.
Es tu punto de partida, no tu rival.

    python3 motor_ejemplo.py        # escucha en http://0.0.0.0:5002

Sin instalar nada: solo la biblioteca estándar de Python 3.

CÓMO USARLO PARA APRENDER. Está partido en tres piezas, y solo las dos últimas son «tu motor»:

    1. El tablero y las reglas   → idéntico a bot_template.py. No hace falta que lo toques.
    2. `evaluar`                 → cuánto vale una posición. Es donde vive el criterio de juego.
    3. `buscar`                  → cuánto mira hacia adelante. Es donde vive la fuerza bruta.

La forma más rápida de tener un motor mejor que este es cambiar `evaluar`. La segunda, darle más
profundidad a `buscar`. Las dos cosas por separado se notan, y se notan en la clasificación.

Ver el protocolo completo en https://alefrajav.iaintelecto.space/arena/desarrolladores
"""

import json
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

NOMBRE = "Python-Ejemplo-Minimax"
VERSION = "1.0.0"
JUEGOS = ["checkers-8x8"]

# Cuánto tiempo se permite pensar como mucho, y cuánto del reloj se gasta por jugada. Un motor que
# gasta demasiado pronto llega sin tiempo al final, que es donde se decide.
SEGUNDOS_MAX = 0.5
FRACCION_DEL_RELOJ = 0.02      # 2 % de lo que quede

# ────────────────────────────────────────────────────────────────────────────────────────────────
# EL FRENO, Y ES A PROPÓSITO
#
# Este motor mira DOS jugadas hacia adelante y para. El código no cambia si le pones más: con
# profundidad libre le ganaba 4-0 al Aprendiz y 3-0 al Veterano, y ahí ya no sería un ejemplo sino
# un rival — un motor que cualquiera descarga y registra sin tocar una línea no debe encabezar la
# clasificación del club.
#
# **Subir este número es la primera mejora que vas a hacer, y se nota enseguida.** Ponlo en 4 y
# juega contra el de antes. Ponlo en 6 y vuelve a probar.
#
# UN AVISO QUE TE AHORRARÁ UNA TARDE: no lo bajes a 1. A profundidad 1 el motor mira su jugada pero
# NO la respuesta del rival, y en damas eso es letal —comes una pieza y te comen tres, porque
# capturar es obligatorio—. Medido: a profundidad 1 pierde 0-8 contra el bot que juega al AZAR.
# Dos es el mínimo que tiene sentido, y por eso está aquí.
#
# Ahí es donde se entiende que un motor de damas es una cuenta entre profundidad y tiempo.
# ────────────────────────────────────────────────────────────────────────────────────────────────
PROFUNDIDAD_MAX = 2

# ── 1. EL TABLERO Y LAS REGLAS ──────────────────────────────────────────────────────────────────
#
# Esto es lo mismo que en bot_template.py, y no hace falta que lo entiendas para escribir tu motor.
# Solo se juegan las 32 casillas oscuras, numeradas del 1 al 32; las negras salen en 1-12 y mueven
# primero. Por dentro se usa (fila, columna) con la fila 0 arriba; las negras avanzan hacia filas
# mayores.

AVANCE = {"B": +1, "W": -1}
FILA_DE_CORONACION = {"B": 7, "W": 0}
RIVAL = {"B": "W", "W": "B"}


def columnas_de(fila):
    return [c for c in range(8) if (fila + c) % 2 == 1]


def casilla_a_rc(n):
    fila = (n - 1) // 4
    return fila, columnas_de(fila)[(n - 1) % 4]


def rc_a_casilla(fila, col):
    return fila * 4 + columnas_de(fila).index(col) + 1


def dentro(fila, col):
    return 0 <= fila < 8 and 0 <= col < 8


def leer_posicion(fen):
    trozos = fen.strip().split(":")
    lado = trozos[0].strip().upper()
    tablero = {}
    for parte in trozos[1:]:
        color, lista = parte[0].upper(), parte[1:]
        for bruto in filter(None, (x.strip() for x in lista.split(","))):
            dama = bruto.upper().startswith("K")
            tablero[casilla_a_rc(int(bruto[1:] if dama else bruto))] = (color, dama)
    return lado, tablero


def direcciones(es_dama, lado):
    # Una pieza normal NO captura hacia atrás. Solo las damas.
    return [-1, 1] if es_dama else [AVANCE[lado]]


def capturas_desde(tablero, origen, lado, es_dama):
    salidas = []
    for df in direcciones(es_dama, lado):
        for dc in (-1, 1):
            comida = (origen[0] + df, origen[1] + dc)
            destino = (origen[0] + 2 * df, origen[1] + 2 * dc)
            if not dentro(*destino):
                continue
            pieza = tablero.get(comida)
            if pieza and pieza[0] != lado and destino not in tablero:
                salidas.append((destino, comida))
    return salidas


def cadenas_desde(tablero, origen, lado, es_dama, recorrido):
    salidas = capturas_desde(tablero, origen, lado, es_dama)
    if not salidas:
        return [recorrido]
    completas = []
    for destino, comida in salidas:
        siguiente = dict(tablero)
        del siguiente[comida]
        del siguiente[origen]
        corona = not es_dama and destino[0] == FILA_DE_CORONACION[lado]
        siguiente[destino] = (lado, es_dama or corona)
        # Coronar termina el turno, aunque quedara algo por comer.
        if corona:
            completas.append(recorrido + [destino])
        else:
            completas += cadenas_desde(siguiente, destino, lado, es_dama, recorrido + [destino])
    return completas


def jugadas_legales(lado, tablero):
    """Las jugadas legales, en texto PDN. Capturar es obligatorio."""
    mias = [(rc, p) for rc, p in tablero.items() if p[0] == lado]

    capturas = []
    for origen, (_, es_dama) in mias:
        if capturas_desde(tablero, origen, lado, es_dama):
            for cadena in cadenas_desde(tablero, origen, lado, es_dama, [origen]):
                if len(cadena) > 1:
                    capturas.append("x".join(str(rc_a_casilla(*c)) for c in cadena))
    if capturas:
        return capturas

    simples = []
    for origen, (_, es_dama) in mias:
        for df in direcciones(es_dama, lado):
            for dc in (-1, 1):
                destino = (origen[0] + df, origen[1] + dc)
                if dentro(*destino) and destino not in tablero:
                    simples.append(f"{rc_a_casilla(*origen)}-{rc_a_casilla(*destino)}")
    return simples


def aplicar(tablero, lado, jugada):
    """Aplica una jugada PDN y devuelve el tablero resultante. La jugada trae la cadena entera."""
    casillas = [casilla_a_rc(int(n)) for n in jugada.replace("x", "-").split("-")]
    nuevo = dict(tablero)
    lado_pieza, dama = nuevo.pop(casillas[0])

    for i in range(len(casillas) - 1):
        origen, destino = casillas[i], casillas[i + 1]
        if abs(destino[0] - origen[0]) == 2:      # fue un salto: se come la de en medio
            nuevo.pop(((origen[0] + destino[0]) // 2, (origen[1] + destino[1]) // 2), None)

    final = casillas[-1]
    if not dama and final[0] == FILA_DE_CORONACION[lado_pieza]:
        dama = True
    nuevo[final] = (lado_pieza, dama)
    return nuevo


# ── 2. EVALUAR: CUÁNTO VALE UNA POSICIÓN ────────────────────────────────────────────────────────
#
# EMPIEZA POR AQUÍ SI QUIERES UN MOTOR MEJOR. Es lo que más se nota y lo más barato de cambiar.
#
# Devuelve un número desde el punto de vista de `lado`: positivo, va ganando. Este de ejemplo mira
# tres cosas, y son las tres más obvias que se le ocurren a cualquiera:
#
#   · el material, que es lo que decide casi todas las partidas de damas;
#   · lo cerca que está cada peón de coronar, porque una dama vale casi el doble;
#   · quedarse en la última fila propia, que impide que el rival corone.
#
# Lo que NO mira, y son ideas para tu motor: el control del centro, las piezas atrapadas, las
# estructuras de peones enlazados, ni el final de partida —donde tener la oposición decide—.

VALOR_PEON = 100
VALOR_DAMA = 175


def evaluar(tablero, lado):
    puntos = 0
    for (fila, _), (color, dama) in tablero.items():
        valor = VALOR_DAMA if dama else VALOR_PEON

        if not dama:
            # Cuanto más cerca de coronar, más vale: de 0 a 6 filas de avance, 4 puntos cada una.
            avanzadas = fila if color == "B" else 7 - fila
            valor += avanzadas * 4
            # La última fila propia vale por lo que impide, no por lo que hace.
            if fila == (0 if color == "B" else 7):
                valor += 6

        puntos += valor if color == lado else -valor
    return puntos


# ── 3. BUSCAR: CUÁNTO SE MIRA HACIA ADELANTE ────────────────────────────────────────────────────
#
# Minimax con poda alfa-beta. La poda no cambia el resultado: descarta ramas que ya no pueden
# mejorar lo encontrado, y por eso deja llegar más hondo en el mismo tiempo.
#
# Se usa profundización iterativa: se busca a profundidad 1, luego 2, luego 3… y se guarda la mejor
# jugada de la última profundidad TERMINADA. Así el motor siempre tiene una respuesta lista cuando
# se acaba el tiempo, en vez de quedarse a medias sin nada que contestar.

class SinTiempo(Exception):
    pass


def minimax(tablero, lado, yo, profundidad, alfa, beta, limite):
    if time.monotonic() > limite:
        raise SinTiempo()

    legales = jugadas_legales(lado, tablero)
    if not legales:
        # Quien no puede mover, pierde. Se resta la profundidad para preferir ganar PRONTO.
        return (-100_000 + profundidad) if lado == yo else (100_000 - profundidad)
    if profundidad == 0:
        return evaluar(tablero, yo)

    if lado == yo:
        mejor = -1_000_000
        for jugada in legales:
            v = minimax(aplicar(tablero, lado, jugada), RIVAL[lado], yo, profundidad - 1, alfa, beta, limite)
            mejor = max(mejor, v)
            alfa = max(alfa, v)
            if beta <= alfa:
                break        # el rival nunca llegaría aquí: no hace falta seguir mirando
        return mejor

    peor = 1_000_000
    for jugada in legales:
        v = minimax(aplicar(tablero, lado, jugada), RIVAL[lado], yo, profundidad - 1, alfa, beta, limite)
        peor = min(peor, v)
        beta = min(beta, v)
        if beta <= alfa:
            break
    return peor


def buscar(lado, tablero, segundos):
    """La mejor jugada que dé tiempo a encontrar. Devuelve (jugada, profundidad alcanzada)."""
    legales = jugadas_legales(lado, tablero)
    if not legales:
        return None, 0
    if len(legales) == 1:
        return legales[0], 0          # obligada: no se gasta ni un milisegundo en pensarla

    limite = time.monotonic() + segundos
    mejor, alcanzada = legales[0], 0

    for profundidad in range(1, PROFUNDIDAD_MAX + 1):
        try:
            puntuadas = []
            for jugada in legales:
                v = minimax(aplicar(tablero, lado, jugada), RIVAL[lado], lado,
                            profundidad - 1, -1_000_000, 1_000_000, limite)
                puntuadas.append((v, jugada))
            # Terminada entera: esta profundidad se puede creer.
            mejor = max(puntuadas)[1]
            alcanzada = profundidad
        except SinTiempo:
            break                      # se agotó a media profundidad: vale la anterior
    return mejor, alcanzada


# ── El protocolo ────────────────────────────────────────────────────────────────────────────────

class Motor(BaseHTTPRequestHandler):
    def _responder(self, codigo, cuerpo):
        crudo = json.dumps(cuerpo).encode()
        self.send_response(codigo)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(crudo)))
        self.end_headers()
        self.wfile.write(crudo)

    def do_GET(self):
        if self.path.rstrip("/") == "/aje/info":
            self._responder(200, {"protocolo": 1, "nombre": NOMBRE, "version": VERSION, "juegos": JUEGOS})
        else:
            self._responder(404, {"error": "no encontrado"})

    def do_POST(self):
        if self.path.rstrip("/") != "/aje/move":
            return self._responder(404, {"error": "no encontrado"})

        empezado = time.time()
        largo = int(self.headers.get("Content-Length") or 0)
        try:
            peticion = json.loads(self.rfile.read(largo) or b"{}")
        except ValueError:
            return self._responder(400, {"error": "JSON mal formado"})

        # Rechaza limpiamente lo que no sepas jugar. Nunca contestes una jugada inventada.
        if peticion.get("juego") not in JUEGOS:
            return self._responder(400, {"error": f"juego no soportado: {peticion.get('juego')}"})

        try:
            lado, tablero = leer_posicion(peticion["posicion"])
        except (KeyError, ValueError, IndexError) as err:
            return self._responder(400, {"error": f"posición ilegible: {err}"})

        # Repartir el reloj es media partida. Con 60 s y el 3 %, la primera jugada piensa 1,8 s y
        # las últimas décimas: nunca se llega al final sin tiempo.
        restantes_ms = peticion.get("msRestantes") or 10_000
        segundos = max(0.05, min(SEGUNDOS_MAX, restantes_ms * FRACCION_DEL_RELOJ / 1000))

        jugada, profundidad = buscar(lado, tablero, segundos)
        if jugada is None:
            return self._responder(400, {"error": "sin jugadas legales"})

        self._responder(200, {
            "jugada": jugada,
            "msPensados": int((time.time() - empezado) * 1000),
            "info": f"profundidad {profundidad}",
        })

    def log_message(self, *_):
        pass


if __name__ == "__main__":
    print(f"{NOMBRE} v{VERSION} escuchando en http://0.0.0.0:5002/aje/move")
    HTTPServer(("0.0.0.0", 5002), Motor).serve_forever()
