"""Heads API (#114): train + inspect the per-concept heads that power
suggestions (replacing Camie + centroid).

POST /api/heads/train   — (re)train all eligible heads (one run at a time).
GET  /api/heads         — status: head count, last-trained, running run, the
                          per-concept head table (strength + auto-apply ready),
                          and recent training runs. The card rehydrates from
                          here so status survives navigation.
"""

from quart import Blueprint, jsonify, request
from sqlalchemy import desc, func, select

from ..extensions import get_session
from ..models import (
    HeadAutoApplyRun,
    HeadMetric,
    HeadMetricsSnapshot,
    HeadTrainingRun,
    Tag,
    TagHead,
)
from ..models.tag import image_tag
from ..services.ml.heads import (
    HeadAutoApplyAlreadyRunning,
    HeadAutoApplyDisabled,
    HeadTrainingAlreadyRunning,
    start_head_auto_apply_run,
    start_head_training_run,
)

heads_bp = Blueprint("heads", __name__, url_prefix="/api/heads")


def _serialize_run(run: HeadTrainingRun) -> dict:
    return {
        "id": run.id,
        "params": run.params,
        "status": run.status,
        "started_at": run.started_at.isoformat() if run.started_at else None,
        "finished_at": run.finished_at.isoformat() if run.finished_at else None,
        "n_trained": run.n_trained,
        "n_skipped": run.n_skipped,
        "error": run.error,
    }


@heads_bp.route("/train", methods=["POST"])
async def train():
    body = await request.get_json(silent=True) or {}
    params = body.get("params") or body or {}
    async with get_session() as session:
        try:
            run_id = await session.run_sync(
                lambda s: start_head_training_run(s, params)
            )
        except HeadTrainingAlreadyRunning as running:
            return jsonify({
                "error": "training_already_running",
                "running_id": int(running.args[0]),
            }), 409
        await session.commit()
    return jsonify({"run_id": run_id, "status": "running"}), 202


@heads_bp.route("", methods=["GET"])
async def status():
    async with get_session() as session:
        count, last_trained = (
            await session.execute(
                select(func.count(), func.max(TagHead.trained_at))
            )
        ).one()
        graduated = (
            await session.execute(
                select(func.count()).where(
                    TagHead.auto_apply_threshold.is_not(None)
                )
            )
        ).scalar_one()
        running = (
            await session.execute(
                select(HeadTrainingRun.id)
                .where(HeadTrainingRun.status == "running")
                .order_by(HeadTrainingRun.id.desc())
                .limit(1)
            )
        ).scalar_one_or_none()
        runs = (
            await session.execute(
                select(HeadTrainingRun)
                .order_by(HeadTrainingRun.id.desc())
                .limit(10)
            )
        ).scalars().all()
        # The per-concept table: strongest first, capped for the admin card.
        head_rows = (
            await session.execute(
                select(
                    TagHead.tag_id, Tag.name, Tag.kind,
                    TagHead.n_pos, TagHead.n_neg, TagHead.ap,
                    TagHead.precision_cv, TagHead.recall,
                    TagHead.auto_apply_threshold, TagHead.trained_at,
                )
                .join(Tag, Tag.id == TagHead.tag_id)
                .order_by(desc(TagHead.ap))
                .limit(500)
            )
        ).all()
        heads = [
            {
                "tag_id": r.tag_id,
                "name": r.name,
                "category": r.kind.value if hasattr(r.kind, "value") else str(r.kind),
                "n_pos": r.n_pos,
                "n_neg": r.n_neg,
                "ap": r.ap,
                "precision": r.precision_cv,
                "recall": r.recall,
                "auto_apply": r.auto_apply_threshold is not None,
                "trained_at": r.trained_at.isoformat() if r.trained_at else None,
            }
            for r in head_rows
        ]
    return jsonify({
        "head_count": count,
        "graduated_count": graduated,
        "last_trained_at": last_trained.isoformat() if last_trained else None,
        "running_id": running,
        "runs": [_serialize_run(r) for r in runs],
        "heads": heads,
    })


def _serialize_apply_run(run: HeadAutoApplyRun) -> dict:
    return {
        "id": run.id,
        "dry_run": run.dry_run,
        "status": run.status,
        "started_at": run.started_at.isoformat() if run.started_at else None,
        "finished_at": run.finished_at.isoformat() if run.finished_at else None,
        "n_applied": run.n_applied,
        "report": run.report,
        "error": run.error,
    }


