"""task-2503+1 회귀 테스트 — 회장 §4 8건 false-positive fix 회귀.

대상 픽스:
  §3.a dependency 문자열 정규화
  §3.b merged evidence 4 충족 경로
  §3.c merged task active/conflict 제외
  §3.d parallel_safe false declaration 보정
  §3.e read-only report task 예외
  §3.f BLOCK override 허용 룰 (ALLOW_WITH_CHAIR_OVERRIDE + audit 9 필드)

회장 §4 회귀 8건:
  1. dependency 'task-2502.merged' satisfied → ALLOW
  2. dependency 'task-2503.merged' satisfied → ALLOW
  3. merged task는 active/conflicting task에서 제외
  4. task-2503+1이 task-2503과 expected_files overlap 있어도 task-2503 merged → ALLOW
  5. read-only report task expected_files overlap 0이면 ALLOW
  6. expected_files 교집합 0이면 PARALLEL_SAFE_FALSE_DECLARATION 금지
  7. 동일 파일 overlap이 active unmerged task와 발생하면 BLOCK 유지 (회귀)
  8. BLOCK + override flag + chair approval → ALLOW_WITH_CHAIR_OVERRIDE + audit 9 필드
"""
import json
import sys
from pathlib import Path

WORKSPACE = Path(__file__).resolve().parent.parent.parent
if str(WORKSPACE) not in sys.path:
    sys.path.insert(0, str(WORKSPACE))

from utils.merge_topology_gate import (  # type: ignore[import]
    ALLOW,
    ALLOW_WITH_CHAIR_OVERRIDE,
    BLOCK,
    REASON_DUPLICATE_FILE,
    REASON_MISSING_DEPENDENCY,
    REASON_PARALLEL_SAFE_FALSE_DECLARATION,
    _compute_overlap,
    _filter_active_tasks,
    _is_pure_read_only,
    _parse_dependency_spec,
    _verify_merged_state,
    check_dependency_merged,
    classify,
    parse_topology_metadata,
    run_gate,
)


# ─── 공용 fixture: tmp workspace ───────────────────────────────────────────
def _build_tmp_workspace(tmp_path: Path) -> Path:
    """격리된 tmp workspace 생성 (events/reports/task-timers.json 포함)."""
    (tmp_path / "memory" / "events").mkdir(parents=True, exist_ok=True)
    (tmp_path / "memory" / "reports").mkdir(parents=True, exist_ok=True)
    (tmp_path / "memory" / "tasks").mkdir(parents=True, exist_ok=True)
    (tmp_path / "memory" / "orchestration-audit").mkdir(parents=True, exist_ok=True)
    (tmp_path / "memory" / "task-timers.json").write_text(
        json.dumps({"tasks": {}}), encoding="utf-8"
    )
    return tmp_path


def _patch_workspace(monkeypatch, tmp_path: Path) -> Path:
    ws = _build_tmp_workspace(tmp_path)
    monkeypatch.setattr("utils.merge_topology_gate.WORKSPACE", ws)
    monkeypatch.setattr(
        "utils.merge_topology_gate.AUDIT_LOG_PATH",
        ws / "memory" / "orchestration-audit" / "merge-topology-gate.jsonl",
    )
    # caching 격리
    monkeypatch.setattr("utils.merge_topology_gate._MERGED_VERIFY_CACHE", {})
    return ws


# ═══════════════════════════════════════════════════════════════════════════
# Helper unit tests (§3.a)
# ═══════════════════════════════════════════════════════════════════════════
def test_parse_dependency_spec_normalization_a():
    """§3.a: 'task-2503.merged' → task_id + required_state 분리."""
    assert _parse_dependency_spec("task-2503.merged") == {
        "task_id": "task-2503",
        "required_state": "merged",
    }
    assert _parse_dependency_spec("task-2487+1.merged") == {
        "task_id": "task-2487+1",
        "required_state": "merged",
    }
    # 후방 호환: 단순 task ID → required_state='merged' 추론
    assert _parse_dependency_spec("task-2487+1") == {
        "task_id": "task-2487+1",
        "required_state": "merged",
    }
    assert _parse_dependency_spec("none") == {
        "task_id": "none",
        "required_state": "none",
    }


