"""orchestrate_full_sync sync_status 기록 테스트 — W4 P3-2.

단위 테스트: 5개 Service는 monkeypatch로 통과/실패 시뮬레이션.
step_run raw SQL 테이블은 in-memory SQLite.

실패 경로는 별도 세션(_record_sync_failure)을 사용하므로 SessionLocal도 patch.
"""
from __future__ import annotations

import uuid
from unittest.mock import MagicMock

import pytest
from sqlalchemy import create_engine, text as sql_text
from sqlalchemy.orm import Session, sessionmaker


_STEP_RUN_SCHEMA = """
CREATE TABLE step_run (
    id TEXT PRIMARY KEY,
    project_id TEXT NOT NULL,
    episode_id TEXT NOT NULL,
    step_id TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    sync_status TEXT,
    sync_error TEXT,
    synced_at TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    UNIQUE(project_id, episode_id, step_id)
)
"""


@pytest.fixture
def db_with_step_row():
    """step_run 1 row 포함 in-memory DB + Session."""
    engine = create_engine("sqlite:///:memory:")
    with engine.begin() as conn:
        conn.execute(sql_text(_STEP_RUN_SCHEMA))
        conn.execute(sql_text(
            "INSERT INTO step_run (id, project_id, episode_id, step_id, status, created_at, updated_at) "
            "VALUES (:id, 'p1', 'e1', 'text_cleanup', 'completed', '2026-01-01', '2026-01-01')"
        ), {"id": str(uuid.uuid4())})
    with Session(engine) as db:
        yield engine, db


def _mock_all_services(monkeypatch, *, fail_on: str = None):
    """5개 SyncService를 모두 mock. fail_on이 설정되면 해당 Service가 RuntimeError 발생."""
    service_names = [
        "entity_sync_service.EntitySyncService",
        "relation_sync_service.RelationSyncService",
        "scene_still_sync_service.SceneStillSyncService",
        "outlook_sync_service.OutlookSyncService",
        "episode_projection_service.EpisodeProjectionService",
    ]
    for path in service_names:
        mod_name, cls_name = path.rsplit(".", 1)
        mock_cls = MagicMock()
        instance = MagicMock()
        if fail_on and cls_name == fail_on:
            instance.sync_from_checkpoint.side_effect = RuntimeError(f"{cls_name} boom")
        else:
            instance.sync_from_checkpoint.return_value = {"ok": 1}
        mock_cls.return_value = instance
        monkeypatch.setattr(
            f"app.services.checkpoint_sync.{mod_name}.{cls_name}",
            mock_cls,
        )


# ── 성공 경로 ──


def test_success_records_synced_when_step_id_provided(db_with_step_row, monkeypatch):
    """모든 Service 성공 + step_id 제공 → sync_status='synced' 기록."""
    from app.services.checkpoint_sync.orchestrator import orchestrate_full_sync

    _, db = db_with_step_row
    _mock_all_services(monkeypatch)

    orchestrate_full_sync("p1", "e1", db, step_id="text_cleanup")

    row = db.execute(sql_text(
        "SELECT sync_status, sync_error, synced_at FROM step_run WHERE step_id='text_cleanup'"
    )).fetchone()
    assert row[0] == "synced"
    assert row[1] is None
    assert row[2] is not None  # ISO timestamp


def test_success_without_step_id_no_update(db_with_step_row, monkeypatch):
    """step_id 미제공 → sync_status UPDATE 발생 안 함 (backward compat)."""
    from app.services.checkpoint_sync.orchestrator import orchestrate_full_sync

    _, db = db_with_step_row
    _mock_all_services(monkeypatch)

    orchestrate_full_sync("p1", "e1", db)

    row = db.execute(sql_text(
        "SELECT sync_status FROM step_run WHERE step_id='text_cleanup'"
    )).fetchone()
    assert row[0] is None


# ── 실패 경로 ──


