"""RelationSyncService — entity_relation 체크포인트 → RelationFact/RelationParticipant.

Phase 4.4: 기존 "visual_variant 전체 DELETE 후 재생성" 경로를 delta sync
(UPSERT)로 전환. `(base_canon_id, variant_canon_id)` 논리 키 기준으로 동일한
관계는 UPDATE(continuity_reason만 갱신), 사라진 관계만 DELETE, 새 관계만 INSERT.
"""
from __future__ import annotations

import uuid
from typing import Dict, Tuple

from sqlalchemy import text as sql_text

from app.services.checkpoint_sync._base import BaseSyncService, is_cp_syncable


#: 옛 CP 의 행에는 `relation_type` 칸이 **없다** — 그것은 `visual_variant` 다.
_LEGACY_TYPE = "visual_variant"


class RelationSyncRefused(RuntimeError):
    """지우기 **전에** 선다. ★「못 찾았다」를 「없어졌다」로 안 읽는다."""


def _speaks(rows, relation_type: str) -> bool:
    """이 CP 가 그 관계 종류에 대해 **말을 했나**.

    ★★★한 타입의 delta 가 다른 타입을 stale 로 지우면 안 된다 (Codex
    2026-09-01). `part_of` 행만 담은 CP 를 「visual_variant 가 없다」로 읽으면
    남의 타입이 통째로 지워진다 — 실제로 그랬다.

        빈 목록                 → **전부에 대해** 「없다」고 말한 것
        옛 행(칸 없음)          → `visual_variant` 를 말한 것
        `relation_type` 명시    → 그 타입을 말한 것

    ★빈 목록을 「말 안 했다」로 읽으면 기존 계약(`completed` + `relations=[]`
    = authoritative zero)이 깨진다. 가르는 것은 **다른 타입의 행이 있느냐**다.
    """
    if not rows:
        return True
    want = str(relation_type)
    for r in rows or ():
        got = str((r or {}).get("relation_type") or "") or _LEGACY_TYPE
        if got == want:
            return True
    return False


