"""Shared tag-with-fandom query columns, serialization, and membership.

`image_in_tag_scope`/`image_in_any_tag_scope` are the SINGLE source of truth for
"which images belong to a tag" — a fandom tag also owns the images of its
characters (Tag.fandom_id), derived at query time rather than materialized. The
gallery scope, directory count/previews, and the cleanup impact count all route
through them so a fandom can never under-count its characters' images.

Resolving a character tag's fandom NAME via a Tag self-join (Tag.fandom_id -> the
fandom Tag) and serializing the canonical
``{id, name, kind, fandom_id, fandom_name}`` dict were hand-written in
TagService.autocomplete / .list_for_image and GalleryService.get_image_with_tags
(the last two added by the fandom-on-chip feature, 978f49a). One source now
(DRY pattern sweep 2026-06-10) so a new tag field is added in exactly one place.

TagDirectoryService selects the FULL Tag ORM plus an image-count aggregate (a
different select shape), so it keeps its own variant — not folded in here.
"""

from sqlalchemy import exists, or_, select
from sqlalchemy.orm import aliased

from ..models import ImageRecord, Tag
from ..models.tag import image_tag


def _fandom_member_char_ids(tids):
    """Subquery of character tag ids owned by any fandom in `tids` (via
    Tag.fandom_id). Empty for non-fandom tids, so callers can OR it in
    unconditionally — a fandom aggregates its characters' images, every other
    kind degrades to direct-only."""
    char = aliased(Tag)
    return select(char.id).where(char.fandom_id.in_(tids))


def image_in_tag_scope(tid):
    """Correlated EXISTS: the current ImageRecord belongs to tag `tid` — either
    tagged with it DIRECTLY, or (when `tid` is a fandom) carrying a character
    whose fandom_id == `tid`. This is the SINGLE membership predicate shared by
    the gallery scope, the directory count/previews, and the cleanup impact
    count, so a fandom never under-counts the images of its characters."""
    return exists().where(
        image_tag.c.image_record_id == ImageRecord.id,
        or_(
            image_tag.c.tag_id == tid,
            image_tag.c.tag_id.in_(_fandom_member_char_ids([tid])),
        ),
    )


def image_in_any_tag_scope(tids):
    """Like `image_in_tag_scope` but for an OR-group: the image carries AT LEAST
    ONE of `tids` directly, or a character of any fandom in `tids`."""
    return exists().where(
        image_tag.c.image_record_id == ImageRecord.id,
        or_(
            image_tag.c.tag_id.in_(tids),
            image_tag.c.tag_id.in_(_fandom_member_char_ids(tids)),
        ),
    )


def fandom_join_alias():
    """A Tag self-join alias for resolving a tag's fandom name. Outerjoin it on
    ``Tag.fandom_id == <alias>.c.id`` and select via `tag_columns(alias)`."""
    return Tag.__table__.alias("fandom_lookup")


def tag_columns(fandom_alias):
    """The canonical (id, name, kind, fandom_id, fandom_name, is_system)
    column set for a tag select that outerjoins `fandom_alias` (from
    `fandom_join_alias()`)."""
    return [
        Tag.id,
        Tag.name,
        Tag.kind,
        Tag.fandom_id,
        fandom_alias.c.name.label("fandom_name"),
        Tag.is_system,
    ]


def serialize_tag(row) -> dict:
    """Serialize a row carrying .id/.name/.kind/.fandom_id/.fandom_name to the
    canonical tag dict. `kind` may be a TagKind enum or a plain string.
    `is_system` defaults False for callers whose selects predate the column."""
    return {
        "id": row.id,
        "name": row.name,
        "kind": row.kind.value if hasattr(row.kind, "value") else row.kind,
        "fandom_id": row.fandom_id,
        "fandom_name": row.fandom_name,
        "is_system": bool(getattr(row, "is_system", False)),
        # Applied-tag context: only the image-scoped selects (list_for_image /
        # get_image_with_tags) provide these; autocomplete / directory don't →
        # default. `source` drives the auto-applied badge; `confirmed` = the
        # operator affirmed the tag (a training positive, shielded from the
        # retraction sweep — milestone 139).
        "source": getattr(row, "source", None),
        "confirmed": bool(getattr(row, "confirmed", False)),
    }