def test_failure_records_failed_in_separate_session(db_with_step_row, monkeypatch):
    """Service 중 하나가 throw → sync_status='failed' + sync_error 기록.

    실패 기록은 별도 세션(_record_sync_failure)에서 commit되므로
    주 세션을 rollback해도 보존된다.
    """
    from app.services.checkpoint_sync.orchestrator import orchestrate_full_sync

    engine, db = db_with_step_row
    _mock_all_services(monkeypatch, fail_on="SceneStillSyncService")

    # _record_sync_failure 내부에서 사용할 SessionLocal을 테스트 엔진으로 교체
    test_factory = sessionmaker(bind=engine)
    monkeypatch.setattr("app.core.database.SessionLocal", test_factory)

    with pytest.raises(RuntimeError, match="SceneStillSyncService boom"):
        orchestrate_full_sync("p1", "e1", db, step_id="text_cleanup")

    # 주 세션은 failed 반영 안 됐어도 — 별도 세션이 commit했으므로 DB에 남음
    db.rollback()  # 호출자 rollback 시뮬레이션
    row = db.execute(sql_text(
        "SELECT sync_status, sync_error FROM step_run WHERE step_id='text_cleanup'"
    )).fetchone()
    assert row[0] == "failed"
    assert row[1] is not None
    assert "SceneStillSyncService boom" in row[1]


def test_failure_preserves_previous_synced_at(db_with_step_row, monkeypatch):
    """Codex P3-2 Medium: 실패 시 synced_at을 덮어쓰지 않음 (직전 성공 시각 보존)."""
    from app.services.checkpoint_sync.orchestrator import orchestrate_full_sync

    engine, db = db_with_step_row
    # 기존 row에 성공 synced_at 심기
    db.execute(sql_text(
        "UPDATE step_run SET sync_status='synced', synced_at='2026-04-21T10:00:00+00:00' "
        "WHERE step_id='text_cleanup'"
    ))
    db.commit()

    _mock_all_services(monkeypatch, fail_on="RelationSyncService")
    test_factory = sessionmaker(bind=engine)
    monkeypatch.setattr("app.core.database.SessionLocal", test_factory)

    with pytest.raises(RuntimeError):
        orchestrate_full_sync("p1", "e1", db, step_id="text_cleanup")

    db.rollback()
    row = db.execute(sql_text(
        "SELECT sync_status, synced_at FROM step_run WHERE step_id='text_cleanup'"
    )).fetchone()
    assert row[0] == "failed"
    # synced_at은 과거 성공 시각 유지
    assert row[1] == "2026-04-21T10:00:00+00:00"


def test_failure_without_step_id_no_record(db_with_step_row, monkeypatch):
    """step_id 미제공 시 실패해도 기록하지 않음 (snapshot restore 등 기존 경로 호환)."""
    from app.services.checkpoint_sync.orchestrator import orchestrate_full_sync

    engine, db = db_with_step_row
    _mock_all_services(monkeypatch, fail_on="EntitySyncService")

    with pytest.raises(RuntimeError):
        orchestrate_full_sync("p1", "e1", db)

    db.rollback()
    row = db.execute(sql_text(
        "SELECT sync_status FROM step_run WHERE step_id='text_cleanup'"
    )).fetchone()
    assert row[0] is None


def test_failure_recorder_swallows_own_errors(db_with_step_row, monkeypatch, caplog):
    """_record_sync_failure 내부 에러는 로그만 남기고 주 예외를 덮지 않음."""
    from app.services.checkpoint_sync.orchestrator import orchestrate_full_sync

    engine, db = db_with_step_row
    _mock_all_services(monkeypatch, fail_on="OutlookSyncService")

    # SessionLocal을 깨뜨려 recorder 실패 유도
    def broken_factory():
        raise RuntimeError("DB down")

    monkeypatch.setattr("app.core.database.SessionLocal", broken_factory)

    # 주 예외는 원래의 OutlookSyncService boom이 propagate — recorder 예외가 덮지 않음
    with pytest.raises(RuntimeError, match="OutlookSyncService boom"):
        orchestrate_full_sync("p1", "e1", db, step_id="text_cleanup")


# ── sync_error 길이 제한 ──


