"""Tests for the bash run_in_background path (local fallback only).

Covers:
- BashBgStore: record_start / mark_completed / mark_auto_resumed race semantics.
- _run_bash_background local fallback: spawns a detached process with file
  redirects, registers it via the bg_register callback, output/pid/exit_code
  files materialize.
- _run_bash_status / _run_bash_kill: route through bg_status_query / bg_kill_request
  callbacks and format their result.

Remote (jupyter_runtime) path is intentionally not exercised here — that
requires a live wsh account and is covered by the e2e checklist in the plan.
"""

from __future__ import annotations

import time
from pathlib import Path

import pytest

from src.agent_tool_core import ToolExecutionContext
from src.agent_tools import (
    _run_bash_background,
    _run_bash_kill,
    _run_bash_status,
)
from src.agent_types import AgentPermissions
from src.bash_bg_store import BashBgStore, BgTaskSpec, BgTaskStatus


def _make_context(
    tmp_path: Path,
    *,
    bg_register=None,
    bg_status_query=None,
    bg_kill_request=None,
) -> ToolExecutionContext:
    return ToolExecutionContext(
        root=tmp_path,
        command_timeout_seconds=10.0,
        max_output_chars=4096,
        permissions=AgentPermissions(allow_shell_commands=True),
        scratchpad_directory=tmp_path,
        bg_register=bg_register,
        bg_status_query=bg_status_query,
        bg_kill_request=bg_kill_request,
        account_id='acct-test',
        session_id='sess-test',
        run_id='run-test',
    )


# ----- BashBgStore --------------------------------------------------------


def test_store_record_and_complete(tmp_path: Path) -> None:
    store = BashBgStore(tmp_path / 'bg.db')
    spec = BgTaskSpec(
        task_id='bg_abc',
        account_key='acct',
        session_id='sess',
        run_id='run',
        pid=12345,
        task_dir=str(tmp_path / 'bg_abc'),
        output_path=str(tmp_path / 'bg_abc' / 'output'),
        pid_path=str(tmp_path / 'bg_abc' / 'pid'),
        exit_code_path=str(tmp_path / 'bg_abc' / 'exit_code'),
        command='echo hi',
        started_at=time.time(),
        wait_for_completion=True,
    )
    store.record_start(spec)
    row = store.get('bg_abc')
    assert row is not None
    assert row['status'] == 'running'
    assert row['exit_code'] is None

    store.mark_completed('bg_abc', exit_code=0)
    row = store.get('bg_abc')
    assert row['status'] == 'completed'
    assert row['exit_code'] == 0
    assert row['finished_at'] is not None


def test_store_mark_auto_resumed_is_race_safe(tmp_path: Path) -> None:
    store = BashBgStore(tmp_path / 'bg.db')
    spec = BgTaskSpec(
        task_id='bg_race',
        account_key='acct',
        session_id='sess',
        run_id='run',
        pid=1,
        task_dir='/tmp/x',
        output_path='/tmp/x/output',
        pid_path='/tmp/x/pid',
        exit_code_path='/tmp/x/exit_code',
        command='true',
        started_at=time.time(),
        wait_for_completion=True,
    )
    store.record_start(spec)
    store.mark_completed('bg_race', exit_code=0)

    # First caller wins; subsequent callers always see False.
    assert store.mark_auto_resumed('bg_race') is True
    assert store.mark_auto_resumed('bg_race') is False
    assert store.mark_auto_resumed('bg_race') is False


def test_store_mark_killed_only_running(tmp_path: Path) -> None:
    store = BashBgStore(tmp_path / 'bg.db')
    spec = BgTaskSpec(
        task_id='bg_kill',
        account_key='acct',
        session_id='sess',
        run_id='run',
        pid=2,
        task_dir='/tmp/y',
        output_path='/tmp/y/output',
        pid_path='/tmp/y/pid',
        exit_code_path='/tmp/y/exit_code',
        command='sleep 100',
        started_at=time.time(),
        wait_for_completion=False,
    )
    store.record_start(spec)
    store.mark_killed('bg_kill')
    row = store.get('bg_kill')
    assert row['status'] == 'cancelled'

    # Re-killing a non-running task is a no-op (status stays cancelled).
    store.mark_killed('bg_kill')
    row = store.get('bg_kill')
    assert row['status'] == 'cancelled'


# ----- _run_bash_background local fallback --------------------------------


def test_run_bash_background_local_spawns_detached(tmp_path: Path) -> None:
    captured: list[BgTaskSpec] = []

    ctx = _make_context(tmp_path, bg_register=captured.append)
    content, metadata = _run_bash_background(
        {
            'command': 'echo hello-from-bg',
            'wait_for_completion': True,
        },
        ctx,
    )

    assert metadata['action'] == 'bash_bg'
    task_id = metadata['task_id']
    assert task_id.startswith('bg_')
    assert metadata['wait_for_completion'] is True
    assert 'pid=' in content

    # bg_register received exactly one spec, with the agent's session/account.
    assert len(captured) == 1
    spec = captured[0]
    assert spec.task_id == task_id
    assert spec.account_key == 'acct-test'
    assert spec.session_id == 'sess-test'
    assert spec.run_id == 'run-test'
    assert spec.command == 'echo hello-from-bg'
    assert spec.wait_for_completion is True

    # Wait for the detached subprocess to finish — bounded by exit_code file.
    deadline = time.monotonic() + 5.0
    exit_code_path = Path(spec.exit_code_path)
    while time.monotonic() < deadline and not exit_code_path.exists():
        time.sleep(0.05)
    assert exit_code_path.exists(), 'exit_code file did not appear in 5s'
    assert exit_code_path.read_text(encoding='utf-8').strip() == '0'

    output_text = Path(spec.output_path).read_text(encoding='utf-8')
    assert 'hello-from-bg' in output_text