# ═══════════════════════════════════════════════════════════════════════════
# §4 #1 dependency 'task-2502.merged' satisfied → ALLOW
# ═══════════════════════════════════════════════════════════════════════════
def test_dependency_task_2502_merged_satisfied_allows(tmp_path, monkeypatch):
    ws = _patch_workspace(monkeypatch, tmp_path)
    # evidence path 3: memory/events/task-2502.done
    (ws / "memory" / "events" / "task-2502.done").write_text("ok", encoding="utf-8")

    # check helper directly
    ok, unmerged = check_dependency_merged(["task-2502.merged"])
    assert ok is True, f"task-2502.merged must be satisfied via .done event, unmerged={unmerged}"

    # classify → ALLOW
    metadata = {
        "expected_files": ["new/file_2502_dep.py"],
        "risk_area": "content",
        "dependency": ["task-2502.merged"],
        "parallel_policy": "serial_only",
        "merge_queue_position": 1,
        "stale_recheck_required": False,
        "cherry_pick_allowed": False,
    }
    decision = classify(metadata, active_tasks=[])
    assert decision.decision == ALLOW, (
        f"expected ALLOW, got {decision.decision} reasons={decision.reason_codes}"
    )
    assert REASON_MISSING_DEPENDENCY not in decision.reason_codes


# ═══════════════════════════════════════════════════════════════════════════
# §4 #2 dependency 'task-2503.merged' satisfied → ALLOW
# ═══════════════════════════════════════════════════════════════════════════
def test_dependency_task_2503_merged_satisfied_via_report_evidence(tmp_path, monkeypatch):
    ws = _patch_workspace(monkeypatch, tmp_path)
    # evidence path 2: memory/reports/task-2503.md 'mergeCommit' 라인
    (ws / "memory" / "reports" / "task-2503.md").write_text(
        "# task-2503 보고서\n\nmergeCommit: fc49a9fd\n",
        encoding="utf-8",
    )
    ok, kind = _verify_merged_state("task-2503")
    assert ok is True, f"task-2503 merged must be detected, kind={kind}"
    assert kind in ("report_evidence", "done_event"), (
        f"unexpected evidence kind: {kind}"
    )

    metadata = {
        "expected_files": ["new/dep_test.py"],
        "risk_area": "content",
        "dependency": ["task-2503.merged"],
        "parallel_policy": "serial_only",
        "merge_queue_position": 1,
        "stale_recheck_required": False,
        "cherry_pick_allowed": False,
    }
    decision = classify(metadata, active_tasks=[])
    assert decision.decision == ALLOW, (
        f"expected ALLOW, got {decision.decision} reasons={decision.reason_codes}"
    )


# ═══════════════════════════════════════════════════════════════════════════
# §4 #3 merged task는 active/conflicting task에서 제외
# ═══════════════════════════════════════════════════════════════════════════
def test_merged_task_excluded_from_active_filter(tmp_path, monkeypatch):
    ws = _patch_workspace(monkeypatch, tmp_path)
    (ws / "memory" / "events" / "task-2503.done").write_text("ok", encoding="utf-8")

    raw = [
        {"task_id": "task-2503", "expected_files": ["utils/merge_topology_gate.py"]},
        {"task_id": "task-9999", "expected_files": ["other/file.py"]},
    ]
    filtered = _filter_active_tasks(raw)
    ids = [t["task_id"] for t in filtered]
    assert "task-2503" not in ids, (
        f"task-2503 (merged via .done) must be excluded, got: {ids}"
    )
    assert "task-9999" in ids


# ═══════════════════════════════════════════════════════════════════════════
# §4 #4 task-2503+1이 task-2503과 overlap 있어도 task-2503 merged → ALLOW
# ═══════════════════════════════════════════════════════════════════════════
def test_overlap_with_merged_task_does_not_block(tmp_path, monkeypatch):
    ws = _patch_workspace(monkeypatch, tmp_path)
    (ws / "memory" / "events" / "task-2503.done").write_text("ok", encoding="utf-8")
    # task-timers.json 에서 task-2503 은 status=running 으로 잘못 남아 있어도
    # merged evidence 우선이라 active 후보에서 제외된다.
    (ws / "memory" / "task-timers.json").write_text(
        json.dumps({
            "tasks": {
                "task-2503": {"status": "running"},
                "task-2503+1": {"status": "running"},
            }
        }),
        encoding="utf-8",
    )

    metadata = {
        "expected_files": [
            "utils/merge_topology_gate.py",
            "tests/regression/test_merge_topology_gate_real_world_2503_plus_1.py",
        ],
        "risk_area": "dispatch_layer / governance / parallel_policy_enforcement",
        "dependency": ["task-2503.merged"],
        "parallel_policy": "serial_only",
        "merge_queue_position": 2,
        "stale_recheck_required": True,
        "cherry_pick_allowed": False,
    }
    overlapping_active = [
        {
            "task_id": "task-2503",
            "expected_files": ["utils/merge_topology_gate.py"],
            "risk_area": "dispatch_layer",
            "parallel_policy": "serial_only",
            "merge_queue_position": 1,
        }
    ]
    decision = classify(metadata, active_tasks=overlapping_active)
    assert decision.decision == ALLOW, (
        f"expected ALLOW (task-2503 merged → not active), "
        f"got {decision.decision} reasons={decision.reason_codes}"
    )
    assert REASON_DUPLICATE_FILE not in decision.reason_codes
    assert "task-2503" not in decision.conflicting_tasks


