"""Scalanie dwóch scen kanonicznych w jedną (admin merge).

`keep_id` przejmuje wszystko od `drop_id`:
  - external_refs (ze zmianą scene_id na keep)
  - scene_performers (z deduplikacją na (scene_id, performer_id))
  - scene_tags
  - scene_fingerprints
Następnie `drop` Scene jest usuwana — CASCADE i tak by wyczyściło reszta, ale
relacje i tak przepinamy do `keep`, a nie kasujemy razem ze sceną.

Pending merge_candidates referencjonujące `drop_id` (left lub right) są kasowane
żeby admin nie musiał ich ponownie rozstrzygać.
"""
from __future__ import annotations

import logging
import uuid
from datetime import UTC, datetime

from sqlalchemy import or_, select, update
from sqlalchemy.orm import Session

from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus
from app.models.playback_source import PlaybackSource
from app.models.scene import (
    Scene,
    SceneExternalRef,
    SceneFingerprint,
    ScenePerformer,
    SceneTag,
)

log = logging.getLogger(__name__)


class MergeError(Exception):
    pass


def merge_scenes(
    session: Session,
    *,
    keep_id: uuid.UUID,
    drop_id: uuid.UUID,
    resolved_by: str | None = None,
) -> Scene:
    if keep_id == drop_id:
        raise MergeError("cannot merge scene into itself")

    keep = session.get(Scene, keep_id)
    drop = session.get(Scene, drop_id)
    if keep is None or drop is None:
        raise MergeError("scene not found")

    _move_external_refs(session, keep_id=keep_id, drop_id=drop_id)
    _move_performers(session, keep_id=keep_id, drop_id=drop_id)
    _move_tags(session, keep_id=keep_id, drop_id=drop_id)
    _move_fingerprints(session, keep_id=keep_id, drop_id=drop_id)
    _move_playback_sources(session, keep_id=keep_id, drop_id=drop_id)
    _coalesce_canonical_fields(keep, drop)

    session.delete(drop)
    session.flush()

    _close_pending_candidates(session, scene_id=drop_id, resolved_by=resolved_by)
    return keep


def resolve_candidate(
    session: Session,
    *,
    candidate_id: uuid.UUID,
    action: str,  # "merge" | "reject"
    keep_left: bool = True,
    resolved_by: str | None = None,
) -> MergeCandidate:
    """Rozstrzyga jeden MergeCandidate. Dla `merge` decyzja co zostaje:
    `keep_left=True` (default) → `left_id` przejmuje `right_id`."""
    cand = session.get(MergeCandidate, candidate_id)
    if cand is None:
        raise MergeError("candidate not found")
    if cand.status != MergeStatus.pending:
        raise MergeError(f"candidate already resolved: status={cand.status.value}")
    if cand.kind != MergeKind.scene:
        raise MergeError(f"only scene merges are supported (got {cand.kind.value})")

    now = datetime.now(UTC)
    if action == "reject":
        cand.status = MergeStatus.rejected
        cand.resolved_at = now
        cand.resolved_by = resolved_by
        return cand

    if action == "merge":
        keep_id, drop_id = (cand.left_id, cand.right_id) if keep_left else (cand.right_id, cand.left_id)
        merge_scenes(session, keep_id=keep_id, drop_id=drop_id, resolved_by=resolved_by)
        cand.status = MergeStatus.merged
        cand.resolved_at = now
        cand.resolved_by = resolved_by
        # Update reasons z final decyzją (zachowaj poprzednie scoring data)
        reasons = dict(cand.reasons or {})
        reasons["resolution"] = {"keep_id": str(keep_id), "drop_id": str(drop_id)}
        cand.reasons = reasons
        return cand

    raise MergeError(f"unsupported action: {action}")


# ---- helpery --------------------------------------------------------------