def test_run_bash_background_failed_command_records_nonzero_exit(
    tmp_path: Path,
) -> None:
    captured: list[BgTaskSpec] = []
    ctx = _make_context(tmp_path, bg_register=captured.append)

    _content, metadata = _run_bash_background(
        {'command': 'exit 7'},
        ctx,
    )
    spec = captured[0]
    assert metadata['action'] == 'bash_bg'

    deadline = time.monotonic() + 5.0
    exit_code_path = Path(spec.exit_code_path)
    while time.monotonic() < deadline and not exit_code_path.exists():
        time.sleep(0.05)
    assert exit_code_path.exists()
    assert exit_code_path.read_text(encoding='utf-8').strip() == '7'


def test_run_bash_background_requires_shell_permission(tmp_path: Path) -> None:
    from src.agent_tool_core import ToolPermissionError

    ctx = ToolExecutionContext(
        root=tmp_path,
        command_timeout_seconds=10.0,
        max_output_chars=4096,
        permissions=AgentPermissions(allow_shell_commands=False),
        scratchpad_directory=tmp_path,
    )
    with pytest.raises(ToolPermissionError):
        _run_bash_background({'command': 'echo nope'}, ctx)


# ----- bash_status / bash_kill via callbacks ------------------------------


def test_bash_status_routes_through_callback(tmp_path: Path) -> None:
    fake_status = BgTaskStatus(
        task_id='bg_xyz',
        status='completed',
        pid=999,
        started_at=time.time() - 5.0,
        finished_at=time.time(),
        exit_code=0,
        output_path='/tmp/x/output',
        output_preview='all good\n',
        auto_resumed=False,
    )

    queries: list[str] = []

    def query(task_id: str) -> BgTaskStatus | None:
        queries.append(task_id)
        return fake_status

    ctx = _make_context(tmp_path, bg_status_query=query)
    content, metadata = _run_bash_status({'task_id': 'bg_xyz'}, ctx)

    assert queries == ['bg_xyz']
    assert metadata['action'] == 'bash_status'
    assert metadata['status'] == 'completed'
    assert metadata['exit_code'] == 0
    assert 'all good' in content
    assert 'task_id=bg_xyz' in content


def test_bash_status_unknown_task_raises(tmp_path: Path) -> None:
    from src.agent_tool_core import ToolExecutionError

    def query(_task_id: str):
        return None

    ctx = _make_context(tmp_path, bg_status_query=query)
    with pytest.raises(ToolExecutionError):
        _run_bash_status({'task_id': 'bg_missing'}, ctx)


def test_bash_status_without_callback_raises(tmp_path: Path) -> None:
    from src.agent_tool_core import ToolExecutionError

    ctx = _make_context(tmp_path)
    with pytest.raises(ToolExecutionError):
        _run_bash_status({'task_id': 'bg_anything'}, ctx)


def test_bash_kill_routes_through_callback(tmp_path: Path) -> None:
    killed: list[str] = []

    def kill(task_id: str) -> bool:
        killed.append(task_id)
        return True

    ctx = _make_context(tmp_path, bg_kill_request=kill)
    content, metadata = _run_bash_kill({'task_id': 'bg_kk'}, ctx)
    assert killed == ['bg_kk']
    assert metadata['killed'] is True
    assert 'bg_kk' in content


def test_bash_kill_failure_reports(tmp_path: Path) -> None:
    ctx = _make_context(tmp_path, bg_kill_request=lambda _t: False)
    content, metadata = _run_bash_kill({'task_id': 'bg_dead'}, ctx)
    assert metadata['killed'] is False
    assert '未能终止' in content


# ----- end-to-end: spawn + register + sync via real BashBgStore -----------


def test_local_bg_lifecycle_with_real_store(tmp_path: Path) -> None:
    """Plug a real BashBgStore into bg_register and check the row appears."""
    store = BashBgStore(tmp_path / 'bg.db')

    def register(spec: BgTaskSpec) -> None:
        store.record_start(spec)

    ctx = _make_context(tmp_path, bg_register=register)
    _content, metadata = _run_bash_background(
        {'command': 'echo lifecycle && exit 0'}, ctx,
    )
    task_id = metadata['task_id']
    row = store.get(task_id)
    assert row is not None
    assert row['status'] == 'running'

    # Wait for child to write exit_code (more reliable than kill -0, since
    # the unreaped child becomes a zombie and kill -0 still reports it alive).
    deadline = time.monotonic() + 5.0
    exit_code_path = Path(row['exit_code_path'])
    while time.monotonic() < deadline and not exit_code_path.exists():
        time.sleep(0.05)
    if not exit_code_path.exists():
        pytest.fail('background process did not exit within 5 seconds')

    # Simulate manager finalize step.
    exit_code = int(
        exit_code_path.read_text(encoding='utf-8').strip()
    )
    store.mark_completed(task_id, exit_code=exit_code)
    row = store.get(task_id)
    assert row['status'] == 'completed'
    assert row['exit_code'] == 0
