"""The suggestion read-path: trained HEADS score one image's frozen embedding
into alias-resolved, category-grouped, ranked suggestions.

Tagging-v2 (#114): suggestions now come from the per-concept heads that LEARN
from the operator's tags (services/ml/heads.py) — the Camie prediction source
and the per-tag SigLIP centroid have been REMOVED. A head exists only for an
existing concept tag, so every suggestion is a canonical tag (no raw model key,
no alias remap, no creates-new). Rejected tags stay in the list FLAGGED (not
dropped) so the rail can show + reverse a dismissal.
"""

from dataclasses import dataclass, field

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from ...models import ImageRecord, TagSuggestionRejection
from ...models.tag import image_tag
from .ccip import match_image as ccip_match_image
from .heads import score_image


@dataclass(frozen=True)
class Suggestion:
    # Every suggestion is a canonical Tag: heads/CCIP only score EXISTING concept
    # tags (tagging-v2, #114). The old raw-model-key / creates-new / alias-remap
    # cases are gone — a suggestion always maps to a real tag id.
    canonical_tag_id: int
    display_name: str
    category: str
    score: float
    source: str  # 'head' | 'ccip' | 'both' (Camie tagger/centroid removed in v2)
    # above_threshold = the score cleared the head's own suggest cut (or the
    # system floor). The Suggestions PANEL shows only these; the typed-tag
    # dropdown fetches ALL suggestions (every head, min=0) and just annotates each
    # matching row with its score, so a low-confidence concept can still be typed
    # and picked. CCIP character matches are always above their match threshold.
    above_threshold: bool
    # rejected = the operator dismissed this tag for this image (a stored
    # TagSuggestionRejection). It stays in the list — flagged, not dropped — so
    # the rejection is VISIBLE and REVERSIBLE in the rail (misclick recovery,
    # operator-asked 2026-06-27) instead of silently vanishing or re-suggesting.
    rejected: bool = False
    # grounding = the crop region that produced this suggestion (#1206):
    # {bbox:[x,y,w,h] normalized, kind, detector}. None when the whole-image
    # vector won (not localized) or for a CCIP-only hit (figure grounding TBD).
    # Lets the rail highlight the exact region on hover.
    grounding: dict | None = None


@dataclass
class SuggestionList:
    by_category: dict[str, list[Suggestion]] = field(default_factory=dict)


