from __future__ import annotations

import json
import signal
import subprocess
from copy import deepcopy
from pathlib import Path

import pytest

from crowley_diary.backends import (
    BackendResult,
    CodexExecBackend,
    FixtureBackend,
    TranscriptionRequest,
)
from crowley_diary.cache import RunCache
from crowley_diary.config import EvaluationProfile
from crowley_diary.pipeline import run_batch, transcribe_page
from crowley_diary.source import SourcePackage
from crowley_diary.util import load_json, project_root, write_json


def request(tmp_path: Path) -> TranscriptionRequest:
    image = tmp_path / "page.png"
    image.write_bytes(b"image")
    schema = tmp_path / "schema.json"
    schema.write_text("{}", encoding="utf-8")
    return TranscriptionRequest(
        page_id="page-039",
        image_path=image,
        prompt="prompt",
        output_schema_path=schema,
        model="gpt-5.6-sol",
        reasoning_effort="xhigh",
        timeout_seconds=10,
    )


def test_fixture_backend_loads_page() -> None:
    backend = FixtureBackend(project_root() / "tests" / "fixtures" / "model")
    result = backend.transcribe(
        TranscriptionRequest(
            page_id="page-039",
            image_path=Path("unused.png"),
            prompt="unused",
            output_schema_path=Path("unused.json"),
            model="fixture",
            reasoning_effort="none",
            timeout_seconds=1,
        )
    )
    assert result.success
    assert json.loads(result.final_message or "{}")["page_role"] == "blank"


def test_codex_backend_constructs_subscription_cli_command(monkeypatch, tmp_path: Path) -> None:
    class FakeProcess:
        returncode = 0

        def __init__(self, command, **kwargs):
            assert kwargs["start_new_session"] is True
            output_index = command.index("--output-last-message") + 1
            Path(command[output_index]).write_text(
                '{"page_id":"page-039"}', encoding="utf-8"
            )

        def communicate(self, *, input=None, timeout=None):
            assert input == "prompt"
            assert timeout == 10
            return '{"type":"thread.started","thread_id":"thread-test"}\n', ""

    monkeypatch.setattr("crowley_diary.backends.codex_exec.subprocess.Popen", FakeProcess)
    result = CodexExecBackend().transcribe(request(tmp_path))
    assert result.success
    assert "--ephemeral" in result.command
    assert "--output-schema" in result.command
    assert "--image" in result.command
    assert "read-only" in result.command
    assert result.metadata["thread_id"] == "thread-test"


def test_codex_backend_timeout_decodes_bytes_and_kills_process_group(
    monkeypatch, tmp_path: Path
) -> None:
    calls: list[tuple[int, int]] = []

    class TimedOutProcess:
        pid = 1234
        returncode = None

        def __init__(self, command, **kwargs):
            pass

        def communicate(self, *, input=None, timeout=None):
            if input is not None:
                raise subprocess.TimeoutExpired(
                    "codex", timeout, output=b'partial\n', stderr=b'timed out\n'
                )
            self.returncode = -15
            return "", ""

    monkeypatch.setattr(
        "crowley_diary.backends.codex_exec.subprocess.Popen", TimedOutProcess
    )
    monkeypatch.setattr(
        "crowley_diary.backends.codex_exec.os.killpg",
        lambda pid, sig: calls.append((pid, sig)),
    )
    result = CodexExecBackend().transcribe(request(tmp_path))
    assert not result.success
    assert result.stdout == "partial\n"
    assert result.stderr == "timed out\n"
    assert calls == [(1234, signal.SIGTERM)]


def test_content_addressed_cache_round_trip(tmp_path: Path) -> None:
    cache = RunCache(tmp_path)
    cache.store("abc", {"draft": {"page_id": "page-039"}})
    assert cache.load("abc") == {"draft": {"page_id": "page-039"}}


def test_canonical_invariant_failure_receives_one_repair_attempt(
    monkeypatch, source_root: Path, tmp_path: Path
) -> None:
    good = load_json(project_root() / "tests" / "fixtures" / "model" / "page-181.json")
    bad = deepcopy(good)
    bad["blocks"][0]["date"].update(
        {
            "date_normalized": None,
            "normalization_status": "normalized",
            "normalization_confidence": None,
            "normalization_reason": None,
        }
    )

    class SequenceBackend:
        name = "sequence-fixture"

        def __init__(self) -> None:
            self.responses = [bad, good]
            self.calls = 0

        def transcribe(self, request: TranscriptionRequest) -> BackendResult:
            value = self.responses[self.calls]
            self.calls += 1
            return BackendResult(
                success=True,
                exit_code=0,
                final_message=json.dumps(value),
                stdout="",
                stderr="",
                command=["sequence-fixture"],
            )

    monkeypatch.setattr(
        "crowley_diary.pipeline.RunCache", lambda _path: RunCache(tmp_path / "cache")
    )
    backend = SequenceBackend()
    profile = EvaluationProfile(
        backend=backend.name,
        model="fixture",
        reasoning_effort="none",
        timeout_seconds=10,
        max_repair_attempts=1,
        previous_context_characters=100,
        next_context_characters=100,
        max_batch_pages=20,
    )
    result = transcribe_page(
        source=SourcePackage(source_root),
        page=181,
        run_id="repair-test",
        run_root=tmp_path / "run",
        profile=profile,
        backend=backend,
    )
    assert result.status == "valid_candidate"
    assert backend.calls == 2
    assert (tmp_path / "run" / "input-schema.json").is_file()
    assert (tmp_path / "run" / "input-transcription-specification.md").is_file()
    repair_report = load_json(
        tmp_path / "run" / "pages" / "page-181" / "attempt-02" / "canonical-validation.json"
    )
    assert repair_report["valid"]