# ═══════════════════════════════════════════════════════════════════════════
# §4 #5 read-only report task expected_files overlap 0이면 ALLOW
# ═══════════════════════════════════════════════════════════════════════════
def test_read_only_report_task_allows(tmp_path, monkeypatch):
    _patch_workspace(monkeypatch, tmp_path)

    spec = {
        "expected_files": ["memory/reports/task-2494-rejudge.md"],
        "risk_area": "read_only_report_generation",
        "dependency": "none",
        "parallel_policy": "parallel_safe",
        "merge_queue_position": "n/a",
        "stale_recheck_required": False,
        "cherry_pick_allowed": False,
        "forbidden_actions": [
            "any_code_modification",
            "any_test_modification",
            "any_pr_modification",
            "any_branch_modification",
        ],
    }
    assert _is_pure_read_only(spec) is True

    # Even with an active task that overlaps elsewhere, no overlap on the single
    # read-only path → ALLOW. Adversarial: include a confounding active task.
    other = {
        "task_id": "task-other",
        "expected_files": ["src/unrelated.py"],
        "risk_area": "content",
        "parallel_policy": "serial_only",
        "merge_queue_position": 1,
    }
    decision = classify(spec, active_tasks=[other])
    assert decision.decision == ALLOW, (
        f"read-only report task must be ALLOW, got {decision.decision} "
        f"reasons={decision.reason_codes}"
    )


# ═══════════════════════════════════════════════════════════════════════════
# §4 #6 expected_files 교집합 0이면 PARALLEL_SAFE_FALSE_DECLARATION 금지
# ═══════════════════════════════════════════════════════════════════════════
def test_parallel_safe_no_overlap_does_not_raise_false_declaration(tmp_path, monkeypatch):
    _patch_workspace(monkeypatch, tmp_path)

    metadata = {
        "expected_files": ["src/unique_a.py"],
        "risk_area": "content",
        "dependency": "none",
        "parallel_policy": "parallel_safe",
        "merge_queue_position": "n/a",
        "stale_recheck_required": False,
        "cherry_pick_allowed": False,
    }
    other = {
        "task_id": "task-other",
        "expected_files": ["src/unique_b.py"],
        "risk_area": "content",
        "parallel_policy": "serial_only",
        "merge_queue_position": 1,
    }
    overlap = _compute_overlap(metadata, other)
    assert overlap["files"] == set()
    assert overlap["mutation_risk"] is False

    decision = classify(metadata, active_tasks=[other])
    assert REASON_PARALLEL_SAFE_FALSE_DECLARATION not in decision.reason_codes, (
        f"PARALLEL_SAFE_FALSE_DECLARATION must NOT fire when overlap=0, "
        f"got reasons={decision.reason_codes}"
    )
    assert decision.decision == ALLOW


# ═══════════════════════════════════════════════════════════════════════════
# §4 #7 동일 파일 overlap이 active unmerged task와 발생하면 BLOCK 유지
# ═══════════════════════════════════════════════════════════════════════════
def test_unmerged_active_overlap_still_blocks(tmp_path, monkeypatch):
    """기존 룰 #2 회귀 — merged evidence가 없는 active task와 파일 충돌은 여전히 BLOCK."""
    _patch_workspace(monkeypatch, tmp_path)

    metadata = {
        "expected_files": ["utils/shared.py"],
        "risk_area": "content",
        "dependency": "none",
        "parallel_policy": "serial_only",
        "merge_queue_position": 1,
        "stale_recheck_required": False,
        "cherry_pick_allowed": False,
    }
    active_unmerged = [
        {
            "task_id": "task-9001",  # tmp_path에는 .done/.report 모두 없음 → unmerged
            "expected_files": ["utils/shared.py"],
            "risk_area": "content",
            "parallel_policy": "serial_only",
            "merge_queue_position": 2,
        }
    ]
    decision = classify(metadata, active_tasks=active_unmerged)
    assert decision.decision == BLOCK, (
        f"unmerged active overlap must keep BLOCK, got {decision.decision}"
    )
    assert REASON_DUPLICATE_FILE in decision.reason_codes
    assert "task-9001" in decision.conflicting_tasks


