"""TagHead — a small per-concept classifier trained on the operator's tags.

Milestone #114, tagging-v2: the production form of the head the eval (#1130)
proved. One row per concept (general or character) that has enough labelled
positives. The head is a logistic-regression boundary over the FROZEN SigLIP
embedding (L2-normalized), trained on the operator's positives + negatives
(rejections + sampled unlabeled). It REPLACES the Camie prediction + per-tag
centroid as the suggestion source — and unlike them it LEARNS: every accept /
reject re-trains it sharper.

Scoring (suggestion path, API worker, NO numpy): p = sigmoid(weights · x̂ + bias)
where x̂ is the L2-normalized image embedding. Surface as a suggestion when
p >= suggest_threshold; auto-apply only once auto_apply_threshold is set (the
head "graduated" — a precision-targeted operating point was achievable). The
thresholds come from CROSS-VALIDATED out-of-fold scores so they're honest, not
in-sample-optimistic; the deployable weights are fit on all data.
"""

from datetime import datetime
from typing import Any

from pgvector.sqlalchemy import Vector
from sqlalchemy import (
    DateTime,
    Float,
    ForeignKey,
    Integer,
    String,
    func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from .base import Base

# Matches image_record.siglip_embedding's dimensionality — the head operates in
# the same space. A model-version change re-embeds AND retrains (embedding_version
# guards staleness).
HEAD_DIM = 1152


class TagHead(Base):
    __tablename__ = "tag_head"

    # One head per concept tag; cascade so deleting a tag retires its head.
    tag_id: Mapped[int] = mapped_column(
        ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
    )
    # The embedding the head was trained against (image_record's
    # embedder_model_version). A mismatch with the current embedder means the
    # head is stale and must be retrained, not scored.
    embedding_version: Mapped[str] = mapped_column(String(128), nullable=False)
    # Logistic-regression coefficients over the L2-normalized embedding, stored
    # as a pgvector for compactness + a future in-DB dot-product path. NOT a
    # similarity target, just a serialized weight vector.
    weights: Mapped[list[float]] = mapped_column(Vector(HEAD_DIM), nullable=False)
    bias: Mapped[float] = mapped_column(Float, nullable=False)
    # Probability cutoff for SURFACING as a suggestion (F1-best on CV scores).
    suggest_threshold: Mapped[float] = mapped_column(Float, nullable=False)
    # Probability cutoff for EARNED auto-apply: the operating point that holds
    # precision >= the configured target while maximizing recall. NULL = the head
    # hasn't graduated (can't auto-apply without a human yet).
    auto_apply_threshold: Mapped[float | None] = mapped_column(Float, nullable=True)
    # Training-set sizes + cross-validated quality, surfaced in the admin card so
    # the operator can see which concepts are strong / need more tags.
    n_pos: Mapped[int] = mapped_column(Integer, nullable=False)
    n_neg: Mapped[int] = mapped_column(Integer, nullable=False)
    ap: Mapped[float] = mapped_column(Float, nullable=False)
    # 'precision' is a SQL reserved word → store as precision_cv (the
    # cross-validated precision at the suggest operating point).
    precision_cv: Mapped[float] = mapped_column(Float, nullable=False)
    recall: Mapped[float] = mapped_column(Float, nullable=False)
    trained_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False, server_default=func.now()
    )
    # Training-data fingerprint (positives + rejections) at last fit — the
    # incremental-retrain change detector (#1317 p2). A manual Retrain refits only
    # heads whose fingerprint moved; the nightly run ignores it (full reconcile).
    # NULL forces a refit (pre-fingerprint heads).
    train_fingerprint: Mapped[str | None] = mapped_column(
        String(128), nullable=True
    )
    # Extra detail (auto-apply operating point, F1, etc.) — non-load-bearing.
    metrics: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