def test_batch_guard_refuses_corpus_scale_by_default(
    source_root: Path, tmp_path: Path
) -> None:
    selection = tmp_path / "selection.json"
    write_json(
        selection,
        {"cases": [{"page": page} for page in range(1, 22)]},
    )
    profile = EvaluationProfile(
        backend="fixture",
        model="fixture",
        reasoning_effort="none",
        timeout_seconds=10,
        max_repair_attempts=1,
        previous_context_characters=100,
        next_context_characters=100,
        max_batch_pages=20,
    )
    with pytest.raises(ValueError, match="batch limit"):
        run_batch(
            source=SourcePackage(source_root),
            selection_path=selection,
            profile=profile,
            backend=FixtureBackend(project_root() / "tests" / "fixtures" / "model"),
        )


def test_batch_resume_reuses_valid_candidate_without_backend_call(
    monkeypatch, source_root: Path, tmp_path: Path
) -> None:
    class CountingFixtureBackend:
        name = "counting-fixture"

        def __init__(self) -> None:
            self.delegate = FixtureBackend(project_root() / "tests" / "fixtures" / "model")
            self.calls = 0

        def transcribe(self, request: TranscriptionRequest) -> BackendResult:
            self.calls += 1
            return self.delegate.transcribe(request)

    monkeypatch.setattr(
        "crowley_diary.pipeline.RunCache", lambda _path: RunCache(tmp_path / "cache")
    )
    selection = tmp_path / "selection.json"
    write_json(selection, {"cases": [{"page": 39}]})
    profile = EvaluationProfile(
        backend="counting-fixture",
        model="fixture",
        reasoning_effort="none",
        timeout_seconds=10,
        max_repair_attempts=1,
        previous_context_characters=100,
        next_context_characters=100,
        max_batch_pages=20,
    )
    backend = CountingFixtureBackend()
    run_root = tmp_path / "run"
    _, first = run_batch(
        source=SourcePackage(source_root),
        selection_path=selection,
        profile=profile,
        backend=backend,
        run_id="resume-test",
        run_root=run_root,
    )
    assert first[0].status == "valid_candidate"
    assert backend.calls == 1
    assert (run_root / "pages" / "page-039" / "current-ocr.txt").is_file()
    assert (run_root / "pages" / "page-039" / "neighbor-context.json").is_file()

    _, second = run_batch(
        source=SourcePackage(source_root),
        selection_path=selection,
        profile=profile,
        backend=backend,
        run_id="resume-test",
        run_root=run_root,
        resume=True,
    )
    assert backend.calls == 1
    assert second[0].resumed is True
    assert load_json(run_root / "run-manifest.json")["resume_count"] == 1


def test_infrastructure_retry_does_not_consume_transcription_repair(
    monkeypatch, source_root: Path, tmp_path: Path
) -> None:
    good = load_json(project_root() / "tests" / "fixtures" / "model" / "page-039.json")

    class TransientBackend:
        name = "transient-fixture"

        def __init__(self) -> None:
            self.calls = 0

        def transcribe(self, request: TranscriptionRequest) -> BackendResult:
            self.calls += 1
            if self.calls == 1:
                return BackendResult(
                    success=False,
                    exit_code=1,
                    final_message=None,
                    stdout="",
                    stderr="503 service unavailable",
                    command=["transient-fixture"],
                    error="temporary service failure",
                )
            return BackendResult(
                success=True,
                exit_code=0,
                final_message=json.dumps(good),
                stdout="",
                stderr="",
                command=["transient-fixture"],
            )

    monkeypatch.setattr(
        "crowley_diary.pipeline.RunCache", lambda _path: RunCache(tmp_path / "cache")
    )
    backend = TransientBackend()
    profile = EvaluationProfile(
        backend=backend.name,
        model="fixture",
        reasoning_effort="none",
        timeout_seconds=10,
        max_repair_attempts=1,
        previous_context_characters=100,
        next_context_characters=100,
        max_batch_pages=20,
        max_infrastructure_retries=2,
        infrastructure_backoff_seconds=0,
    )
    result = transcribe_page(
        source=SourcePackage(source_root),
        page=39,
        run_id="infrastructure-retry-test",
        run_root=tmp_path / "run",
        profile=profile,
        backend=backend,
    )
    assert result.status == "valid_candidate"
    assert result.attempt_count == 1
    assert result.repair_used is False
    assert result.infrastructure_retries == 1
    assert backend.calls == 2
    assert (tmp_path / "run" / "pages" / "page-039" / "attempt-01-infra-02").is_dir()
    assert not (tmp_path / "run" / "pages" / "page-039" / "attempt-02").exists()
