"""
app: interfaz local de PlumLoom (Flask, una sola pagina).

    python Engine/ui/app.py            # http://127.0.0.1:5055
    python Engine/ui/app.py --port 8080

Habla DIRECTO con Forge (`127.0.0.1:7860`), pero la generacion la hace
`pack_builder.generate()` (via `runner`): la UI no reimplementa nada.

Flujo:
  /api/plan    compone los jobs y los guarda en memoria  -> preview (D2.7)
  /api/start   ejecuta EXACTAMENTE ese plan (hilo aparte) -> run_id
  /api/progress progreso en vivo (imagenes + log)
  /api/regen   regenera una imagen suelta (misma pose/camara, semilla nueva)
  /api/bank    ver / anadir / vetar poses (D2.3)

La UI nunca publica en Pixiv: solo lee tags (`pixiv_*`), nada de subir.
"""
import argparse
import json
import os
import sys
import threading
import time
import uuid
from datetime import datetime
from pathlib import Path

HERE = Path(__file__).resolve().parent
if str(HERE) not in sys.path:
    sys.path.insert(0, str(HERE))

from flask import Flask, jsonify, render_template, request, send_from_directory  # noqa: E402

import planner  # noqa: E402
import runner  # noqa: E402
import pose_bank  # noqa: E402
import pack_builder as pb  # noqa: E402
from stages import STAGES, STAGE_LEVEL, STAGE_DRESS  # noqa: E402

BASE_DIR = planner.BASE_DIR
UI_RUNS = runner.UI_RUNS

app = Flask(__name__, static_folder="static", template_folder="templates")
app.json.ensure_ascii = False

LOCK = threading.Lock()
PLANS = {}
RUNS = {}
_FORGE = {"ok": None, "checked": 0.0}


# ------------------------------------------------------------------ helpers

def forge_ok(force=False) -> bool:
    """Forge responde? Cache de 5 s (el debounce de la preview lo pide mucho)."""
    now = time.time()
    if not force and _FORGE["ok"] is not None and now - _FORGE["checked"] < 5:
        return _FORGE["ok"]
    ok = False
    try:
        import requests
        resp = requests.get("%s/sdapi/v1/options" % pb.FORGE, timeout=1.5)
        ok = resp.status_code == 200
    except Exception:
        ok = False
    _FORGE.update({"ok": ok, "checked": now})
    return ok


def _run_dir(name) -> Path:
    """Carpeta de un lote de la UI (solo dentro de Output/ui_runs)."""
    if not name:
        raise ValueError("falta el lote")
    root = UI_RUNS.resolve()
    path = (UI_RUNS / str(name)).resolve()
    if root not in path.parents:
        raise ValueError("lote fuera de Output/ui_runs")
    if not path.is_dir():
        raise ValueError("no existe el lote '%s'" % name)
    return path


def _new_run_id() -> str:
    return uuid.uuid4().hex[:10]


def _log(state, text):
    state["log"].append("%s  %s" % (datetime.now().strftime("%H:%M:%S"), text))
    if len(state["log"]) > 400:
        del state["log"][:-300]


# ------------------------------------------------------------------ paginas

@app.get("/")
def index():
    return render_template("index.html")


@app.get("/api/meta")
def api_meta():
    ctx = planner.load_context()
    used = planner.used_characters()
    chars = planner.characters(ctx, used)
    counts = planner.pose_counts(ctx)
    themes = planner.themes_meta(ctx)
    return jsonify({
        "ok": True,
        "forge": {"ok": forge_ok(), "url": pb.FORGE},
        "base_dir": str(BASE_DIR),
        "characters": chars,
        "themes": themes,
        "stages": [{"id": s, "title": planner.STAGE_TITLE[s],
                    "level": STAGE_LEVEL[s], "dress": STAGE_DRESS[s]}
                   for s in STAGES],
        "levels": list(planner.LEVELS),
        "poses": counts,
        "pack_presets": list(planner.PACK_PRESETS),
        "defaults": {"total": 45, "stages": list(STAGES)},
    })


# ------------------------------------------------------------------ plan

@app.post("/api/plan")
def api_plan():
    data = request.get_json(force=True, silent=True) or {}
    try:
        result = planner.compose(data)
    except (ValueError, SystemExit) as exc:
        return jsonify({"ok": False, "error": str(exc)}), 400

    plan = result["plan"]
    preview = result["preview"]
    plan_id = _new_run_id()
    # carpeta legible: <personaje>_<ambiente>_<fecha> (D2.15)
    plan["run_dir"] = str(planner.unique_run_dir(UI_RUNS, preview))
    with LOCK:
        PLANS[plan_id] = plan
        while len(PLANS) > 24:
            PLANS.pop(next(iter(PLANS)))

    preview["ok"] = True
    preview["plan_id"] = plan_id
    preview["run_name"] = Path(plan["run_dir"]).name
    preview["forge"] = forge_ok()
    return jsonify(preview)


