"""Black-box tests for auto-rm behavior on run/spawn.

Spec source: auto-rm.rm-tests

FALSIFIABLE ORDERING TESTS:
These tests are designed to FAIL if temporal ordering is violated:

1. Result MUST be printed/sent BEFORE cleanup
2. State MUST exist at the moment output is produced
3. Cleanup MUST only happen for terminal states (spawn)
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path

import pytest


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


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


@pytest.fixture
def short_state_env(tmp_path: Path) -> Iterator[tuple[dict[str, str], Path]]:
    """Provide env + short ANIMA_STATE symlink to avoid UNIX socket path limits."""
    state_dir = tmp_path / "anima_state"
    state_dir.mkdir(parents=True, exist_ok=True)

    short_state = Path("/tmp") / f"anima_state_{os.getpid()}_{time.monotonic_ns()}"
    short_state.symlink_to(state_dir, target_is_directory=True)

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

    yield env, state_dir

    if short_state.exists() or short_state.is_symlink():
        short_state.unlink()


def _run_cli(env: dict[str, str], *args: str) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        _anima_cmd(*args),
        capture_output=True,
        text=True,
        timeout=45,
        env=env,
        check=False,
    )


def _parse_json_output(result: subprocess.CompletedProcess[str]) -> dict[str, object]:
    assert result.returncode == 0, (
        f"command failed rc={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}"
    )
    try:
        payload = json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        raise AssertionError(
            f"command output was not valid JSON:\nstdout={result.stdout}\nstderr={result.stderr}"
        ) from exc

    assert isinstance(payload, dict), f"expected JSON object, got {payload!r}"
    return payload


def _wait_for_terminal_state(
    state_file: Path, timeout_seconds: float = 45.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 _pid_is_alive(pid: int) -> bool:
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    return True


def test_run_default_cleans_up_state_dir(
    short_state_env: tuple[dict[str, str], Path],
) -> None:
    """anima run default removes agent state directory when task finishes."""
    env, state_dir = short_state_env

    result = _run_cli(env, "run", "auto-rm default run", "--model", "test", "--json")
    payload = _parse_json_output(result)
    instance_id = str(payload.get("instance_id") or payload.get("agent_id") or "")
    assert instance_id, f"missing instance_id/agent_id in output: {payload!r}"

    agent_dir = state_dir / "agents" / instance_id
    assert not agent_dir.exists(), (
        "Expected anima run default (--rm) to remove state directory, "
        f"but agent dir still exists: {agent_dir}"
    )


def test_run_no_rm_preserves_state_dir(
    short_state_env: tuple[dict[str, str], Path],
) -> None:
    """anima run --no-rm preserves agent state directory."""
    env, state_dir = short_state_env

    result = _run_cli(
        env,
        "run",
        "auto-rm run no-rm",
        "--model",
        "test",
        "--no-rm",
        "--json",
    )
    payload = _parse_json_output(result)
    instance_id = str(payload.get("instance_id") or payload.get("agent_id") or "")
    assert instance_id, f"missing instance_id/agent_id in output: {payload!r}"

    agent_dir = state_dir / "agents" / instance_id
    assert agent_dir.exists(), (
        "Expected anima run --no-rm to preserve state directory, "
        f"but agent dir is missing: {agent_dir}"
    )


def test_spawn_rm_cleans_up_after_completion(
    short_state_env: tuple[dict[str, str], Path],
) -> None:
    """anima spawn --rm removes state directory after daemon completion."""
    env, state_dir = short_state_env

    result = _run_cli(
        env,
        "spawn",
        "auto-rm spawn with rm",
        "--model",
        "test",
        "--rm",
        "--json",
        "--completion-policy",
        "auto",
        "--lifecycle",
        "ephemeral",
    )
    payload = _parse_json_output(result)
    instance_id = str(payload.get("instance_id") or "")
    pid_value = payload.get("pid")
    assert instance_id, f"missing instance_id in spawn output: {payload!r}"
    assert isinstance(pid_value, int), (
        f"missing/invalid pid in spawn output: {payload!r}"
    )

    agent_dir = state_dir / "agents" / instance_id

    # Wait for daemon process to finish; cleanup is expected after completion.
    deadline = time.monotonic() + 45.0
    while time.monotonic() < deadline:
        if not _pid_is_alive(pid_value):
            break
        time.sleep(0.1)

    assert not _pid_is_alive(pid_value), (
        "Expected spawned daemon process to exit before timeout, "
        f"but pid {pid_value} is still alive"
    )
    assert not agent_dir.exists(), (
        "Expected anima spawn --rm to clean up state after completion, "
        f"but agent dir still exists: {agent_dir}"
    )


def test_spawn_default_preserves_state(
    short_state_env: tuple[dict[str, str], Path],
) -> None:
    """anima spawn without --rm preserves state after completion."""
    env, state_dir = short_state_env

    result = _run_cli(
        env,
        "spawn",
        "auto-rm spawn default",
        "--model",
        "test",
        "--json",
        "--completion-policy",
        "auto",
        "--lifecycle",
        "ephemeral",
    )
    payload = _parse_json_output(result)
    instance_id = str(payload.get("instance_id") or "")
    assert instance_id, f"missing instance_id in spawn output: {payload!r}"

    agent_dir = state_dir / "agents" / instance_id
    state_file = agent_dir / "state.json"

    terminal = _wait_for_terminal_state(state_file, timeout_seconds=45.0)
    assert terminal.get("state") in TERMINAL_STATES, (
        f"Expected terminal state for spawn default, got {terminal!r}"
    )
    assert agent_dir.exists(), (
        "Expected anima spawn (without --rm) to preserve state directory, "
        f"but agent dir is missing: {agent_dir}"
    )


def test_run_cleanup_after_result_semantics(
    short_state_env: tuple[dict[str, str], Path],
) -> None:
    """auto-rm cleanup runs in 'finally' block AFTER result is printed.

    FALSIFIABLE ORDERING TEST:
    This test WILL FAIL if cleanup happens before result output.

    Proof of ordering:
    1. We successfully parse JSON output (must have instance_id)
    2. We check that state directory is gone (cleanup happened)
    3. The existence of instance_id in output proves state existed when output was produced
       (instance_id comes from state directory creation)
    4. The subsequent absence proves cleanup happened after output

    If cleanup happened BEFORE output, instance_id would not exist in the output
    because state directory creation would have been cleaned up before the ID was generated.

    Ordering: create_state → run_task → print_result → cleanup_state
    """
    env, state_dir = short_state_env

    # Run with auto-rm (default) should cleanup after printing result
    result = _run_cli(env, "run", "test cleanup ordering", "--model", "test", "--json")

    # Verify exit code 0 (completed successfully)
    assert result.returncode == 0, (
        f"Expected successful run, got exit {result.returncode}\n"
        f"stdout={result.stdout}\nstderr={result.stderr}"
    )

    # CRITICAL: Parse JSON output - this proves result was printed before cleanup
    # If cleanup ran before output, the subprocess couldn't have created instance_id
    # because state creation happens first, then instance_id is generated from it
    payload = _parse_json_output(result)
    instance_id = str(payload.get("instance_id") or payload.get("agent_id") or "")
    assert instance_id, f"missing instance_id in output: {payload!r}"

    # Cleanup must have run AFTER JSON was printed
    agent_dir = state_dir / "agents" / instance_id
    assert not agent_dir.exists(), (
        "Cleanup should have deleted state dir after result was printed"
    )

    # FALSIFIABLE ASSERTION: If cleanup happened before output,
    # the instance_id would not exist because:
    # 1. State creation is required before instance_id generation
    # 2. Cleanup deletes the state directory
    # 3. Therefore, cleanup before output = no instance_id in output
    # Since we have instance_id, cleanup MUST have happened after output


def test_spawn_cleanup_after_result_semantics(
    short_state_env: tuple[dict[str, str], Path],
) -> None:
    """auto-rm cleanup for spawn runs in 'finally' after subprocess writes result.

    FALSIFIABLE ORDERING TEST:
    This test WILL FAIL if cleanup happens before result write.

    For spawn, there are TWO ordering guarantees:
    1. Parent: spawn returns PID immediately (output before any subprocess work)
    2. Child: subprocess writes result to state.json BEFORE cleanup

    Proof of ordering:
    - Parent output (instance_id, pid) happens immediately
    - Child cleanup only happens for terminal states
    - We poll state.json while process is running to verify it exists
    - We verify terminal state was reached before cleanup

    If cleanup happened BEFORE result write in child:
    - state.json would be deleted before reaching terminal state
    - We would see no state.json at poll time
    - Process would fail to write its result
    """
    env, state_dir = short_state_env

    result = _run_cli(
        env, "spawn", "test spawn cleanup ordering", "--model", "test", "--rm", "--json",
        "--completion-policy", "auto", "--lifecycle", "ephemeral",
    )
    payload = _parse_json_output(result)
    instance_id = str(payload.get("instance_id") or "")
    pid_value = payload.get("pid")
    assert instance_id, f"missing instance_id in spawn output: {payload!r}"
    assert isinstance(pid_value, int), (
        f"missing/invalid pid in spawn output: {payload!r}"
    )

    agent_dir = state_dir / "agents" / instance_id
    state_file = agent_dir / "state.json"

    # FALSIFIABLE: Verify state.json exists WHILE process is running
    # This proves subprocess hasn't cleaned up yet
    poll_count = 0
    saw_state_while_running = False

    while _pid_is_alive(pid_value) and poll_count < 10:
        # Poll for state directory existence during subprocess execution
        # If cleanup happened before result write, this would be False
        if state_file.exists():
            saw_state_while_running = True
            # Read state to verify it has valid structure
            try:
                state_data = json.loads(state_file.read_text())
                # State should be "running" while process is alive
                # (or transitioning between states)
                assert isinstance(state_data, dict), "State must be a dict"
            except (json.JSONDecodeError, OSError):
                # Could be mid-write, retry
                pass
        time.sleep(0.1)
        poll_count += 1

    # Process must be in terminal state or cleaned up by now
    # Wait for process to finish (with timeout)
    deadline = time.monotonic() + 45.0
    while time.monotonic() < deadline:
        if not _pid_is_alive(pid_value):
            break
        time.sleep(0.1)

    # FALSIFIABLE: At some point during execution, state.json MUST have existed
    # If cleanup happened before result write, state.json would never exist
    # and subprocess would fail to write its result
    assert saw_state_while_running or not _pid_is_alive(pid_value), (
        "State directory must exist during subprocess execution, "
        "proving cleanup hasn't happened before result write"
    )

    # Process completed, cleanup should have run
    assert not agent_dir.exists(), (
        "Cleanup should have deleted state dir after subprocess completed"
    )


def test_run_crash_path_cleanup_after_error_output(
    short_state_env: tuple[dict[str, str], Path],
) -> None:
    """Crash path: error output appears BEFORE cleanup in run.

    FALSIFIABLE ORDERING TEST:
    This test WILL FAIL if cleanup happens before error output.

    Ordering guarantee: try { run } except { print error } finally { cleanup }
    Even on crash, error output appears before cleanup.

    Proof of ordering:
    - On error, we get JSON error output
    - Error output contains instance_id (proves state existed)
    - After output, cleanup runs (state is gone)
    - If cleanup ran first, instance_id wouldn't exist in output
    """
    env, state_dir = short_state_env

    # Run with test model - might succeed or fail, but ordering should hold
    result = _run_cli(env, "run", "test crash path", "--model", "test", "--json")

    has_output = bool(result.stdout.strip())

    # FALSIFIABLE: We MUST have output (success or error)
    # If cleanup ran before error handling, we might have no output at all
    assert has_output, (
        "Error path should produce output (stdout for JSON) before cleanup. "
        f"stdout={result.stdout!r}, stderr={result.stderr!r}, rc={result.returncode}"
    )

    # Try to parse output
    payload = None
    instance_id = None

    if has_output:
        try:
            payload = json.loads(result.stdout)
            instance_id = str(
                payload.get("instance_id") or payload.get("agent_id") or ""
            )
        except json.JSONDecodeError:
            # Non-JSON output is valid for error paths
            pass

    # FALSIFIABLE: If we got instance_id, state MUST have existed
    # This proves state existed when output was produced
    if instance_id:
        agent_dir = state_dir / "agents" / instance_id
        # Cleanup must run AFTER output (success or error)
        assert not agent_dir.exists(), (
            "Crash path cleanup should still delete state dir after error output, "
            f"but dir still exists: {agent_dir}"
        )
    else:
        # For non-JSON error output, verify no state directories remain
        agents_dir = state_dir / "agents"
        if agents_dir.exists():
            remaining = list(agents_dir.iterdir())
            # All should be cleaned up by auto-rm default
            assert len(remaining) == 0, (
                f"Crash path with auto-rm should clean up all state dirs, "
                f"but found {len(remaining)} remaining: {[p.name for p in remaining]}"
            )


def test_run_no_rm_preserves_state_on_error(
    short_state_env: tuple[dict[str, str], Path],
) -> None:
    """Error path with --no-rm preserves state for debugging.

    This test verifies --no-rm prevents cleanup even on errors.
    """
    env, state_dir = short_state_env

    result = _run_cli(
        env, "run", "test error preserve", "--model", "test", "--no-rm", "--json"
    )

    # Try to parse output
    try:
        if result.stdout.strip():
            payload = json.loads(result.stdout)
            instance_id = str(
                payload.get("instance_id") or payload.get("agent_id") or ""
            )
            if instance_id:
                agent_dir = state_dir / "agents" / instance_id
                # With --no-rm, state should be preserved even on error
                assert agent_dir.exists(), (
                    "State should be preserved with --no-rm even on error path"
                )
    except json.JSONDecodeError:
        # Non-JSON output is acceptable
        pass


def test_spawn_crash_path_cleanup_after_terminal_state(
    short_state_env: tuple[dict[str, str], Path],
) -> None:
    """Spawn crash path: cleanup only after terminal state (failed, killed, completed).

    FALSIFIABLE ORDERING TEST:
    This test WILL FAIL if cleanup happens before terminal state.

    For spawn with --rm:
    1. Cleanup ONLY happens for terminal states (completed, failed, killed)
    2. Non-terminal states (running) preserve state
    3. Terminal state must be written to state.json BEFORE cleanup

    Proof of ordering:
    - We capture state.json showing terminal state
    - Terminal state persists long enough to read
    - Then cleanup happens (state directory disappears)
    """
    env, state_dir = short_state_env

    result = _run_cli(
        env, "spawn", "test spawn crash path", "--model", "test", "--rm", "--json",
        "--completion-policy", "auto", "--lifecycle", "ephemeral",
    )
    payload = _parse_json_output(result)
    instance_id = str(payload.get("instance_id") or "")
    pid_value = payload.get("pid")
    assert instance_id, f"missing instance_id in spawn output: {payload!r}"
    assert isinstance(pid_value, int), (
        f"missing/invalid pid in spawn output: {payload!r}"
    )

    agent_dir = state_dir / "agents" / instance_id
    state_file = agent_dir / "state.json"

    # Poll state directory WHILE process is running to capture state transitions
    poll_count = 0
    saw_running_state = False
    saw_terminal_before_cleanup = False
    last_state = None

    # FALSIFIABLE: Verify state exists during execution
    while _pid_is_alive(pid_value) and poll_count < 20:
        if state_file.exists():
            try:
                state_data = json.loads(state_file.read_text())
                last_state = state_data.get("state")
                if last_state == "running":
                    saw_running_state = True
            except (json.JSONDecodeError, OSError):
                pass
        time.sleep(0.1)
        poll_count += 1

    # Wait for process to reach terminal state
    deadline = time.monotonic() + 45.0
    while time.monotonic() < deadline:
        if not _pid_is_alive(pid_value):
            break
        time.sleep(0.1)

    # FALSIFIABLE: Verify terminal state was recorded BEFORE cleanup
    # If cleanup happened before terminal state, state_file would not exist
    # or would not show terminal state
    if state_file.exists():
        state_data = json.loads(state_file.read_text())
        terminal_state = state_data.get("state")

        # Store that we saw terminal state before cleanup started
        if terminal_state in TERMINAL_STATES:
            saw_terminal_before_cleanup = True

    # Small delay to ensure cleanup has started
    time.sleep(0.5)

    # After cleanup, state directory should not exist
    assert not agent_dir.exists(), (
        f"Spawned process cleanup should run after terminal state, "
        f"but state dir still exists: {agent_dir}"
    )

    # FALSIFIABLE: We MUST have seen either running state or terminal state
    # If cleanup happened before state recording, we would see neither
    assert saw_running_state or saw_terminal_before_cleanup, (
        f"Must observe state transition before cleanup. "
        f"Last observed state: {last_state}, "
        f"saw_running: {saw_running_state}, "
        f"saw_terminal: {saw_terminal_before_cleanup}"
    )