def _move_external_refs(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
    drop_refs = (
        session.execute(select(SceneExternalRef).where(SceneExternalRef.scene_id == drop_id))
        .scalars()
        .all()
    )
    for ref in drop_refs:
        clash = session.execute(
            select(SceneExternalRef).where(
                SceneExternalRef.source_id == ref.source_id,
                SceneExternalRef.external_id == ref.external_id,
                SceneExternalRef.scene_id == keep_id,
            )
        ).scalar_one_or_none()
        if clash is not None:
            # Już mamy ref pod keep — usuń konkurencyjny pod drop
            session.delete(ref)
        else:
            ref.scene_id = keep_id


def _move_performers(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
    drop_links = (
        session.execute(select(ScenePerformer).where(ScenePerformer.scene_id == drop_id))
        .scalars()
        .all()
    )
    for link in drop_links:
        clash = session.execute(
            select(ScenePerformer).where(
                ScenePerformer.scene_id == keep_id,
                ScenePerformer.performer_id == link.performer_id,
            )
        ).scalar_one_or_none()
        if clash is not None:
            if link.as_alias and not clash.as_alias:
                clash.as_alias = link.as_alias
            session.delete(link)
        else:
            link.scene_id = keep_id


def _move_tags(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
    drop_links = (
        session.execute(select(SceneTag).where(SceneTag.scene_id == drop_id))
        .scalars()
        .all()
    )
    for link in drop_links:
        clash = session.execute(
            select(SceneTag).where(
                SceneTag.scene_id == keep_id, SceneTag.tag_id == link.tag_id
            )
        ).scalar_one_or_none()
        if clash is not None:
            session.delete(link)
        else:
            link.scene_id = keep_id


def _move_fingerprints(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
    drops = (
        session.execute(select(SceneFingerprint).where(SceneFingerprint.scene_id == drop_id))
        .scalars()
        .all()
    )
    for fp in drops:
        clash = session.execute(
            select(SceneFingerprint).where(
                SceneFingerprint.scene_id == keep_id,
                SceneFingerprint.kind == fp.kind,
                SceneFingerprint.value == fp.value,
            )
        ).scalar_one_or_none()
        if clash is not None:
            session.delete(fp)
        else:
            fp.scene_id = keep_id


def _move_playback_sources(session: Session, *, keep_id: uuid.UUID, drop_id: uuid.UUID) -> None:
    """Przepnij playback_sources z `drop` na `keep`.

    Wcześniej merge_scenes NIE ruszał playback_sources → CASCADE (ON DELETE) kasował
    je razem z `drop` sceną. Dla cross-source merdży (tpdb↔stashdb canonical, zwykle
    bez tube-playbacku) nie bolało, ale phash_exact / tube-dup merge GUBIŁ linki do
    odtwarzania. Unique constraint `(origin, page_url)` jest GLOBALNY → drop i keep nie
    mogą współdzielić tego samego źródła, więc samo przepięcie scene_id nie grozi kolizją.
    """
    session.execute(
        update(PlaybackSource)
        .where(PlaybackSource.scene_id == drop_id)
        .values(scene_id=keep_id)
    )


def _coalesce_canonical_fields(keep: Scene, drop: Scene) -> None:
    """Wypełnij braki w `keep` polami z `drop`. Nie nadpisuje istniejących wartości."""
    if not keep.description and drop.description:
        keep.description = drop.description
    if not keep.duration_sec and drop.duration_sec:
        keep.duration_sec = drop.duration_sec
    if not keep.code and drop.code:
        keep.code = drop.code
    if not keep.director and drop.director:
        keep.director = drop.director
    if not keep.release_date and drop.release_date:
        keep.release_date = drop.release_date
    if not keep.studio_id and drop.studio_id:
        keep.studio_id = drop.studio_id
    if drop.title and len(drop.title) > len(keep.title or ""):
        keep.title = drop.title
        keep.title_normalized = drop.title_normalized
    # created_at = najwcześniejsze "first seen" obu scen. NEW badge (apka) bazuje na
    # created_at; gdy dedup wybierze świeży re-ingest/mirror jako `keep`, stara treść
    # dostawałaby fałszywe NEW po merdżu (report f17799b3). Bierzemy min → zdeduplikowana
    # scena zachowuje datę pierwszego pojawienia, nie datę re-ingestu.
    if drop.created_at and keep.created_at and drop.created_at < keep.created_at:
        keep.created_at = drop.created_at
    # backfill: scalona scena jest backfillem TYLKO gdy OBIE były backfillem. Jeśli
    # którakolwiek była świeża (backfill=False), treść jest realnie nowa, więc zdjęcie
    # flagi przywraca jej status "nowa" (review 5: fresh scena scalona w martwy dup nie
    # może na stałe stracić NEW przez to że keep był backfillem).
    keep.backfill = bool(keep.backfill and drop.backfill)


def _close_pending_candidates(
    session: Session, *, scene_id: uuid.UUID, resolved_by: str | None
) -> None:
    """Pending candidates referencjonujące usuniętą scenę kasujemy (status=rejected),
    bo right_id już nie istnieje. Auto_merged/merged zostawiamy jako audit."""
    session.execute(
        update(MergeCandidate)
        .where(
            MergeCandidate.status == MergeStatus.pending,
            or_(MergeCandidate.left_id == scene_id, MergeCandidate.right_id == scene_id),
        )
        .values(
            status=MergeStatus.rejected,
            resolved_at=datetime.now(UTC),
            resolved_by=resolved_by or "auto:scene_dropped",
        )
    )