@app.post("/api/start")
def api_start():
    data = request.get_json(force=True, silent=True) or {}
    plan_id = data.get("plan_id")
    plan = PLANS.get(plan_id)
    if not plan:
        return jsonify({"ok": False, "error": "plan caducado: vuelve a "
                                             "previsualizar"}), 409
    if not forge_ok(force=True):
        return jsonify({"ok": False, "error": "Forge no responde en %s "
                                             "(webui-user.bat --api)"
                                             % pb.FORGE}), 409

    run_id = _new_run_id()
    # la carpeta se calculo al previsualizar; si ya existe (dos planes en el
    # mismo segundo, o un lote que se relanza), se le pone sufijo (D2.15)
    run_dir = Path(plan["run_dir"])
    if run_dir.exists():
        base = run_dir.name
        n = 2
        while (run_dir.parent / ("%s_%d" % (base, n))).exists():
            n += 1
        run_dir = run_dir.parent / ("%s_%d" % (base, n))
        plan["run_dir"] = str(run_dir)
    total = sum(len(item["jobs"]) for item in plan["items"])
    state = {
        "run_id": run_id, "kind": "batch", "state": "running",
        "plan_id": plan_id, "run_dir": plan["run_dir"],
        "run_name": run_dir.name,
        "total": total, "done": 0, "failed": 0, "stop": False,
        "current": None, "images": [], "log": [], "summary": None,
        "error": None, "started": time.time(), "finished": None,
    }
    with LOCK:
        RUNS[run_id] = state
    _log(state, "lanzando %d imagenes en %s" % (total, state["run_name"]))

    def on_event(ev):
        kind = ev.get("event")
        with LOCK:
            if kind == "char_start":
                _log(state, "personaje %d/%d: %s (%d imagenes)"
                     % (ev["index"], ev["total"], ev["char"], ev["images"]))
            elif kind == "start":
                state["current"] = {"char": ev.get("char"),
                                    "stage": ev.get("stage"),
                                    "nivel": ev.get("nivel"),
                                    "pose_id": ev.get("pose_id")}
            elif kind == "done" and "record" in ev:
                rec = ev["record"]
                char = ev.get("char")
                folder = ev.get("dir") or char
                state["done"] += 1
                state["current"] = None
                state["images"].append({
                    "char": char,
                    # pack_builder escribe `img/<fichero>` (sin personaje) y la
                    # carpeta puede no ser la clave (Reze lleva `\(`): la ruta
                    # servible por /api/image/<lote>/<rel> lleva la carpeta
                    "file": "%s/%s" % (folder, rec.get("file")),
                    "stage": rec.get("stage"), "nivel": rec.get("nivel"),
                    "pose_id": rec.get("pose_id"), "seed": rec.get("seed"),
                    "camera": rec.get("camera"), "dress": rec.get("dress"),
                    "theme": rec.get("theme"),
                })
                _log(state, "%d/%d %s %s %s (seed %s)"
                     % (state["done"], state["total"], ev.get("char"),
                        rec.get("stage") or rec.get("nivel"),
                        rec.get("pose_id"), rec.get("seed")))
            elif kind == "fail":
                state["failed"] += 1
                _log(state, "FALLO %s %s: %s"
                     % (ev.get("char"), ev.get("pose_id"), ev.get("error")))
            elif kind == "done" and "generated" in ev:
                _log(state, "lote terminado: %d imagenes" % ev["generated"])

    def worker():
        try:
            summary = runner.run_plan(
                plan, on_event=on_event,
                should_stop=lambda: state.get("stop", False))
            with LOCK:
                state["summary"] = summary
                state["state"] = ("cancelled" if summary.get("stopped")
                                  else "done")
                state["finished"] = time.time()
                state["current"] = None
        except Exception as exc:                      # noqa: BLE001
            with LOCK:
                state["state"] = "error"
                state["error"] = str(exc)
                state["finished"] = time.time()
                _log(state, "ERROR: %s" % exc)

    threading.Thread(target=worker, daemon=True).start()
    return jsonify({"ok": True, "run_id": run_id,
                    "run_name": state["run_name"], "total": total})


@app.post("/api/stop")
def api_stop():
    data = request.get_json(force=True, silent=True) or {}
    state = RUNS.get(data.get("run_id"))
    if not state:
        return jsonify({"ok": False, "error": "run desconocido"}), 404
    state["stop"] = True
    _log(state, "cancelacion pedida (acaba la imagen en curso)")
    return jsonify({"ok": True})


