"""3화를 sync 하면 1화의 아웃룩이 사라지던 것 (2026-09-04 실측).

## 무엇이 결함이었나 — 실측 프로젝트 `da049582` (골목 끝, 3화)

    1화 CP  O01 남색작업복 · O02 노란우비 · O03 회색외투   (3개)
    3화 CP  O01 감색차장제복 · O02 작업점퍼                 (2개)
    지금 DB O01 감색차장제복 · O02 작업점퍼                 ← 3화 것과 정확히 일치

`OutlookSyncService` 의 stale cleanup 이 `_existing_ols`(**프로젝트 전체**
아웃룩)를 훑어 이번 화 체크포인트에 없는 것을 `entity_canon` 째 DELETE 했다.
그래서 1화의 `O03` 이 없어졌고, `O01` 은 1화가 만든 참조 이미지를 붙든 채
이름만 3화 것으로 바뀌었다 — **그림과 이름이 갈렸다.**

`character_outlook` 은 화 칸이 아예 없어서, 같은 인물이 다음 화에 다른 옷을
입으면 delta sync 가 **앞 화 배정**을 지웠다.

## 왜 fake DB 로 안 재는가

이 결함은 「어느 범위를 훑어 무엇을 지우는가」다. fake DB 는 내가 짠 범위만
흉내 내므로 같이 틀린다. 실물 PG 에 1화 자산을 심어 두고 **2화를 sync 한 뒤
1화 것이 남아 있는지**를 본다.

Lane: ``-m pg``.
"""
from __future__ import annotations

import json
import uuid
from pathlib import Path

import pytest
from sqlalchemy import text as sql_text

pytestmark = pytest.mark.pg


def _seed(session, pid: str, ep1: str, ep2: str) -> None:
    uid = f"multi-ep-{uuid.uuid4()}"
    session.execute(sql_text(
        "INSERT INTO user_account (id, username, display_name, password_hash, "
        "role, is_active, created_at, updated_at) VALUES "
        "(:uid, :uname, 't', 'x', 'creator', 1, '2026-01-01', '2026-01-01')"
    ), {"uid": uid, "uname": f"u_{uid}"})
    session.execute(sql_text(
        "INSERT INTO project_registry (id, name, created_by, created_at, updated_at) "
        "VALUES (:pid, 'multi-ep', :uid, '2026-01-01', '2026-01-01')"
    ), {"pid": pid, "uid": uid})
    for n, eid in enumerate((ep1, ep2), start=1):
        session.execute(sql_text(
            "INSERT INTO episode (id, project_id, title, episode_number, "
            "source_filename, source_path, created_at, updated_at) VALUES "
            "(:eid, :pid, :t, :n, 'x.txt', 'x/x.txt', '2026-01-01', '2026-01-01')"
        ), {"eid": eid, "pid": pid, "t": f"{n}화", "n": n})
    session.commit()


def _canon(session, pid: str, cid: str, short_id: str, name: str, etype: str) -> None:
    session.execute(sql_text(
        "INSERT INTO entity_canon (id, project_id, short_id, name, entity_type, "
        "description, stable_traits, metadata_json, t2i_prompt, status, "
        "created_at, updated_at) VALUES "
        "(:cid, :pid, :s, :n, :e, '', '[]', '{}', '', 'active', "
        "'2026-01-01', '2026-01-01')"
    ), {"cid": cid, "pid": pid, "s": short_id, "n": name, "e": etype})


def _link(session, pid: str, eid: str, cid: str) -> None:
    session.execute(sql_text(
        "INSERT INTO entity_episode_link (id, canon_id, project_id, episode_id) "
        "VALUES (:lid, :cid, :pid, :eid)"
    ), {"lid": str(uuid.uuid4()), "cid": cid, "pid": pid, "eid": eid})


def _pair(session, pid: str, char_id: str, ol_id: str, eid=None) -> str:
    row_id = str(uuid.uuid4())
    session.execute(sql_text(
        "INSERT INTO character_outlook (id, project_id, episode_id, character_id, "
        "outlook_id, created_at) VALUES (:id, :pid, :eid, :c, :o, '2026-01-01')"
    ), {"id": row_id, "pid": pid, "eid": eid, "c": char_id, "o": ol_id})
    return row_id