class SuggestionService:
    def __init__(self, session: AsyncSession):
        self.session = session

    async def for_image(
        self, image_id: int, threshold_override: float | None = None,
    ) -> SuggestionList:
        """Head-scored suggestions for one image, grouped by category and ranked.

        Each trained head scores the image's frozen embedding; a concept surfaces
        when its score clears the head's own suggest threshold. threshold_override
        (used by the typed tag-input dropdown's "show everything" mode) replaces
        that per-head cut with a flat floor (0 → every head), so a low-scoring
        concept can still be typed + picked in canonical formatting.

        Already-applied tags are dropped; rejected tags stay FLAGGED and sink to
        the bottom of their category so a dismissal is visible + reversible."""
        img = await self.session.get(ImageRecord, image_id)
        if img is None:
            return SuggestionList()

        applied = set(
            (
                await self.session.execute(
                    select(image_tag.c.tag_id).where(
                        image_tag.c.image_record_id == image_id
                    )
                )
            ).scalars().all()
        )
        rejected = set(
            (
                await self.session.execute(
                    select(TagSuggestionRejection.tag_id).where(
                        TagSuggestionRejection.image_record_id == image_id
                    )
                )
            ).scalars().all()
        )

        hits = await score_image(
            self.session, image_id, threshold_override=threshold_override
        )
        # CCIP character matches OVERLAY the SigLIP character heads — a
        # complementary, identity-specialized signal with different failure modes
        # (CCIP needs a detected figure; heads work whole-image). Merged by tag:
        # 'both' when they corroborate, taking the higher score.
        ccip_hits = await ccip_match_image(self.session, image_id)

        merged: dict[tuple[str, int], dict] = {}
        for h in hits:
            merged[(h["category"], h["tag_id"])] = {
                "name": h["name"], "score": h["score"], "source": "head",
                "above_threshold": h["above_threshold"],
                "grounding": h.get("grounding"),
            }
        for c in ccip_hits:
            key = ("character", c["tag_id"])
            ex = merged.get(key)
            if ex is not None:
                ex["source"] = "both"
                ex["score"] = max(ex["score"], c["score"])
                # CCIP only returns matches above its own threshold, so a CCIP
                # corroboration always makes the merged suggestion above-threshold.
                ex["above_threshold"] = True
                # Keep the head's localized crop if it had one; else fall back to
                # the CCIP figure so a corroborated character still grounds (#1206).
                ex["grounding"] = ex.get("grounding") or c.get("grounding")
            else:
                merged[key] = {
                    "name": c["name"], "score": c["score"], "source": "ccip",
                    "above_threshold": True,
                    "grounding": c.get("grounding"),
                }

        result = SuggestionList()
        for (cat, tag_id), m in merged.items():
            if tag_id in applied:
                continue
            result.by_category.setdefault(cat, []).append(
                Suggestion(
                    canonical_tag_id=tag_id,
                    display_name=m["name"],
                    category=cat,
                    score=m["score"],
                    source=m["source"],
                    above_threshold=m["above_threshold"],
                    rejected=tag_id in rejected,
                    grounding=m.get("grounding"),
                )
            )
        for cat in result.by_category:
            # Live suggestions first (by score), rejected ones sink to the
            # bottom of the category — visible for recovery, out of the way.
            result.by_category[cat].sort(key=lambda s: (s.rejected, -s.score))
        return result

    async def for_selection(
        self,
        image_ids: list[int],
        threshold: float = 0.8,
        top_k: int = 10,
    ) -> dict[str, list[dict]]:
        """Consensus suggestions across image_ids. A tag is included iff it
        was suggested for (or already applied to) >= threshold fraction of
        the selection AND was acceptable on >= 1 image. Confidence is the
        mean over images where it was suggested. Aggregated by
        canonical_tag_id (every suggestion is a canonical tag now)."""
        if not image_ids:
            return {}
        threshold = min(1.0, max(0.0, threshold))
        total = len(image_ids)

        stats: dict[int, dict] = {}
        for image_id in image_ids:
            sl = await self.for_image(image_id)
            for category, items in sl.by_category.items():
                for s in items:
                    # for_image keeps rejected tags (flagged) for the rail;
                    # bulk consensus must still ignore them — a tag dismissed on
                    # an image isn't a suggestion for that image.
                    if s.rejected:
                        continue
                    st = stats.get(s.canonical_tag_id)
                    if st is None:
                        st = {
                            "tag_id": s.canonical_tag_id,
                            "name": s.display_name,
                            "category": category,
                            "source": s.source,
                            "suggested_count": 0,
                            "sum_score": 0.0,
                        }
                        stats[s.canonical_tag_id] = st
                    st["suggested_count"] += 1
                    st["sum_score"] += s.score

        rows = (
            await self.session.execute(
                select(
                    image_tag.c.image_record_id, image_tag.c.tag_id
                ).where(image_tag.c.image_record_id.in_(image_ids))
            )
        ).all()
        applied_by_tag: dict[int, set[int]] = {}
        for iid, tid in rows:
            applied_by_tag.setdefault(tid, set()).add(iid)

        result: dict[str, list[dict]] = {}
        for st in stats.values():
            existing_count = len(applied_by_tag.get(st["tag_id"], set()))
            covered = st["suggested_count"] + existing_count
            coverage = covered / total
            if coverage < threshold or st["suggested_count"] < 1:
                continue
            result.setdefault(st["category"], []).append(
                {
                    "canonical_tag_id": st["tag_id"],
                    "name": st["name"],
                    "category": st["category"],
                    "confidence": round(
                        st["sum_score"] / st["suggested_count"], 4
                    ),
                    "coverage": round(coverage, 4),
                    "covered_count": covered,
                    "source": st["source"],
                }
            )
        for cat in result:
            result[cat].sort(key=lambda x: x["confidence"], reverse=True)
            result[cat] = result[cat][:top_k]
        return result