class RelationSyncService(BaseSyncService):
    def sync_from_checkpoint(self) -> Dict[str, int]:
        """entity_relation 체크포인트 → RelationFact + RelationParticipant (delta sync).

        Returns: {"relations": n_current, "inserted": n, "updated": n, "deleted": n, "skipped": n}
        """
        from app.models.project import (EntityCanon, RelationFact,
                                        RelationParticipant)
        from app.modules.pipeline.grounding_entity_contract import (
            relation_sync_owner_types)

        rel_cp = self._load_cp("entity_relation")
        # M2 Fix 2 단일 표준: partial이라도 데이터 있으면 sync.
        # 빈 relations(cascade 직후)면 is_cp_syncable이 False 반환 — 별도 검사 불필요.
        if not is_cp_syncable(rel_cp, ["relations"]):
            return self._empty_delta(skipped=1)

        # Codex P1-2: partial 시 stale 제거 skip — 부분 cp가 미포함 relation을
        # 정상 누락으로 처리해 옛 row 삭제하지 않도록. completed만 authoritative.
        is_partial = (rel_cp.get("status") == "partial")

        # short_id → entity_canon.id 매핑
        canon_rows = (
            self.db.query(EntityCanon)
            .filter(
                EntityCanon.project_id == self.project_id,
                # ★그 relation 이 **닿는 갈래**만 — 「전체 owner 목록」이
                #  아니다 (Codex BLOCK 2026-09-01). 이 조회는
                #  `visual_variant` delta 용이다.
                EntityCanon.entity_type.in_(
                    list(relation_sync_owner_types("visual_variant"))),
            )
            .all()
        )
        sid_to_canon_id = {c.short_id: c.id for c in canon_rows}

        # 기대 상태 (체크포인트에서 parse)
        # key: (base_canon_id, variant_canon_id) → reason
        # P2-2: completed cp는 data.relations 키 자체가 없을 수도 있음 (빈 zero rows).
        rows = rel_cp.get("data", {}).get("relations", []) or []
        desired: Dict[Tuple[str, str], str] = {}
        for rel in rows:
            if not rel.get("visual_similarity"):
                continue
            base_cid = sid_to_canon_id.get(rel.get("base_short_id", ""))
            var_cid = sid_to_canon_id.get(rel.get("variant_short_id", ""))
            if not base_cid or not var_cid:
                self.logger.warning(
                    "Relation skip — unknown canon: %s→%s",
                    rel.get("base_short_id"),
                    rel.get("variant_short_id"),
                )
                continue
            desired[(base_cid, var_cid)] = rel.get(
                "reason", "시각적 변형 — 기본 요소에 의존"
            )

        # 현재 상태 (DB의 visual_variant)
        # key: (base_canon_id, variant_canon_id) → (rel_id, continuity_reason)
        existing = self._load_existing_visual_variants()

        # Delta 계산
        desired_keys = set(desired.keys())
        existing_keys = set(existing.keys())

        to_insert = desired_keys - existing_keys
        # Codex P1-2: partial이면 stale relation 삭제 skip — 부분 cp가 옛 relation을
        # 정상 누락으로 처리해 데이터 손실되지 않도록 (다음 completed에서 정리).
        # ★★★그리고 **이 CP 가 그 타입을 말했을 때만** 지운다 (Codex 2026-09-01).
        #  `part_of` 행만 담은 CP 는 visual_variant 에 대해 **아무 말도 안 한
        #  것**이다 — 그것을 「없다」로 읽으면 남의 타입을 지운다.
        to_delete = (set() if (is_partial or not _speaks(rows, "visual_variant"))
                     else (existing_keys - desired_keys))
        to_update = [
            key for key in desired_keys & existing_keys
            if desired[key] != existing[key][1]
        ]

        # 삭제 (participant 먼저 → fact)
        for key in to_delete:
            rel_id = existing[key][0]
            self.db.execute(
                sql_text("DELETE FROM relation_participant WHERE relation_id = :rid"),
                {"rid": rel_id},
            )
            self.db.execute(
                sql_text("DELETE FROM relation_fact WHERE id = :rid"),
                {"rid": rel_id},
            )

        # 업데이트 (continuity_reason만)
        for key in to_update:
            rel_id = existing[key][0]
            self.db.execute(
                sql_text(
                    "UPDATE relation_fact SET continuity_reason = :reason WHERE id = :rid"
                ),
                {"reason": desired[key], "rid": rel_id},
            )

        # 신규 (RelationFact + 2 participant)
        for key in to_insert:
            base_cid, var_cid = key
            rel_id = str(uuid.uuid4())
            self.db.add(
                RelationFact(
                    id=rel_id,
                    project_id=self.project_id,
                    relation_family="identity",
                    relation_type="visual_variant",
                    directionality="directed",
                    temporal_scope="persistent",
                    continuity_priority="critical",
                    continuity_reason=desired[key],
                    created_at=self.now,
                )
            )
            self.db.add(
                RelationParticipant(
                    id=str(uuid.uuid4()),
                    relation_id=rel_id,
                    canon_id=base_cid,
                    participant_role="base",
                    participant_order=1,
                )
            )
            self.db.add(
                RelationParticipant(
                    id=str(uuid.uuid4()),
                    relation_id=rel_id,
                    canon_id=var_cid,
                    participant_role="variant",
                    participant_order=2,
                )
            )

        if to_insert or to_update or to_delete:
            self.db.flush()
            self.logger.info(
                "Visual-variant delta sync: +%d / ~%d / -%d (total=%d)",
                len(to_insert),
                len(to_update),
                len(to_delete),
                len(desired),
            )

        # ★★★`part_of` 는 **제 타입만** 본다 (§2-6.5, 2026-09-01).
        #  한 타입의 delta 가 다른 타입을 stale 로 지우면 안 된다 — 그래서
        #  조회·삭제 모두 `relation_type` 을 못박는다.
        part = self._sync_part_of(rel_cp, is_partial=is_partial)

        return {
            "relations": len(desired) + part["relations"],
            "inserted": len(to_insert) + part["inserted"],
            "updated": len(to_update),
            "deleted": len(to_delete) + part["deleted"],
            "skipped": 0,
            # ★반환 **모양을 안 바꾼다** — 부르는 쪽(orchestrator)과 기존 시험이
            #  이 다섯 칸을 그대로 본다. 타입별 내역은 DB 에서 센다.
        }

    def _sync_part_of(self, rel_cp, *, is_partial: bool) -> Dict[str, int]:
        """`location_part → location` 을 **DB 에** 남긴다.

        ★기대 상태는 `grounding_relation_projection.desired_keys` 가 낸다 —
        여기서 CP 를 다시 해석하지 않는다. 갈래 짝 검사도 **그 함수 안에서**
        같이 돈다(`assert_pair`).
        ★`visual_variant` 를 **건드리지 않는다**. 반대도 마찬가지다.

        ★★★원하는 participant 의 canon 이 **하나라도 없으면 선다** (Codex
        BLOCK 2026-09-01). 앞 판은 경고하고 넘어간 뒤 `have - desired` 로
        **멀쩡한 기존 `part_of` 를 지웠다** — 그리고 sync 는 성공으로 끝났다.
        「못 찾았다」를 「없어졌다」로 읽으면 데이터 손실이다.
        """
        from app.models.project import RelationFact, RelationParticipant
        from app.modules.pipeline.grounding_relation_projection import (
            RELATION_PART_OF, ROLE_PART, ROLE_WHOLE, desired_keys)

        rows = rel_cp.get("data", {}).get("relations", []) or []
        want = desired_keys(rows, relation_type=RELATION_PART_OF)
        speaks = _speaks(rows, RELATION_PART_OF)
        sid_to_canon = self._canon_by_short_id()
        desired = set()
        missing = []
        for p_sid, w_sid in want:
            p, w = sid_to_canon.get(p_sid), sid_to_canon.get(w_sid)
            if not p or not w:
                missing.append((p_sid, w_sid))
                continue
            desired.add((p, w))
        if missing:
            # ★**아무것도 지우기 전에** 선다
            raise RelationSyncRefused(
                f"`part_of` 가 가리키는 canon 이 없다: {sorted(missing)} — "
                "이것을 「없어졌다」로 읽으면 멀쩡한 관계를 지운다. "
                "EntitySync 가 먼저 그 canon 을 만들어야 한다")

        have = self._load_existing_part_of()
        to_insert = desired - set(have)
        # ★이 CP 가 `part_of` 를 말했을 때만 지운다 — 반대도 마찬가지다
        to_delete = (set() if (is_partial or not speaks)
                     else (set(have) - desired))

        for key in to_delete:
            rid = have[key]
            self.db.execute(sql_text(
                "DELETE FROM relation_participant WHERE relation_id = :rid"),
                {"rid": rid})
            self.db.execute(sql_text(
                "DELETE FROM relation_fact WHERE id = :rid"), {"rid": rid})

        for part_cid, whole_cid in to_insert:
            rid = str(uuid.uuid4())
            self.db.add(RelationFact(
                id=rid, project_id=self.project_id,
                relation_family="structure", relation_type=RELATION_PART_OF,
                directionality="directed", temporal_scope="persistent",
                continuity_priority="critical",
                continuity_reason="구조 — 장소의 고정 설비",
                created_at=self.now))
            for cid, role, order in ((part_cid, ROLE_PART, 1),
                                     (whole_cid, ROLE_WHOLE, 2)):
                self.db.add(RelationParticipant(
                    id=str(uuid.uuid4()), relation_id=rid, canon_id=cid,
                    participant_role=role, participant_order=order))
        if to_insert or to_delete:
            self.db.flush()
        return {"relations": len(desired), "inserted": len(to_insert),
                "deleted": len(to_delete)}

    def _canon_by_short_id(self) -> Dict[str, str]:
        """`short_id` → canon id. ★`part_of` 가 닿는 갈래만 본다."""
        from app.models.project import EntityCanon
        from app.modules.pipeline.grounding_entity_contract import (
            relation_sync_owner_types)

        rows = (self.db.query(EntityCanon)
                .filter(EntityCanon.project_id == self.project_id,
                        EntityCanon.entity_type.in_(
                            list(relation_sync_owner_types("part_of"))))
                .all())
        return {c.short_id: c.id for c in rows if c.short_id}

    def _load_existing_part_of(self) -> Dict[Tuple[str, str], str]:
        """DB 의 `part_of` — ★**그 타입만**. `visual_variant` 를 안 본다."""
        from app.modules.pipeline.grounding_relation_projection import (
            RELATION_PART_OF, ROLE_PART, ROLE_WHOLE)

        rows = self.db.execute(sql_text(
            "SELECT rf.id AS rel_id, "
            "  MAX(CASE WHEN rp.participant_role = :part "
            "      THEN rp.canon_id END) AS part_cid, "
            "  MAX(CASE WHEN rp.participant_role = :whole "
            "      THEN rp.canon_id END) AS whole_cid "
            "FROM relation_fact rf "
            "LEFT JOIN relation_participant rp ON rp.relation_id = rf.id "
            "WHERE rf.project_id = :pid AND rf.relation_type = :rt "
            "GROUP BY rf.id"),
            {"pid": self.project_id, "rt": RELATION_PART_OF,
             "part": ROLE_PART, "whole": ROLE_WHOLE}).fetchall()
        got: Dict[Tuple[str, str], str] = {}
        broken = []
        for rid, p, w in ((r[0], r[1], r[2]) for r in rows):
            if not p or not w:
                broken.append(rid)
                continue
            got[(p, w)] = rid
        if broken:
            # ★망가진 관계를 **빼고 새 것을 덧붙이면** 그것이 숨는다 (Codex)
            raise RelationSyncRefused(
                f"`part_of` 관계에 참가자가 모자란다: {sorted(broken)} — "
                "덧붙여 숨기지 않는다. 사람이 보고 정해야 한다")
        return got

    def _load_existing_visual_variants(self) -> Dict[Tuple[str, str], Tuple[str, str]]:
        """현재 DB의 visual_variant 관계를 (base_canon_id, variant_canon_id) → (rel_id, reason) 맵으로.

        RelationParticipant를 RelationFact와 JOIN하여 base/variant role을 구분.
        RelationFact가 비정상적으로 participant를 2개 미만 가진 경우 skip.
        """
        rows = self.db.execute(
            sql_text(
                "SELECT rf.id AS rel_id, "
                "       rf.continuity_reason AS reason, "
                "       MAX(CASE WHEN rp.participant_role = 'base' THEN rp.canon_id END) AS base_cid, "
                "       MAX(CASE WHEN rp.participant_role = 'variant' THEN rp.canon_id END) AS variant_cid "
                "FROM relation_fact rf "
                "LEFT JOIN relation_participant rp ON rp.relation_id = rf.id "
                "WHERE rf.project_id = :pid "
                "  AND rf.relation_type = 'visual_variant' "
                "GROUP BY rf.id, rf.continuity_reason"
            ),
            {"pid": self.project_id},
        ).fetchall()

        result: Dict[Tuple[str, str], Tuple[str, str]] = {}
        for row in rows:
            rel_id, reason, base_cid, variant_cid = row[0], row[1], row[2], row[3]
            if not base_cid or not variant_cid:
                # 비정상 (participant 누락) — 정리 대상이지만 delta에서 skip
                self.logger.warning(
                    "visual_variant relation %s has missing base/variant, skipping delta",
                    rel_id,
                )
                continue
            result[(base_cid, variant_cid)] = (rel_id, reason or "")
        return result

    @staticmethod
    def _empty_delta(*, skipped: int = 0) -> Dict[str, int]:
        return {
            "relations": 0,
            "inserted": 0,
            "updated": 0,
            "deleted": 0,
            "skipped": skipped,
        }