def _write_cp(root: Path, pid: str, eid: str, step_id: str, payload: dict) -> None:
    d = root / pid / "checkpoints" / "episodes" / eid / step_id
    d.mkdir(parents=True, exist_ok=True)
    (d / "manifest.json").write_text(json.dumps(payload, ensure_ascii=False),
                                     encoding="utf-8")


def _outlook_cp(entries) -> dict:
    """실측 `outlook_phase3` 체크포인트의 키를 그대로 쓴다."""
    return {
        "status": "completed",
        "data": {
            "outlooks": [
                {"name": name, "description": "", "character_id": csid,
                 "is_shared": False, "short_id": osid}
                for csid, osid, name in entries
            ],
            "scene_assignments": [{
                "scene_index": 1,
                "assignments": [
                    {"character_id": csid, "outlook_name": name, "outlook_id": osid}
                    for csid, osid, name in entries
                ],
            }],
            "removed": [], "null_outlook_chars": [],
        },
    }


@pytest.fixture
def scene(pg_session, tmp_path: Path, monkeypatch):
    """1화가 아웃룩 셋을 만들어 뒀고, 2화 CP 에는 둘만 있다."""
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))

    pid = f"p-{uuid.uuid4()}"
    ep1, ep2 = str(uuid.uuid4()), str(uuid.uuid4())
    _seed(pg_session, pid, ep1, ep2)

    ids = {k: str(uuid.uuid4()) for k in
           ("C01", "O01", "O02", "O03")}
    _canon(pg_session, pid, ids["C01"], "C01", "민수", "character")
    _canon(pg_session, pid, ids["O01"], "O01", "남색작업복", "outlook")
    _canon(pg_session, pid, ids["O02"], "O02", "노란우비", "outlook")
    _canon(pg_session, pid, ids["O03"], "O03", "회색외투", "outlook")
    for k in ("C01", "O01", "O02", "O03"):
        _link(pg_session, pid, ep1, ids[k])
    # 1화의 옷 배정 — 화 칸을 적어 둔다.
    # ★2화가 **원하지 않는** 옷이어야 한다. 2화도 원하는 쌍을 쓰면 옛 코드에서도
    #  안 지워져서 시험이 결함을 못 잡는다(실측: 그 조합으로 짰다가 옛 코드에서
    #  통과했다).
    _pair(pg_session, pid, ids["C01"], ids["O03"], eid=ep1)
    # 2화에는 같은 인물만 나온다.
    _link(pg_session, pid, ep2, ids["C01"])
    pg_session.commit()

    # 2화 CP: O01·O02 만 있고 O03 은 없다. 이름도 2화 것으로 다르다.
    _write_cp(tmp_path, pid, ep2, "outlook_phase3", _outlook_cp([
        ("C01", "O01", "감색차장제복"),
        ("C01", "O02", "작업점퍼"),
    ]))
    return pid, ep1, ep2, ids


def _sync(session, pid: str, eid: str):
    from app.services.checkpoint_sync import OutlookSyncService

    OutlookSyncService(session, pid, eid).sync_from_checkpoint()
    session.commit()


def test_2화_sync_가_1화_아웃룩_canon_을_안_지운다(pg_session, scene):
    """★핵심 — 종전에는 O03 이 통째로 DELETE 됐다."""
    pid, _ep1, ep2, ids = scene
    _sync(pg_session, pid, ep2)

    rows = pg_session.execute(sql_text(
        "SELECT short_id, name FROM entity_canon "
        "WHERE project_id = :pid AND entity_type = 'outlook' ORDER BY short_id"
    ), {"pid": pid}).fetchall()
    got = {r[0] for r in rows}
    assert "O03" in got, (
        f"1화의 O03 회색외투가 2화 sync 로 사라졌다 — 남은 것: {sorted(got)}")