def test_repair_transitions_failed_to_synced(db_with_step_row, monkeypatch):
    """Codex P3-3: 같은 트랜잭션에서 failed→synced 전환 (split commit 회피)."""
    from app.services.checkpoint_sync.orchestrator import orchestrate_full_sync

    engine, db = db_with_step_row
    # step_run을 failed로 세팅
    db.execute(sql_text(
        "UPDATE step_run SET sync_status='failed', sync_error='orig err' "
        "WHERE step_id='text_cleanup'"
    ))
    db.commit()

    _mock_all_services(monkeypatch)

    orchestrate_full_sync("p1", "e1", db, repair_step_ids={"text_cleanup": "orig err"})

    row = db.execute(sql_text(
        "SELECT sync_status, sync_error, synced_at FROM step_run WHERE step_id='text_cleanup'"
    )).fetchone()
    assert row[0] == "synced"
    assert row[1] is None
    assert row[2] is not None


def test_repair_preserves_concurrent_new_failure(db_with_step_row, monkeypatch):
    """Codex P3-3 High: sync_error guard — concurrent 프로세스의 새 실패는 덮지 않음.

    세션 시나리오:
    - T0: step 'text_cleanup'의 sync_error='orig err'를 repair 시도
    - T1: orchestrate_full_sync 실행 직전 concurrent 프로세스가 sync_error='new err'로 갱신
    - T2: guarded UPDATE — sync_error가 'orig err'가 아니므로 전환 안 함 (새 실패 보존)
    """
    from app.services.checkpoint_sync.orchestrator import orchestrate_full_sync

    engine, db = db_with_step_row
    # 시나리오: concurrent 프로세스가 이미 sync_error를 'new err'로 갱신한 상태
    db.execute(sql_text(
        "UPDATE step_run SET sync_status='failed', sync_error='new err' "
        "WHERE step_id='text_cleanup'"
    ))
    db.commit()

    _mock_all_services(monkeypatch)

    # 우리가 캡처했던 prev_err='orig err'로 repair 시도 — sync_error 불일치
    orchestrate_full_sync("p1", "e1", db, repair_step_ids={"text_cleanup": "orig err"})

    row = db.execute(sql_text(
        "SELECT sync_status, sync_error FROM step_run WHERE step_id='text_cleanup'"
    )).fetchone()
    # 새 실패는 유지 (synced로 덮어쓰지 않음)
    assert row[0] == "failed"
    assert row[1] == "new err"


def test_repair_handles_null_sync_error(db_with_step_row, monkeypatch):
    """sync_error가 NULL인 failed row도 정상 전환 (NULL=NULL 비교)."""
    from app.services.checkpoint_sync.orchestrator import orchestrate_full_sync

    engine, db = db_with_step_row
    db.execute(sql_text(
        "UPDATE step_run SET sync_status='failed', sync_error=NULL "
        "WHERE step_id='text_cleanup'"
    ))
    db.commit()

    _mock_all_services(monkeypatch)

    orchestrate_full_sync("p1", "e1", db, repair_step_ids={"text_cleanup": None})

    row = db.execute(sql_text(
        "SELECT sync_status FROM step_run WHERE step_id='text_cleanup'"
    )).fetchone()
    assert row[0] == "synced"


def test_failure_error_message_truncated_to_1000(db_with_step_row, monkeypatch):
    """매우 긴 예외 메시지는 1000자로 자름 (sync_error 컬럼 공간 보호)."""
    from app.services.checkpoint_sync.orchestrator import orchestrate_full_sync

    engine, db = db_with_step_row
    long_msg = "x" * 2000

    # Mock EntitySyncService에 긴 에러 메시지
    mock_cls = MagicMock()
    instance = MagicMock()
    instance.sync_from_checkpoint.side_effect = RuntimeError(long_msg)
    mock_cls.return_value = instance
    monkeypatch.setattr(
        "app.services.checkpoint_sync.entity_sync_service.EntitySyncService",
        mock_cls,
    )
    # 나머지 Service는 모킹 안 — Entity에서 바로 throw

    test_factory = sessionmaker(bind=engine)
    monkeypatch.setattr("app.core.database.SessionLocal", test_factory)

    with pytest.raises(RuntimeError):
        orchestrate_full_sync("p1", "e1", db, step_id="text_cleanup")

    db.rollback()
    err = db.execute(sql_text(
        "SELECT sync_error FROM step_run WHERE step_id='text_cleanup'"
    )).scalar()
    assert len(err) == 1000