@app.get("/api/progress")
def api_progress():
    run_id = request.args.get("run_id")
    state = RUNS.get(run_id)
    if not state:
        return jsonify({"ok": False, "error": "run desconocido"}), 404
    with LOCK:
        payload = {k: v for k, v in state.items() if k != "log"}
        payload["log"] = state["log"][-80:]
        payload["ok"] = True
    return jsonify(payload)


# ------------------------------------------------------------------ lotes

@app.get("/api/runs")
def api_runs():
    out = []
    if UI_RUNS.exists():
        for path in sorted(UI_RUNS.iterdir(), reverse=True):
            if not path.is_dir():
                continue
            man = path / "manifest_all.json"
            if not man.exists():
                continue
            try:
                records = json.loads(man.read_text(encoding="utf-8"))
            except (OSError, json.JSONDecodeError):
                continue
            chars = sorted({r.get("char") or r.get("char_dir")
                            for r in records if (r.get("char") or r.get("char_dir"))})
            out.append({
                "name": path.name,
                "images": len(records),
                "chars": len(chars),
                "mtime": man.stat().st_mtime,
                "chars_list": chars[:8],
            })
        out.sort(key=lambda r: r["mtime"], reverse=True)
    return jsonify({"ok": True, "runs": out[:30]})


@app.get("/api/run/<name>")
def api_run(name):
    try:
        run_dir = _run_dir(name)
    except ValueError as exc:
        return jsonify({"ok": False, "error": str(exc)}), 404
    records, plan = [], None
    try:
        records = json.loads((run_dir / "manifest_all.json").read_text(
            encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        records = []
    try:
        plan = json.loads((run_dir / "plan.json").read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        plan = None
    sheets = sorted(p.name for p in run_dir.glob("*.jpg"))
    return jsonify({"ok": True, "name": name, "records": records,
                    "plan": plan, "sheets": sheets})


@app.get("/api/image/<name>/<path:rel>")
def api_image(name, rel):
    try:
        run_dir = _run_dir(name)
    except ValueError:
        return jsonify({"ok": False, "error": "lote desconocido"}), 404
    return send_from_directory(run_dir, rel)


@app.post("/api/open")
def api_open():
    data = request.get_json(force=True, silent=True) or {}
    try:
        run_dir = _run_dir(data.get("run"))
        os.startfile(str(run_dir))                # noqa: S606 (Windows)
    except Exception as exc:                      # noqa: BLE001
        return jsonify({"ok": False, "error": str(exc)}), 400
    return jsonify({"ok": True, "path": str(run_dir)})


# ------------------------------------------------------------------ regen

@app.post("/api/regen")
def api_regen():
    data = request.get_json(force=True, silent=True) or {}
    try:
        run_dir = _run_dir(data.get("run"))
    except ValueError as exc:
        return jsonify({"ok": False, "error": str(exc)}), 400
    if not forge_ok(force=True):
        return jsonify({"ok": False, "error": "Forge no responde en %s"
                                             % pb.FORGE}), 409

    regen_id = _new_run_id()
    state = {
        "run_id": regen_id, "kind": "regen", "state": "running",
        "run_dir": str(run_dir), "run_name": run_dir.name,
        "total": 1, "done": 0, "failed": 0, "stop": False, "current": None,
        "images": [], "log": [], "summary": None, "error": None,
        "started": time.time(), "finished": None,
        "target": {"char": data.get("char"), "pose_id": data.get("pose_id"),
                   "stage": data.get("stage"), "file": data.get("file")},
    }
    with LOCK:
        RUNS[regen_id] = state
    _log(state, "regenerando %s %s" % (data.get("char"), data.get("file")))

    seed = data.get("seed")
    seed = int(seed) if seed not in (None, "") else None

    def worker():
        try:
            result = runner.regen_one(
                run_dir, data.get("char"), pose_id=data.get("pose_id"),
                stage=data.get("stage"), file=data.get("file"), seed=seed,
                neg_extra=data.get("neg_extra") or None)
            with LOCK:
                state["state"] = "done"
                state["done"] = 1
                state["summary"] = result
                state["finished"] = time.time()
                state["images"] = [{"char": result["char"],
                                    "file": result["new_file"],
                                    "stage": result["stage"],
                                    "nivel": result["nivel"],
                                    "pose_id": result["pose_id"],
                                    "seed": result["seed"],
                                    "camera": result["camera"],
                                    "dress": result["dress"]}]
                _log(state, "nueva imagen %s (seed %s)%s"
                     % (result["new_file"], result["seed"],
                        "  [ojo: el prompt cambio]" if result["drift"] else ""))
                # refrescar el lote padre si esta en memoria
                for other in RUNS.values():
                    for img in other.get("images", []):
                        if (img.get("char") == result["char"]
                                and img.get("file") == result["old_file"]):
                            img["file"] = result["new_file"]
                            img["seed"] = result["seed"]
        except Exception as exc:                  # noqa: BLE001
            with LOCK:
                state["state"] = "error"
                state["error"] = str(exc)
                state["finished"] = time.time()
                _log(state, "ERROR: %s" % exc)

    threading.Thread(target=worker, daemon=True).start()
    return jsonify({"ok": True, "run_id": regen_id})


# ------------------------------------------------------------------ banco

@app.get("/api/bank")
def api_bank():
    ctx = planner.load_context()
    avoid = ctx["avoid"]
    poses = []
    for entry in ctx["bank"]:
        poses.append({
            "id": entry.get("id"),
            "nivel": entry.get("nivel"),
            "pose_line": entry.get("pose_line"),
            "tema": entry.get("tema") or [],
            "provenance": entry.get("provenance"),
            "avoided": pose_bank.is_avoided(entry.get("pose_line", ""), avoid),
            "motivo": (avoid.get(pose_bank.normalize_key(
                entry.get("pose_line", ""))) or {}).get("motivo"),
        })
    return jsonify({"ok": True, "poses": poses,
                    "counts": planner.pose_counts(ctx),
                    "stats": pose_bank.stats(ctx["bank"])})


@app.post("/api/bank/add")
def api_bank_add():
    data = request.get_json(force=True, silent=True) or {}
    line = (data.get("pose_line") or "").strip()
    nivel = (data.get("nivel") or "").strip()
    if not line:
        return jsonify({"ok": False, "error": "la pose esta vacia"}), 400
    if nivel not in planner.LEVELS:
        return jsonify({"ok": False, "error": "nivel invalido"}), 400
    if pose_bank.is_avoided(line):
        return jsonify({"ok": False, "error": "esa pose esta vetada; "
                                             "reactivala primero"}), 400
    bank = pose_bank.load_bank()
    sig = pose_bank.signature(line)
    dupe = next((e for e in bank if pose_bank.signature(
        e.get("pose_line", "")) == sig), None)
    if dupe:
        return jsonify({"ok": False,
                        "error": "ya existe como %s" % dupe.get("id")}), 409
    tema = data.get("tema") or []
    if isinstance(tema, str):
        tema = [t.strip() for t in tema.split(",") if t.strip()]
    new_id = pose_bank.add_pose(line, nivel, tema, None, "", None, "ui",
                                1.0, (data.get("notas") or "").strip())
    return jsonify({"ok": True, "id": new_id})


@app.post("/api/bank/blacklist")
def api_bank_blacklist():
    data = request.get_json(force=True, silent=True) or {}
    line = _pose_line_from_request(data)
    if not line:
        return jsonify({"ok": False, "error": "pose no encontrada"}), 404
    row = pose_bank.blacklist(line, (data.get("motivo") or
                                     "vetada desde la UI").strip())
    return jsonify({"ok": True, "key": row["key"]})


@app.post("/api/bank/unblacklist")
def api_bank_unblacklist():
    data = request.get_json(force=True, silent=True) or {}
    line = _pose_line_from_request(data)
    if not line:
        return jsonify({"ok": False, "error": "pose no encontrada"}), 404
    if not pose_bank.unblacklist(line):
        return jsonify({"ok": False, "error": "no estaba vetada"}), 404
    return jsonify({"ok": True})


def _pose_line_from_request(data):
    if data.get("pose_line"):
        return data["pose_line"]
    if data.get("id"):
        entry = next((e for e in pose_bank.load_bank()
                      if e.get("id") == data["id"]), None)
        return entry.get("pose_line") if entry else None
    return None


# ------------------------------------------------------------------ hashtags db

@app.get("/api/hashtags-db")
def api_hashtags_db():
    p = BASE_DIR / "Archivos" / "character_hashtags_db.json"
    if not p.exists():
        return jsonify({})
    try:
        data = json.loads(p.read_text(encoding="utf-8"))
        return jsonify(data)
    except Exception as e:
        return jsonify({"error": str(e)}), 500


# ------------------------------------------------------------------ main

def main():
    p = argparse.ArgumentParser(description="UI local de PlumLoom")
    p.add_argument("--host", default=os.environ.get("PLUMLOOM_UI_HOST",
                                                    "127.0.0.1"))
    p.add_argument("--port", type=int,
                   default=int(os.environ.get("PLUMLOOM_UI_PORT", "5055")))
    p.add_argument("--debug", action="store_true")
    args = p.parse_args()
    print("[-] UI en http://%s:%d  (Forge: %s)" % (args.host, args.port,
                                                   pb.FORGE))
    if not forge_ok(force=True):
        print("[!] Forge no responde ahora mismo: la preview funciona, "
              "lanzar no.")
    app.run(host=args.host, port=args.port, debug=args.debug, threaded=True)


if __name__ == "__main__":
    main()