def test_1화_링크는_그대로_있고_2화_링크만_이번_CP_를_따른다(pg_session, scene):
    pid, ep1, ep2, ids = scene
    _sync(pg_session, pid, ep2)

    def linked(eid):
        return {r[0] for r in pg_session.execute(sql_text(
            "SELECT ec.short_id FROM entity_episode_link el "
            "JOIN entity_canon ec ON ec.id = el.canon_id "
            "WHERE el.episode_id = :eid AND ec.entity_type = 'outlook'"
        ), {"eid": eid}).fetchall()}

    assert linked(ep1) == {"O01", "O02", "O03"}, "1화 링크가 건드려졌다"
    assert linked(ep2) == {"O01", "O02"}, "2화 링크가 이번 CP 와 다르다"


def test_1화_옷_배정이_2화_sync_로_안_지워진다(pg_session, scene):
    """`character_outlook` 에 화 칸이 없어 앞 화 배정이 지워지던 자리."""
    pid, ep1, ep2, ids = scene
    _sync(pg_session, pid, ep2)

    n = pg_session.execute(sql_text(
        "SELECT count(*) FROM character_outlook "
        "WHERE project_id = :pid AND episode_id = :eid AND character_id = :c"
    ), {"pid": pid, "eid": ep1, "c": ids["C01"]}).fetchone()[0]
    assert n == 1, "1화의 옷 배정(C01→O01)이 2화 sync 로 사라졌다"


def test_아무_데도_안_붙은_아웃룩은_지우지_않고_표시만_한다(pg_session, scene, tmp_path):
    """`_mark_orphan_outlooks` — 종전 이름은 `_remove_...` 였고 실제로 지웠다."""
    from app.core.entity_identity import CANON_STATUS_ORPHANED

    pid, ep1, ep2, ids = scene
    # 어느 화에도 안 붙고 관계·이미지도 없는 아웃룩 하나.
    lone = str(uuid.uuid4())
    _canon(pg_session, pid, lone, "O09", "버려진옷", "outlook")
    pg_session.commit()

    _sync(pg_session, pid, ep2)

    row = pg_session.execute(sql_text(
        "SELECT status FROM entity_canon WHERE id = :id"
    ), {"id": lone}).fetchone()
    assert row is not None, "아무 데도 안 붙은 아웃룩이 삭제됐다 — 표시만 해야 한다"
    assert row[0] == CANON_STATUS_ORPHANED, f"표시가 안 됐다: {row[0]}"


def test_O00_은_고아로_표시되지_않는다(pg_session, scene):
    """Null Outlook 은 예약값이라 관계가 없는 것이 정상이다."""
    from app.core.entity_identity import (CANON_STATUS_ORPHANED,
                                          NULL_OUTLOOK_SHORT_ID)

    pid, _ep1, ep2, _ids = scene
    null_ol = str(uuid.uuid4())
    _canon(pg_session, pid, null_ol, NULL_OUTLOOK_SHORT_ID, "Null Outlook", "outlook")
    pg_session.commit()

    _sync(pg_session, pid, ep2)

    row = pg_session.execute(sql_text(
        "SELECT status FROM entity_canon WHERE id = :id"
    ), {"id": null_ol}).fetchone()
    assert row is not None and row[0] != CANON_STATUS_ORPHANED, (
        "O00 이 고아로 표시됐다 — 예약값은 관계가 없는 것이 정상이다")


def test_화_칸_없는_legacy_배정은_지우지_않고_이어받는다(pg_session, scene):
    """옛 행을 지우면 이관이 손실이다. 이 화가 원하는 쌍이면 화만 적는다."""
    pid, _ep1, ep2, ids = scene
    legacy_id = _pair(pg_session, pid, ids["C01"], ids["O02"], eid=None)
    pg_session.commit()

    _sync(pg_session, pid, ep2)

    row = pg_session.execute(sql_text(
        "SELECT episode_id FROM character_outlook WHERE id = :id"
    ), {"id": legacy_id}).fetchone()
    assert row is not None, "legacy 배정 행이 삭제됐다"
    assert row[0] == ep2, f"legacy 행이 이 화로 안 이어받아졌다: {row[0]}"

    dupes = pg_session.execute(sql_text(
        "SELECT count(*) FROM character_outlook "
        "WHERE project_id = :pid AND character_id = :c AND outlook_id = :o"
    ), {"pid": pid, "c": ids["C01"], "o": ids["O02"]}).fetchone()[0]
    assert dupes == 1, f"같은 쌍이 {dupes}행으로 늘었다 — 이어받기가 아니라 추가했다"
