"""Source grounding script unit tests — Codex APPROVED Q1-Q5.

dry-run no API, no DB write. 사용자 요구사항 #7 그대로:
- source evidence extractor unit
- Gemini response schema validation helper
- HTML contains expected sections
- API key/secret 로그 금지
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

_REPO_ROOT = Path(__file__).resolve().parents[3]
_SCRIPTS = _REPO_ROOT / "backend" / "scripts"
if str(_SCRIPTS) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS))

import experiment_rooftop_source_grounding as exp  # noqa: E402


def _bundle(episode: str = "", planning: str = "") -> exp.SourceBundle:
    return exp.SourceBundle(
        episode_fulltext=episode,
        planning_doc_text=planning,
        episode_meta={"fulltext_length": len(episode)},
        project_meta={"planning_doc_length": len(planning)},
    )


# ---------------------------------------------------------------------------
# Deterministic evidence extractor
# ---------------------------------------------------------------------------
def test_evidence_hits_sub_space_keywords() -> None:
    b = _bundle(episode="햇살이 비치는 옥탑방 거실에서 침대 위 어두운 벽면에 붉은 원이 보였다.")
    rows = exp.extract_evidence(b)
    keywords = {r.keyword for r in rows}
    assert "옥탑방" in keywords
    assert "거실" in keywords
    assert "침대" in keywords
    assert "벽면" in keywords


def test_evidence_field_hint_resolved() -> None:
    b = _bundle(episode="식탁 위에 사진이 놓여 있다.")
    rows = exp.extract_evidence(b)
    table_rows = [r for r in rows if r.keyword == "식탁"]
    assert table_rows
    assert table_rows[0].candidate_field == "furniture.dining_table"
    assert table_rows[0].source == "episode"


def test_evidence_multiple_sources_separate_rows() -> None:
    b = _bundle(
        episode="옥탑방 안에서 사건이 일어난다.",
        planning="이야기는 옥탑방을 배경으로 한다.",
    )
    rows = exp.extract_evidence(b)
    sources = {r.source for r in rows if r.keyword == "옥탑방"}
    assert sources == {"episode", "planning_doc"}


def test_evidence_quote_window_contains_keyword() -> None:
    b = _bundle(episode="앞 문장. 햇살이 비치는 옥탑방 거실. 다음 문장.")
    rows = exp.extract_evidence(b)
    for r in rows:
        if r.keyword == "옥탑방":
            assert "옥탑방" in r.quote


# ---------------------------------------------------------------------------
# Schema validation helper — strict JSON 구조 sanity
# ---------------------------------------------------------------------------
def test_rooftop_bible_schema_required_fields() -> None:
    schema = exp.ROOFTOP_BIBLE_SCHEMA
    assert schema["type"] == "object"
    required = set(schema["required"])
    expected = {
        "sub_spaces", "layout_relations", "doors_windows", "kitchen", "bathroom",
        "furniture", "materials", "condition_age", "socioeconomic_tone",
        "scale_density", "state_variants", "required_visual_cues",
        "forbidden_luxury_cues", "unknowns", "inference_limits",
    }
    assert expected.issubset(required), f"missing fields: {expected - required}"


def test_gemini_instruction_no_truncation_directive() -> None:
    # Gemini 입력 자르지 마라 (CLAUDE.md 절대 규칙).
    instr = exp.GEMINI_INSTRUCTION_TEMPLATE
    # 새 marker: <<EPISODE_BODY>> / <<PLANNING_BODY>> (Codex Blocking — str.format placeholder 회피)
    assert "<<EPISODE_BODY>>" in instr
    assert "<<PLANNING_BODY>>" in instr
    # 자르기/truncate/[:400] 등 substring 없음.
    for token in (":400]", ":500]", "truncate", "잘라"):
        assert token not in instr, f"GEMINI_INSTRUCTION must not contain {token!r}"


def test_build_gemini_prompt_no_format_keyerror() -> None:
    """Codex Blocking: {trusted, weak, unknown} literal brace 가 .format() 와 충돌 →
    build_gemini_prompt 는 replace 방식이라 안전."""
    prompt = exp.build_gemini_prompt(
        episode_text="옥탑방 거실 본문",
        planning_text="기획안 본문",
    )
    assert "옥탑방 거실 본문" in prompt
    assert "기획안 본문" in prompt
    # literal brace 가 그대로 유지됨 (str.format 안 거침).
    assert "{trusted, weak, unknown}" in prompt
    # marker 토큰 (replace 처리됐어야 — 잔존하면 fail).
    assert "<<EPISODE_BODY>>" not in prompt
    assert "<<PLANNING_BODY>>" not in prompt


def test_build_gemini_prompt_empty_inputs() -> None:
    prompt = exp.build_gemini_prompt(episode_text="", planning_text="")
    # marker 는 사라지고, instruction 본문은 남음
    assert "<<EPISODE_BODY>>" not in prompt
    assert "<<PLANNING_BODY>>" not in prompt
    assert "EPISODE 1 FULLTEXT" in prompt


def test_gemini_instruction_evidence_only_directive() -> None:
    instr = exp.GEMINI_INSTRUCTION
    # Codex Q3: Gemini 가 판정자 아님.
    assert "evidence" in instr.lower() or "evidence" in instr
    assert "unknowns" in instr
    # metric 금지 명시 (Codex 권고).
    assert "metric" in instr or "qualitative" in instr


# ---------------------------------------------------------------------------
# Luxury cue list + severity enum (Codex Q3/Q4)
# ---------------------------------------------------------------------------
def test_luxury_cue_list_has_candidates() -> None:
    for kw in ("marble", "chandelier", "walk-in closet", "luxury bathroom"):
        assert kw in exp.LUXURY_CUE_CANDIDATES


def test_severity_enum_three_levels() -> None:
    assert exp.SEVERITY_CANDIDATES == ["info", "suspicious", "likely_mismatch"]


# ---------------------------------------------------------------------------
# Comparison — plate grouping (Codex Q5)
# ---------------------------------------------------------------------------
def test_comparison_returns_empty_when_base_run_missing() -> None:
    b = _bundle(episode="옥탑방 거실")
    evidence = exp.extract_evidence(b)
    rows = exp.build_comparison_report(
        base_run_dir=Path("/tmp/nonexistent_base_run"),
        bundle=b, evidence_rows=evidence, gemini_bible=None,
    )
    assert rows == []


# ---------------------------------------------------------------------------
# HTML sections — 사용자 요구사항 #7
# ---------------------------------------------------------------------------
def test_render_html_contains_expected_sections(tmp_path) -> None:
    b = _bundle(episode="옥탑방 거실에 식탁이 있다.")
    evidence = exp.extract_evidence(b)
    run_meta = {
        "run_id": "test_run", "created_at": "2026-05-23T20:00:00",
        "plan_version": "v1", "project_id": exp.PROJECT_ID,
        "episode_id": exp.EPISODE_ID, "evidence_count": len(evidence),
        "comparison_count": 0, "gemini_status": "skipped",
        "args": {}, "serve_info": None,
    }
    exp.render_html(
        run_dir=tmp_path, bundle=b, evidence_rows=evidence,
        base_run_dir=None, comparison_rows=[], gemini_bible=None,
        run_meta=run_meta,
    )
    html = (tmp_path / "index.html").read_text(encoding="utf-8")
    for header in (
        "1. Source bundle", "2. Deterministic source evidence",
        "3. Gemini rooftop bible", "4. Current base plate",
        "5. Comparison candidates", "6. Run meta",
    ):
        assert header in html, f"HTML missing section header {header!r}"


def test_render_html_quote_limit_applied(tmp_path) -> None:
    # 매우 긴 quote 가 HTML 에 truncate 되는지 (Codex 추가 권고).
    long_quote = "옥탑방 " + ("거실 " * 200)
    b = _bundle(episode=long_quote)
    evidence = exp.extract_evidence(b)
    run_meta = {"run_id": "qtest", "created_at": "x", "plan_version": "v1",
                "project_id": exp.PROJECT_ID, "episode_id": exp.EPISODE_ID,
                "evidence_count": len(evidence), "comparison_count": 0,
                "gemini_status": "skipped", "args": {}, "serve_info": None}
    exp.render_html(run_dir=tmp_path, bundle=b, evidence_rows=evidence,
                    base_run_dir=None, comparison_rows=[], gemini_bible=None,
                    run_meta=run_meta)
    html = (tmp_path / "index.html").read_text(encoding="utf-8")
    # 정확한 quote 가 잘렸으면 "..." 포함.
    assert "..." in html


# ---------------------------------------------------------------------------
# API key/secret 로그 금지 (사용자 요구사항 #7)
# ---------------------------------------------------------------------------
def test_jsonl_append_does_not_carry_api_key() -> None:
    payload = {
        "stage": "gemini_bible", "model": "gemini-3.5-flash",
        "status": "ok", "latency_ms": 1234,
    }
    # 우리 _append_jsonl_if 함수가 payload 키 외의 secret 을 주입하지 않음을 확인.
    # (단순히 dumps 만 함 — secret 키 추가 없음을 명시 검증)
    s = json.dumps(payload)
    for forbidden in ("api_key", "Authorization", "Bearer", "GEMINI_API_KEY"):
        assert forbidden not in s


def test_call_gemini_returns_failed_without_api_key(monkeypatch) -> None:
    # settings.gemini_api_key (단수) + env GEMINI_API_KEY 둘 다 비움 → failed.
    from app.core.config import settings  # noqa: PLC0415

    monkeypatch.delenv("GEMINI_API_KEY", raising=False)
    monkeypatch.setattr(settings, "gemini_api_key", "")
    result = exp.call_gemini_bible(
        episode_text="test", planning_text="test",
        gemini_model="gemini-3.5-flash", max_attempts=1,
    )
    assert result["status"] == "failed"
    assert "GEMINI_API_KEY" in result.get("error", "")


# ---------------------------------------------------------------------------
# Source bundle structure
# ---------------------------------------------------------------------------
def test_source_bundle_dataclass_defaults() -> None:
    b = exp.SourceBundle(episode_fulltext="", planning_doc_text="")
    assert b.missing_inputs == []
    assert b.episode_meta == {}
    assert b.project_meta == {}


# ---------------------------------------------------------------------------
# Codex Blocking 1 — base plate assets copy
# ---------------------------------------------------------------------------
def test_copy_base_plate_assets_copies_png_and_json(tmp_path) -> None:
    # 가짜 base_run_dir 만들기
    base_run = tmp_path / "fake_base_run"
    (base_run / "base_plates").mkdir(parents=True)
    (base_run / "realized_spatial_cards").mkdir(parents=True)
    (base_run / "base_plates" / "main_room__eye_level_wide.png").write_bytes(b"PNGDATA")
    (base_run / "realized_spatial_cards" / "base__main_room__eye_level_wide.json").write_text(
        '{"plate_id": "base__main_room__eye_level_wide"}', encoding="utf-8"
    )
    dest = exp.copy_base_plate_assets(tmp_path, base_run)
    assert dest is not None
    assert (dest / "main_room__eye_level_wide.png").exists()
    assert (dest / "base__main_room__eye_level_wide.json").exists()
    # 상대경로 확인
    rel = dest.relative_to(tmp_path)
    assert str(rel).startswith("base_plate_assets/fake_base_run")


def test_copy_base_plate_assets_none_for_missing_base() -> None:
    out = exp.copy_base_plate_assets(Path("/tmp/x"), None)
    assert out is None


def test_html_img_src_uses_local_assets_path(tmp_path) -> None:
    # 가짜 base_run + asset copy 후 render_html → img src 가 base_plate_assets/... 로
    base_run = tmp_path / "fake_base_run"
    (base_run / "base_plates").mkdir(parents=True)
    (base_run / "base_plates" / "main_room__eye_level_wide.png").write_bytes(b"PNG")
    run_dir = tmp_path / "run"
    run_dir.mkdir()
    exp.copy_base_plate_assets(run_dir, base_run)
    b = _bundle(episode="x")
    run_meta = {
        "run_id": "asset_test", "created_at": "x", "plan_version": "v1",
        "project_id": exp.PROJECT_ID, "episode_id": exp.EPISODE_ID,
        "evidence_count": 0, "comparison_count": 0, "gemini_status": "skipped",
        "args": {}, "serve_info": None,
        "base_plate_assets_dir": f"base_plate_assets/{base_run.name}",
    }
    exp.render_html(run_dir=run_dir, bundle=b, evidence_rows=[],
                    base_run_dir=base_run, comparison_rows=[], gemini_bible=None,
                    run_meta=run_meta)
    html = (run_dir / "index.html").read_text(encoding="utf-8")
    assert 'src="base_plate_assets/' in html
    # parent path (..) 사용 금지
    assert 'src="../' not in html


# ---------------------------------------------------------------------------
# Codex Blocking 2 — local schema validation
# ---------------------------------------------------------------------------
def test_gemini_call_uses_jsonschema_validate(monkeypatch) -> None:
    """call_gemini_bible 에 jsonschema.validate import 가 들어가 있고
    schema 위반 응답을 RuntimeError 로 잡는지 — soft test (간접 검증)."""
    import experiment_rooftop_source_grounding as src_mod  # noqa: PLC0415

    src = Path(src_mod.__file__).read_text(encoding="utf-8")
    assert "jsonschema.validate" in src, "Gemini wrapper must call jsonschema.validate"
    assert "ROOFTOP_BIBLE_SCHEMA" in src