@heads_bp.route("/auto-apply", methods=["POST"])
async def auto_apply():
    """Trigger an earned-auto-apply sweep. {dry_run:true} previews (writes
    nothing); a real sweep needs head_auto_apply_enabled on."""
    body = await request.get_json(silent=True) or {}
    params = {"dry_run": bool(body.get("dry_run", False))}
    async with get_session() as session:
        try:
            run_id = await session.run_sync(
                lambda s: start_head_auto_apply_run(s, params)
            )
        except HeadAutoApplyAlreadyRunning as running:
            return jsonify({
                "error": "auto_apply_already_running",
                "running_id": int(running.args[0]),
            }), 409
        except HeadAutoApplyDisabled:
            return jsonify({"error": "auto_apply_disabled"}), 400
        await session.commit()
    return jsonify({"run_id": run_id, "status": "running"}), 202


@heads_bp.route("/auto-apply", methods=["GET"])
async def auto_apply_status():
    async with get_session() as session:
        running = (
            await session.execute(
                select(HeadAutoApplyRun.id)
                .where(HeadAutoApplyRun.status == "running")
                .order_by(HeadAutoApplyRun.id.desc())
                .limit(1)
            )
        ).scalar_one_or_none()
        runs = (
            await session.execute(
                select(HeadAutoApplyRun)
                .order_by(HeadAutoApplyRun.id.desc())
                .limit(10)
            )
        ).scalars().all()
    return jsonify({
        "running_id": running,
        "runs": [_serialize_apply_run(r) for r in runs],
    })


@heads_bp.route("/metrics", methods=["GET"])
async def metrics():
    """Auto-apply observability: per-concept current counts (volume, misfires,
    under-fires, realized misfire rate, head quality) + the daily time-series so
    the operator can tune the precision target + support floor from real data."""
    async with get_session() as session:
        head_rows = (
            await session.execute(
                select(
                    TagHead.tag_id, Tag.name, TagHead.ap, TagHead.precision_cv,
                    TagHead.recall, TagHead.auto_apply_threshold, TagHead.n_pos,
                ).join(Tag, Tag.id == TagHead.tag_id)
            )
        ).all()
        heads = {r.tag_id: r for r in head_rows}
        metric_rows = (
            await session.execute(
                select(
                    HeadMetric.tag_id, HeadMetric.n_misfires, HeadMetric.n_underfires
                )
            )
        ).all()
        mets = {r.tag_id: r for r in metric_rows}
        applied = dict(
            (
                await session.execute(
                    select(image_tag.c.tag_id, func.count())
                    .where(image_tag.c.source == "head_auto")
                    .group_by(image_tag.c.tag_id)
                )
            ).all()
        )
        names = {r.tag_id: r.name for r in head_rows}
        # Names for metric-only tags (head pruned but corrections recorded).
        missing = [t for t in mets if t not in names]
        if missing:
            for tid, nm in (
                await session.execute(
                    select(Tag.id, Tag.name).where(Tag.id.in_(missing))
                )
            ).all():
                names[tid] = nm

        concepts = []
        for tid in set(heads) | set(mets):
            h = heads.get(tid)
            m = mets.get(tid)
            n_applied = applied.get(tid, 0)
            n_mis = m.n_misfires if m else 0
            denom = n_applied + n_mis
            concepts.append({
                "tag_id": tid,
                "name": names.get(tid, str(tid)),
                "n_auto_applied": n_applied,
                "n_misfires": n_mis,
                "n_underfires": m.n_underfires if m else 0,
                # Of everything this head ever auto-applied, the fraction you
                # removed — the misfire rate (null until something fired).
                "misfire_rate": round(n_mis / denom, 4) if denom else None,
                "ap": h.ap if h else None,
                "precision_cv": h.precision_cv if h else None,
                "recall": h.recall if h else None,
                "auto_apply": bool(h and h.auto_apply_threshold is not None),
                "n_pos": h.n_pos if h else None,
            })
        concepts.sort(key=lambda c: (c["n_misfires"], c["n_auto_applied"]), reverse=True)

        snaps = (
            await session.execute(
                select(HeadMetricsSnapshot)
                .order_by(HeadMetricsSnapshot.snapshot_at.desc())
                .limit(1000)
            )
        ).scalars().all()
    return jsonify({
        "concepts": concepts,
        "snapshots": [
            {
                "tag_id": s.tag_id,
                "name": s.name,
                "snapshot_at": s.snapshot_at.isoformat() if s.snapshot_at else None,
                "n_auto_applied": s.n_auto_applied,
                "n_misfires": s.n_misfires,
                "n_underfires": s.n_underfires,
                "ap": s.ap,
                "precision_cv": s.precision_cv,
                "recall": s.recall,
                "n_pos": s.n_pos,
            }
            for s in snaps
        ],
    })