# ═══════════════════════════════════════════════════════════════════════════
# §4 #8 BLOCK + override + chair approval → ALLOW_WITH_CHAIR_OVERRIDE + 9 필드 audit
# ═══════════════════════════════════════════════════════════════════════════
def test_block_with_chair_override_yields_allow_with_chair_override(tmp_path, monkeypatch):
    ws = _patch_workspace(monkeypatch, tmp_path)
    audit_path = ws / "memory" / "orchestration-audit" / "merge-topology-gate.jsonl"

    # Construct a task that would BLOCK on DUPLICATE_FILE (active unmerged overlap).
    # active_tasks come from tmp task-timers.json
    (ws / "memory" / "task-timers.json").write_text(
        json.dumps({
            "tasks": {
                "task-9001": {
                    "status": "running",
                    "expected_files": ["utils/shared.py"],
                    "risk_area": "content",
                    "parallel_policy": "serial_only",
                    "merge_queue_position": 2,
                },
            }
        }),
        encoding="utf-8",
    )

    task_desc = '''# Override case

```yaml
expected_files:
  - "utils/shared.py"
risk_area: "content"
dependency: "none"
parallel_policy: "serial_only"
merge_queue_position: 1
stale_recheck_required: false
cherry_pick_allowed: false
```
'''

    decision, allowed = run_gate(
        task_id="task-test-override-block",
        task_desc=task_desc,
        override_merge_topology_gate=True,
        chair_override_reason="emergency bootstrap (test fixture)",
        chair_approved_by="chair (test)",
    )

    assert allowed is True, f"expected allowed=True after chair override"
    assert decision.decision == ALLOW_WITH_CHAIR_OVERRIDE, (
        f"expected ALLOW_WITH_CHAIR_OVERRIDE, got {decision.decision}"
    )

    # audit 9 필드 박제 검증
    assert audit_path.exists(), "audit jsonl must be created"
    records = [
        json.loads(line)
        for line in audit_path.read_text(encoding="utf-8").splitlines()
        if line.strip()
    ]
    assert records, "audit must contain at least one record"
    rec = records[-1]
    required_fields = [
        "original_decision",
        "override_used",
        "override_decision",
        "override_reason",
        "approved_by",
        "original_reason_codes",
        "conflicting_tasks",
        "task_id",
        "timestamp",
    ]
    for f in required_fields:
        assert f in rec, f"audit record missing field {f!r} (record={rec})"
    assert rec["original_decision"] == BLOCK
    assert rec["override_used"] is True
    assert rec["override_decision"] == ALLOW_WITH_CHAIR_OVERRIDE
    assert REASON_DUPLICATE_FILE in rec["original_reason_codes"]
    assert "task-9001" in rec["conflicting_tasks"]
    assert rec["task_id"] == "task-test-override-block"
    assert rec["timestamp"]


# ═══════════════════════════════════════════════════════════════════════════
# Bonus: BLOCK without override flag → still BLOCK (override 룰 부작용 없음 회귀)
# ═══════════════════════════════════════════════════════════════════════════
def test_block_without_override_flag_still_blocks(tmp_path, monkeypatch):
    ws = _patch_workspace(monkeypatch, tmp_path)
    (ws / "memory" / "task-timers.json").write_text(
        json.dumps({
            "tasks": {
                "task-9001": {
                    "status": "running",
                    "expected_files": ["utils/shared.py"],
                    "risk_area": "content",
                    "parallel_policy": "serial_only",
                    "merge_queue_position": 2,
                },
            }
        }),
        encoding="utf-8",
    )
    task_desc = '''# No override

```yaml
expected_files:
  - "utils/shared.py"
risk_area: "content"
dependency: "none"
parallel_policy: "serial_only"
merge_queue_position: 1
stale_recheck_required: false
cherry_pick_allowed: false
```
'''
    decision, allowed = run_gate(
        task_id="task-test-no-override",
        task_desc=task_desc,
    )
    assert allowed is False
    assert decision.decision == BLOCK


# ═══════════════════════════════════════════════════════════════════════════
# 자기참조: task-2503+1.md 자체를 parse → classify (active_tasks=[]) → != BLOCK
# ═══════════════════════════════════════════════════════════════════════════
def test_self_reference_task_2503_plus_1_passes():
    """task-2503+1.md의 7 metadata가 정상 파싱되고 dependency satisfied."""
    task_md = WORKSPACE / "memory" / "tasks" / "task-2503+1.md"
    assert task_md.exists(), f"task-2503+1.md not found: {task_md}"

    text = task_md.read_text(encoding="utf-8")
    metadata = parse_topology_metadata(text)
    assert metadata.get("expected_files"), "expected_files must be parsed"
    assert metadata.get("dependency"), "dependency must be parsed"

    # dependency mock: task-2503.merged satisfied (실제 .done event 있음)
    decision = classify(metadata, active_tasks=[])
    assert decision.decision != BLOCK, (
        f"task-2503+1 self-reference must NOT BLOCK, "
        f"got {decision.decision} reasons={decision.reason_codes}"
    )
