"""Black-box integration tests for spawn subprocess seams (T13-T16).

Spec link: docs/INTERFACES.md (CLI Launch + State Directory semantics).
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
import time
from pathlib import Path


TERMINAL_STATES = {"completed", "failed", "killed"}


def _anima_cmd(*args: str) -> list[str]:
    return [sys.executable, "-m", "anima", *args]


def _write_persona(path: Path, *, name: str) -> None:
    path.write_text(
        json.dumps(
            {
                "spec_version": "0.1.0",
                "id": name,
                "prompt": "You are a subprocess integration test persona.",
                "model": "test",
            }
        )
    )


def _write_legacy_persona(path: Path, *, name: str) -> None:
    path.write_text(
        json.dumps(
            {
                "spec_version": "0.1.0",
                "name": name,
                "system_prompt": "You are a subprocess integration test persona.",
                "model": "test",
            }
        )
    )


def _spawn_and_parse(
    *,
    task: str,
    persona_file: Path,
    anima_state: Path,
    extra_env: dict[str, str] | None = None,
) -> tuple[subprocess.CompletedProcess[str], dict[str, object]]:
    short_state = Path("/tmp") / f"anima_state_{os.getpid()}_{time.monotonic_ns()}"
    short_state.symlink_to(anima_state, target_is_directory=True)

    env = os.environ.copy()
    env["ANIMA_STATE"] = str(short_state)
    env["HOME"] = str(anima_state.parent)
    if extra_env:
        env.update(extra_env)

    result = subprocess.run(
        _anima_cmd(
            "spawn",
            task,
            "--persona",
            str(persona_file),
            "--model",
            "test",
            "--json",
            "--completion-policy",
            "auto",
            "--lifecycle",
            "ephemeral",
        ),
        capture_output=True,
        text=True,
        timeout=10,
        env=env,
        check=False,
    )
    assert result.returncode == 0, (
        f"spawn failed rc={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}"
    )
    try:
        payload = json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        raise AssertionError(
            f"spawn output was not valid JSON:\n{result.stdout}\n{result.stderr}"
        ) from exc
    assert isinstance(payload, dict), f"Expected JSON object payload, got: {payload!r}"
    assert isinstance(payload.get("instance_id"), str) and payload["instance_id"], (
        f"Missing instance_id in spawn output: {payload!r}"
    )
    return result, payload


def _spawn_raw(
    *,
    task: str,
    persona_file_arg: str,
    anima_state: Path,
    cwd: Path | None = None,
) -> subprocess.CompletedProcess[str]:
    env = os.environ.copy()
    env["ANIMA_STATE"] = str(anima_state)
    env["HOME"] = str(anima_state.parent)
    return subprocess.run(
        _anima_cmd(
            "spawn",
            task,
            "--persona",
            persona_file_arg,
            "--model",
            "test",
            "--json",
            "--completion-policy",
            "auto",
            "--lifecycle",
            "ephemeral",
        ),
        capture_output=True,
        text=True,
        timeout=10,
        env=env,
        cwd=str(cwd) if cwd is not None else None,
        check=False,
    )


def _poll_terminal_state(
    state_file: Path, timeout_seconds: float = 10.0
) -> dict[str, object]:
    deadline = time.monotonic() + timeout_seconds
    last_state: dict[str, object] | None = None

    while time.monotonic() < deadline:
        if state_file.exists():
            loaded = json.loads(state_file.read_text())
            if isinstance(loaded, dict):
                last_state = loaded
                if loaded.get("state") in TERMINAL_STATES:
                    return loaded
        time.sleep(0.1)

    raise AssertionError(
        f"state.json did not reach terminal state within {timeout_seconds:.1f}s: "
        f"last={last_state!r}, file={state_file}"
    )


def _sitecustomize_env(tmp_path: Path, body: str) -> dict[str, str]:
    patch_dir = tmp_path / "sitepatch"
    patch_dir.mkdir(parents=True, exist_ok=True)
    (patch_dir / "sitecustomize.py").write_text(body)

    existing = os.environ.get("PYTHONPATH", "")
    new_path = (
        os.pathsep.join([str(patch_dir), existing]) if existing else str(patch_dir)
    )
    return {"PYTHONPATH": new_path}


def test_t13_spawn_model_test_completes(tmp_state_dir: Path, tmp_path: Path) -> None:
    """T13: spawn with --model test reaches terminal state=completed."""
    persona_file = tmp_path / "persona_t13.json"
    _write_persona(persona_file, name="spawn-t13")

    _, payload = _spawn_and_parse(
        task="T13 complete",
        persona_file=persona_file,
        anima_state=tmp_state_dir,
    )

    instance_id = str(payload["instance_id"])
    state_file = tmp_state_dir / "agents" / instance_id / "state.json"
    terminal = _poll_terminal_state(state_file, timeout_seconds=10.0)

    assert terminal.get("state") == "completed", (
        f"T13 expected completed, got {terminal.get('state')!r} for {instance_id}"
    )


def test_t13_spawn_accepts_relative_and_absolute_persona_paths(
    tmp_state_dir: Path, tmp_path: Path
) -> None:
    """T13 seam proof: spawn accepts both relative and absolute persona paths."""
    persona_file = tmp_path / "persona_relative_absolute.json"
    _write_persona(persona_file, name="spawn-relative-absolute")

    relative_result = _spawn_raw(
        task="T13 relative path",
        persona_file_arg=persona_file.name,
        anima_state=tmp_state_dir,
        cwd=tmp_path,
    )
    assert relative_result.returncode == 0, (
        f"relative persona path should succeed\n"
        f"stdout={relative_result.stdout}\nstderr={relative_result.stderr}"
    )

    absolute_result = _spawn_raw(
        task="T13 absolute path",
        persona_file_arg=str(persona_file),
        anima_state=tmp_state_dir,
    )
    assert absolute_result.returncode == 0, (
        f"absolute persona path should succeed\n"
        f"stdout={absolute_result.stdout}\nstderr={absolute_result.stderr}"
    )


def test_t13_spawn_missing_persona_file_fails_fast(
    tmp_state_dir: Path, tmp_path: Path
) -> None:
    """T13 seam proof: missing persona path fails without creating state."""
    missing = tmp_path / "persona_does_not_exist.json"
    result = _spawn_raw(
        task="T13 missing persona",
        persona_file_arg=str(missing),
        anima_state=tmp_state_dir,
    )

    assert result.returncode != 0
    combined = f"{result.stdout}\n{result.stderr}".lower()
    assert "persona" in combined and "not found" in combined


def test_t13_spawn_prefers_prompt_over_legacy_system_prompt(
    tmp_state_dir: Path, tmp_path: Path
) -> None:
    """T13 seam proof: canonical prompt wins when legacy alias is also present."""
    persona_file = tmp_path / "persona_prompt_precedence.json"
    persona_file.write_text(
        json.dumps(
            {
                "spec_version": "0.1.0",
                "name": "spawn-t13-precedence",
                "prompt": "canonical prompt wins",
                "system_prompt": "legacy prompt should be ignored",
                "model": "test",
            }
        )
    )

    _, payload = _spawn_and_parse(
        task="T13 prompt precedence",
        persona_file=persona_file,
        anima_state=tmp_state_dir,
    )
    instance_id = str(payload["instance_id"])
    state_file = tmp_state_dir / "agents" / instance_id / "state.json"
    terminal = _poll_terminal_state(state_file, timeout_seconds=10.0)

    assert terminal.get("state") == "completed"


def test_t13_spawn_model_test_completes_with_legacy_persona_aliases(
    tmp_state_dir: Path, tmp_path: Path
) -> None:
    """T13 sibling: subprocess persona loader normalizes legacy name/system_prompt keys."""
    persona_file = tmp_path / "persona_t13_legacy.json"
    _write_legacy_persona(persona_file, name="spawn-t13-legacy")

    _, payload = _spawn_and_parse(
        task="T13 legacy aliases",
        persona_file=persona_file,
        anima_state=tmp_state_dir,
    )

    instance_id = str(payload["instance_id"])
    state_file = tmp_state_dir / "agents" / instance_id / "state.json"
    terminal = _poll_terminal_state(state_file, timeout_seconds=10.0)

    assert terminal.get("state") == "completed", (
        "T13 legacy aliases expected completed, "
        f"got {terminal.get('state')!r} for {instance_id}"
    )


def test_t14_temp_persona_file_cleaned_after_child_reads(
    tmp_state_dir: Path, tmp_path: Path
) -> None:
    """T14: no temp persona/job files remain after spawned child completes."""
    persona_file = tmp_path / "persona_t14.json"
    _write_persona(persona_file, name="spawn-t14")

    _, payload = _spawn_and_parse(
        task="T14 cleanup",
        persona_file=persona_file,
        anima_state=tmp_state_dir,
    )

    instance_id = str(payload["instance_id"])
    state_file = tmp_state_dir / "agents" / instance_id / "state.json"
    terminal = _poll_terminal_state(state_file, timeout_seconds=10.0)
    assert terminal.get("state") == "completed", (
        f"T14 setup failed: expected completed, got {terminal.get('state')!r}"
    )

    leftovers = [
        p
        for p in tmp_state_dir.rglob("*")
        if p.is_file() and ("temp_persona" in p.name or "temp_job" in p.name)
    ]
    assert not leftovers, (
        "T14 expected child-owned temp persona/job files to be cleaned up, "
        f"found: {[str(p) for p in leftovers]}"
    )


def test_t15_stderr_captured_to_stderr_log(tmp_state_dir: Path, tmp_path: Path) -> None:
    """T15: spawned child stderr output is written to stderr.log."""
    persona_file = tmp_path / "persona_t15.json"
    _write_persona(persona_file, name="spawn-t15")

    marker = "T15 sitecustomize stderr marker"
    env_patch = _sitecustomize_env(
        tmp_path,
        "\n".join(
            [
                "import sys",
                f"sys.stderr.write({marker!r} + '\\n')",
                "sys.stderr.flush()",
            ]
        ),
    )

    _, payload = _spawn_and_parse(
        task="T15 stderr",
        persona_file=persona_file,
        anima_state=tmp_state_dir,
        extra_env=env_patch,
    )

    instance_id = str(payload["instance_id"])
    state_file = tmp_state_dir / "agents" / instance_id / "state.json"
    _ = _poll_terminal_state(state_file, timeout_seconds=10.0)

    stderr_log = tmp_state_dir / "agents" / instance_id / "stderr.log"
    assert stderr_log.exists(), f"T15 expected stderr.log to exist: {stderr_log}"
    content = stderr_log.read_text()
    assert marker in content, f"T15 expected marker in stderr.log, got: {content!r}"


def test_t16_bad_persona_file_transitions_to_failed_not_running(
    tmp_state_dir: Path, tmp_path: Path
) -> None:
    """T16: persona read/parse failure in child reaches state=failed (not stuck running)."""
    persona_file = tmp_path / "persona_t16.json"
    _write_persona(persona_file, name="spawn-t16")

    env_patch = _sitecustomize_env(
        tmp_path,
        "\n".join(
            [
                "import json as _json",
                "import sys",
                "if any('agent_runner.py' in arg for arg in sys.argv):",
                "    _orig_loads = _json.loads",
                "    def _patched_loads(s, *args, **kwargs):",
                "        if isinstance(s, str) and '\"spec_version\"' in s and ('\"system_prompt\"' in s or '\"prompt\"' in s):",
                "            raise _json.JSONDecodeError('forced bad persona for T16', s, 0)",
                "        return _orig_loads(s, *args, **kwargs)",
                "    _json.loads = _patched_loads",
            ]
        ),
    )

    _, payload = _spawn_and_parse(
        task="T16 bad persona",
        persona_file=persona_file,
        anima_state=tmp_state_dir,
        extra_env=env_patch,
    )

    instance_id = str(payload["instance_id"])
    state_file = tmp_state_dir / "agents" / instance_id / "state.json"
    terminal = _poll_terminal_state(state_file, timeout_seconds=10.0)

    assert terminal.get("state") == "failed", (
        f"T16 expected failed state, got {terminal.get('state')!r} for {instance_id}"
    )
    assert terminal.get("state") != "running", (
        f"T16 expected non-running terminal state, got {terminal.get('state')!r}"
    )


def test_t17_spawn_then_logs_shows_response(
    tmp_state_dir: Path, tmp_path: Path
) -> None:
    """T17: spawn with --model test then `anima logs <id>` shows response text."""
    persona_file = tmp_path / "persona_t17.json"
    _write_persona(persona_file, name="spawn-t17")

    _, payload = _spawn_and_parse(
        task="T17 logs response",
        persona_file=persona_file,
        anima_state=tmp_state_dir,
    )

    instance_id = str(payload["instance_id"])
    state_file = tmp_state_dir / "agents" / instance_id / "state.json"
    _ = _poll_terminal_state(state_file, timeout_seconds=10.0)

    env = os.environ.copy()
    env["ANIMA_STATE"] = str(tmp_state_dir)
    env["HOME"] = str(tmp_state_dir.parent)

    logs_json = subprocess.run(
        _anima_cmd("logs", instance_id, "--json"),
        capture_output=True,
        text=True,
        timeout=10,
        env=env,
        check=False,
    )
    assert logs_json.returncode == 0, (
        f"T17 setup failed: logs --json rc={logs_json.returncode}\n"
        f"stdout={logs_json.stdout}\nstderr={logs_json.stderr}"
    )

    json_lines = [line for line in logs_json.stdout.splitlines() if line.strip()]
    entries = [json.loads(line) for line in json_lines]
    response_entries = [e for e in entries if e.get("event") == "response"]
    assert response_entries, (
        "T17 setup failed: expected at least one response event in logs --json output"
    )

    response_text = response_entries[0].get("details", {}).get("response_text")
    assert isinstance(response_text, str) and response_text.strip(), (
        f"T17 expected response_text in response event, got: {response_entries[0]!r}"
    )

    logs_human = subprocess.run(
        _anima_cmd("logs", instance_id),
        capture_output=True,
        text=True,
        timeout=10,
        env=env,
        check=False,
    )
    assert logs_human.returncode == 0, (
        f"T17 expected `anima logs <id>` to succeed, rc={logs_human.returncode}\n"
        f"stdout={logs_human.stdout}\nstderr={logs_human.stderr}"
    )
    response_prefix = response_text[:30]
    assert "response (" in logs_human.stdout and response_prefix in logs_human.stdout, (
        "T17 expected human logs output to include a response event and response text. "
        f"response_prefix={response_prefix!r}, stdout={logs_human.stdout!r}"
    )


def test_t18_logs_nonexistent_agent_returns_error(tmp_state_dir: Path) -> None:
    """T18: `anima logs <missing>` exits non-zero with not-found error."""
    env = os.environ.copy()
    env["ANIMA_STATE"] = str(tmp_state_dir)
    env["HOME"] = str(tmp_state_dir.parent)

    result = subprocess.run(
        _anima_cmd("logs", "missing_agent_for_t18"),
        capture_output=True,
        text=True,
        timeout=10,
        env=env,
        check=False,
    )

    combined = f"{result.stdout}\n{result.stderr}".lower()
    assert result.returncode != 0, (
        "T18 expected non-zero exit code for missing agent, "
        f"got rc={result.returncode} stdout={result.stdout!r} stderr={result.stderr!r}"
    )
    assert "not found" in combined, (
        "T18 expected not-found error message in logs output, "
        f"got stdout={result.stdout!r} stderr={result.stderr!r}"
    )


def test_t19_logs_json_output_is_valid_json(
    tmp_state_dir: Path, tmp_path: Path
) -> None:
    """T19: `anima logs <id> --json` emits parseable JSONL."""
    persona_file = tmp_path / "persona_t19.json"
    _write_persona(persona_file, name="spawn-t19")

    _, payload = _spawn_and_parse(
        task="T19 logs json",
        persona_file=persona_file,
        anima_state=tmp_state_dir,
    )

    instance_id = str(payload["instance_id"])
    state_file = tmp_state_dir / "agents" / instance_id / "state.json"
    _ = _poll_terminal_state(state_file, timeout_seconds=10.0)

    env = os.environ.copy()
    env["ANIMA_STATE"] = str(tmp_state_dir)
    env["HOME"] = str(tmp_state_dir.parent)

    result = subprocess.run(
        _anima_cmd("logs", instance_id, "--json"),
        capture_output=True,
        text=True,
        timeout=10,
        env=env,
        check=False,
    )
    assert result.returncode == 0, (
        f"T19 expected logs --json success, rc={result.returncode}\n"
        f"stdout={result.stdout}\nstderr={result.stderr}"
    )

    lines = [line for line in result.stdout.splitlines() if line.strip()]
    assert lines, "T19 expected at least one JSONL line from logs --json"

    parsed = [json.loads(line) for line in lines]
    assert all(isinstance(entry, dict) for entry in parsed), (
        f"T19 expected all lines to decode to JSON objects, got: {parsed!r}"
    )
