"""Regression tests for #3280 — file manager falls back to state.db for
external (Telegram/CLI) sessions instead of returning 404.

Covers:
  (a) WebUI session — existing behavior preserved (get_session path).
  (b) state.db-only session — fallback returns a workspace-bearing view.
  (c) Unknown session — KeyError still propagates so callers 404.
  (d) Static check: every file-manager handler in api/routes.py calls
      get_session_for_file_ops, not the raw get_session.
"""

from __future__ import annotations

import gc
import io
import json
import logging
import re
import sqlite3
import threading
import weakref
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import urlparse

import pytest


ROOT = Path(__file__).resolve().parents[1]
ROUTES_PY = ROOT / "api" / "routes.py"


FILE_HANDLERS = [
    "_handle_escape_authorize",
    "_handle_escape_list_dir",
    "_handle_escape_file_read",
    "_handle_escape_file_raw",
    "_handle_folder_download",
    "_handle_file_raw",
    "_handle_file_read",
    "_handle_file_delete",
    "_handle_file_save",
    "_handle_file_create",
    "_handle_file_rename",
    "_handle_create_dir",
    "_handle_file_reveal",
    "_handle_file_path",
    "_handle_file_open_vscode",
    "_handle_office_file_save",
    "_handle_file_move",
]


def _handler_body(src: str, name: str) -> str:
    start = src.index(f"def {name}(")
    # next top-level def or class
    m = re.search(r"\n(?:def |class )", src[start + 1 :])
    end = (start + 1 + m.start()) if m else len(src)
    return src[start:end]


def test_routes_file_handlers_use_fallback():
    src = ROUTES_PY.read_text(encoding="utf-8")
    assert "get_session_for_file_ops" in src, "fallback helper must be imported"
    missing = []
    for name in FILE_HANDLERS:
        body = _handler_body(src, name)
        # Must not call get_session(...) directly inside the handler.
        # (get_session_for_file_ops also contains "get_session(" as a substring,
        # so check word-boundary occurrences.)
        bare = re.findall(r"(?<!_)\bget_session\(", body)
        # Strip occurrences that are actually get_session_for_file_ops( — the
        # regex above already excludes underscore prefix, so any remaining
        # match is a raw get_session call.
        if bare:
            missing.append(name)
    assert not missing, f"raw get_session() still used in: {missing}"


# ---------------------------------------------------------------------------
# Functional tests against api.models.get_session_for_file_ops
# ---------------------------------------------------------------------------

pytestmark_models = pytest.mark.requires_agent_modules


def _make_state_db(path: Path, sid: str) -> None:
    conn = sqlite3.connect(str(path))
    conn.executescript(
        """
        CREATE TABLE sessions (
            id TEXT PRIMARY KEY,
            title TEXT,
            model TEXT,
            message_count INTEGER DEFAULT 0,
            started_at TEXT,
            source TEXT
        );
        CREATE TABLE messages (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            session_id TEXT,
            role TEXT,
            content TEXT,
            timestamp TEXT
        );
        """
    )
    conn.execute(
        "INSERT INTO sessions (id, title, model, message_count, started_at, source) "
        "VALUES (?, 'telegram session', 'gpt-x', 1, '2026-01-01T00:00:00Z', 'telegram')",
        (sid,),
    )
    conn.commit()
    conn.close()


@pytest.fixture
def models_module():
    return pytest.importorskip("api.models")


def test_get_session_for_file_ops_webui_passthrough(models_module, monkeypatch):
    """(a) WebUI session — delegates to get_session, no state.db consulted."""
    profiles_module = pytest.importorskip("api.profiles")
    sentinel = SimpleNamespace(profile=None)
    called = {"get_session": 0, "profile_match": 0, "state_db": 0}

    def fake_get_session(sid, metadata_only=False):
        called["get_session"] += 1
        return sentinel

    def fake_profiles_match(session_profile, active_profile):
        called["profile_match"] += 1
        assert session_profile is None
        assert active_profile == "default"
        return True

    def fake_has(_sid):
        called["state_db"] += 1
        return True

    monkeypatch.setattr(models_module, "get_session", fake_get_session)
    monkeypatch.setattr(models_module, "state_db_has_session", fake_has)
    monkeypatch.setattr(profiles_module, "_profiles_match", fake_profiles_match)
    monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default")
    result = models_module.get_session_for_file_ops("webui-sid")
    assert result is sentinel
    assert called == {"get_session": 1, "profile_match": 1, "state_db": 0}


def test_get_session_for_file_ops_recovers_missing_implicit_workspace(
    models_module, monkeypatch, tmp_path
):
    """A deleted sidecar workspace reloads fully before persisting its binding."""
    profiles_module = pytest.importorskip("api.profiles")
    workspace_module = pytest.importorskip("api.workspace")
    stale = tmp_path / "deleted-workspace"
    fallback = tmp_path / "fallback"
    fallback.mkdir()
    metadata_session = SimpleNamespace(
        session_id="stale-webui-sid",
        profile=None,
        workspace=str(stale),
        _loaded_metadata_only=True,
    )
    session_dir = tmp_path / "sessions"
    session_dir.mkdir()
    sidecar = session_dir / "stale-webui-sid.json"
    sidecar.write_text(
        json.dumps(
            {
                "session_id": "stale-webui-sid",
                "workspace": str(stale),
                "messages": [{"role": "user", "content": "preserve me"}],
                "future_field": {"preserve": True},
            }
        ),
        encoding="utf-8",
    )

    def get_session(_sid, metadata_only=False):
        assert metadata_only is True
        return metadata_session

    monkeypatch.setattr(models_module, "get_session", get_session)
    monkeypatch.setattr(models_module, "SESSION_DIR", session_dir)
    monkeypatch.setattr(models_module, "_write_session_index", lambda **_kwargs: None)
    monkeypatch.setattr(models_module, "get_last_workspace", lambda: str(fallback))
    monkeypatch.setattr(profiles_module, "_profiles_match", lambda *_args: True)
    monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default")
    monkeypatch.setattr(workspace_module, "_home_path", lambda: tmp_path)
    monkeypatch.setattr(workspace_module, "load_workspaces", lambda: [])

    recovered = models_module.get_session_for_file_ops(metadata_session.session_id)

    assert recovered is metadata_session
    assert recovered.session_id == metadata_session.session_id
    assert Path(recovered.workspace) == fallback.resolve()
    persisted = json.loads(sidecar.read_text(encoding="utf-8"))
    assert persisted["workspace"] == str(fallback.resolve())
    assert persisted["messages"] == [{"role": "user", "content": "preserve me"}]
    assert persisted["future_field"] == {"preserve": True}


def test_get_session_for_file_ops_recovery_save_failure_fails_closed(
    models_module, monkeypatch, tmp_path
):
    profiles_module = pytest.importorskip("api.profiles")
    workspace_module = pytest.importorskip("api.workspace")
    stale = tmp_path / "deleted-workspace"
    fallback = tmp_path / "fallback"
    fallback.mkdir()
    metadata_session = SimpleNamespace(
        session_id="stale-save-failure",
        profile=None,
        workspace=str(stale),
        _loaded_metadata_only=True,
    )
    session_dir = tmp_path / "sessions"
    session_dir.mkdir()
    sidecar = session_dir / f"{metadata_session.session_id}.json"
    sidecar.write_text(
        json.dumps(
            {
                "session_id": metadata_session.session_id,
                "workspace": str(stale),
                "messages": [{"role": "user", "content": "preserve me"}],
            }
        ),
        encoding="utf-8",
    )

    monkeypatch.setattr(
        models_module,
        "get_session",
        lambda _sid, metadata_only=False: metadata_session,
    )
    monkeypatch.setattr(models_module, "SESSION_DIR", session_dir)
    monkeypatch.setattr(
        models_module,
        "_safe_replace",
        lambda *_args: (_ for _ in ()).throw(OSError("disk full")),
    )
    monkeypatch.setattr(models_module, "get_last_workspace", lambda: str(fallback))
    monkeypatch.setattr(profiles_module, "_profiles_match", lambda *_args: True)
    monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default")
    monkeypatch.setattr(workspace_module, "_home_path", lambda: tmp_path)
    monkeypatch.setattr(workspace_module, "load_workspaces", lambda: [])

    with pytest.raises(models_module.WorkspaceBindingPersistenceError):
        models_module.get_session_for_file_ops(metadata_session.session_id)

    persisted = json.loads(sidecar.read_text(encoding="utf-8"))
    assert metadata_session.workspace == str(stale)
    assert persisted["workspace"] == str(stale)
    assert persisted["messages"] == [{"role": "user", "content": "preserve me"}]


def test_recovered_workspace_compare_rejects_a_stale_concurrent_binding(
    models_module, monkeypatch, tmp_path
):
    stale = tmp_path / "deleted-workspace"
    fallback_a = tmp_path / "fallback-a"
    fallback_b = tmp_path / "fallback-b"
    fallback_a.mkdir()
    fallback_b.mkdir()
    metadata_session = SimpleNamespace(
        session_id="stale-concurrent-binding",
        workspace=str(stale),
        _loaded_metadata_only=True,
    )
    session_dir = tmp_path / "sessions"
    session_dir.mkdir()
    sidecar = session_dir / f"{metadata_session.session_id}.json"
    sidecar.write_text(
        json.dumps(
            {
                "session_id": metadata_session.session_id,
                "workspace": str(fallback_a.resolve()),
                "messages": [{"role": "user", "content": "preserve me"}],
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr(models_module, "SESSION_DIR", session_dir)

    with pytest.raises(
        models_module.WorkspaceBindingPersistenceError,
        match="session workspace changed",
    ):
        models_module.persist_recovered_workspace_binding(
            metadata_session, fallback_b
        )

    persisted = json.loads(sidecar.read_text(encoding="utf-8"))
    assert metadata_session.workspace == str(stale)
    assert persisted["workspace"] == str(fallback_a.resolve())
    assert persisted["messages"] == [{"role": "user", "content": "preserve me"}]


def test_recovery_cas_uses_the_workspace_seen_when_recovery_was_decided(
    models_module, monkeypatch, tmp_path
):
    stale = tmp_path / "deleted-workspace"
    fallback_a = tmp_path / "fallback-a"
    explicit_b = tmp_path / "explicit-b"
    fallback_a.mkdir()
    explicit_b.mkdir()
    session_dir = tmp_path / "sessions"
    session_dir.mkdir()
    session = SimpleNamespace(
        session_id="explicit-switch-wins",
        workspace=str(explicit_b.resolve()),
        _loaded_metadata_only=True,
    )
    sidecar = session_dir / f"{session.session_id}.json"
    sidecar.write_text(
        json.dumps(
            {
                "session_id": session.session_id,
                "workspace": str(explicit_b.resolve()),
                "messages": [{"role": "user", "content": "preserve me"}],
            }
        ),
        encoding="utf-8",
    )
    monkeypatch.setattr(models_module, "SESSION_DIR", session_dir)

    with pytest.raises(
        models_module.WorkspaceBindingPersistenceError,
        match="session workspace changed",
    ):
        models_module.persist_recovered_workspace_binding(
            session,
            fallback_a,
            expected_workspace=str(stale),
        )

    persisted = json.loads(sidecar.read_text(encoding="utf-8"))
    assert session.workspace == str(explicit_b.resolve())
    assert persisted["workspace"] == str(explicit_b.resolve())
    assert persisted["messages"] == [{"role": "user", "content": "preserve me"}]


def test_recovery_never_recreates_a_missing_session_sidecar(
    models_module, monkeypatch, tmp_path
):
    session_dir = tmp_path / "sessions"
    session_dir.mkdir()
    stale = tmp_path / "deleted-workspace"
    fallback = tmp_path / "fallback"
    fallback.mkdir()
    saves = {"count": 0}
    session = SimpleNamespace(
        session_id="deleted-before-recovery",
        workspace=str(stale),
        _loaded_metadata_only=False,
        save=lambda **_kwargs: saves.__setitem__("count", saves["count"] + 1),
    )
    monkeypatch.setattr(models_module, "SESSION_DIR", session_dir)

    with pytest.raises(
        models_module.WorkspaceBindingPersistenceError,
        match="session sidecar is missing",
    ):
        models_module.persist_recovered_workspace_binding(
            session,
            fallback,
            expected_workspace=str(stale),
        )

    assert saves["count"] == 0
    assert session.workspace == str(stale)
    assert not (session_dir / f"{session.session_id}.json").exists()


class _DeleteJSONHandler:
    def __init__(self, body: dict):
        body_bytes = json.dumps(body).encode()
        self.status = None
        self.rfile = BytesIO(body_bytes)
        self.wfile = BytesIO()
        self.headers = {"Content-Length": str(len(body_bytes))}

    def send_response(self, status):
        self.status = status

    def send_header(self, _key, _value):
        pass

    def end_headers(self):
        pass

    def _safe_webui_print(self, *_args, **_kwargs):
        pass


def test_delete_serializes_with_workspace_recovery_and_sidecar_stays_deleted(
    models_module, monkeypatch, tmp_path
):
    routes_module = pytest.importorskip("api.routes")
    config_module = pytest.importorskip("api.config")
    upload_module = pytest.importorskip("api.upload")
    turn_journal_module = pytest.importorskip("api.turn_journal")
    run_journal_module = pytest.importorskip("api.run_journal")
    background_module = pytest.importorskip("api.background_process")
    terminal_module = pytest.importorskip("api.terminal")
    session_dir = tmp_path / "sessions"
    session_dir.mkdir()
    stale = tmp_path / "deleted-workspace"
    fallback = tmp_path / "fallback"
    fallback.mkdir()
    sid = "delete-recovery-race"
    session = SimpleNamespace(
        session_id=sid,
        workspace=str(stale),
        profile=None,
        _loaded_metadata_only=True,
    )
    sidecar = session_dir / f"{sid}.json"
    sidecar.write_text(
        json.dumps(
            {
                "session_id": sid,
                "workspace": str(stale),
                "messages": [{"role": "user", "content": "preserve me"}],
            }
        ),
        encoding="utf-8",
    )
    replace_entered = threading.Event()
    allow_replace = threading.Event()
    original_replace = models_module._safe_replace

    def paused_replace(source, target):
        replace_entered.set()
        assert allow_replace.wait(timeout=5)
        original_replace(source, target)

    monkeypatch.setattr(models_module, "SESSION_DIR", session_dir)
    monkeypatch.setattr(routes_module, "SESSION_DIR", session_dir)
    monkeypatch.setattr(models_module, "_safe_replace", paused_replace)
    monkeypatch.setattr(models_module, "_write_session_index", lambda **_kwargs: None)
    monkeypatch.setattr(routes_module, "_check_csrf", lambda _handler: True)
    monkeypatch.setattr(routes_module, "get_session", lambda *_args, **_kwargs: session)
    monkeypatch.setattr(routes_module, "_lookup_cli_session_metadata", lambda _sid: {})
    monkeypatch.setattr(routes_module, "_session_is_subagent_view_only", lambda _sid: False)
    monkeypatch.setattr(routes_module, "_is_messaging_session_id", lambda _sid: False)
    monkeypatch.setattr(
        routes_module, "_worktree_retained_payload_for_session_id", lambda _sid: {}
    )
    monkeypatch.setattr(routes_module, "prune_session_from_index", lambda _sid: None)
    monkeypatch.setattr(
        routes_module, "_record_webui_deleted_session_tombstone", lambda _sid: None
    )
    monkeypatch.setattr(
        routes_module, "_publish_session_list_changed", lambda *_args, **_kwargs: None
    )
    monkeypatch.setattr(config_module, "_evict_session_agent", lambda _sid: None)
    monkeypatch.setattr(models_module, "delete_cli_session", lambda _sid: True)
    monkeypatch.setattr(
        upload_module,
        "_session_attachment_dir",
        lambda _sid: tmp_path / "attachments" / _sid,
    )
    monkeypatch.setattr(turn_journal_module, "delete_turn_journal", lambda _sid: None)
    monkeypatch.setattr(run_journal_module, "delete_run_journal", lambda _sid: None)
    monkeypatch.setattr(
        background_module, "forget_bg_task_completion_dedup", lambda _sid: None
    )
    monkeypatch.setattr(terminal_module, "close_terminal", lambda _sid: None)

    recovery_errors = []

    def recover():
        try:
            models_module.persist_recovered_workspace_binding(
                session,
                fallback,
            )
        except Exception as exc:
            recovery_errors.append(exc)

    recovery_thread = threading.Thread(target=recover)
    recovery_thread.start()
    assert replace_entered.wait(timeout=5)

    delete_result = {}

    def delete():
        handler = _DeleteJSONHandler({"session_id": sid})
        routes_module.handle_post(
            handler, SimpleNamespace(path="/api/session/delete")
        )
        delete_result["status"] = handler.status

    delete_thread = threading.Thread(target=delete)
    delete_thread.start()
    delete_thread.join(timeout=0.2)
    assert delete_thread.is_alive(), "delete must wait for the recovery mutation lock"

    allow_replace.set()
    recovery_thread.join(timeout=5)
    delete_thread.join(timeout=5)

    assert not recovery_errors
    assert delete_result["status"] == 200
    assert not sidecar.exists()


def test_delete_returns_503_without_mutation_when_session_lock_is_busy(
    models_module, monkeypatch, tmp_path
):
    routes_module = pytest.importorskip("api.routes")
    sid = "delete-busy-session"
    session_dir = tmp_path / "sessions"
    session_dir.mkdir()
    sidecar = session_dir / f"{sid}.json"
    sidecar.write_text(
        json.dumps({"session_id": sid, "messages": [{"role": "user", "content": "keep"}]}),
        encoding="utf-8",
    )
    cached_session = SimpleNamespace(session_id=sid, profile=None)
    observed = {"acquire_timeouts": [], "released": 0, "mutations": []}

    class ContendedLock:
        def acquire(self, timeout=None):
            observed["acquire_timeouts"].append(timeout)
            return timeout is None

        def release(self):
            observed["released"] += 1

        def __enter__(self):
            assert self.acquire()
            return self

        def __exit__(self, *_args):
            self.release()

    monkeypatch.setattr(routes_module, "SESSION_DIR", session_dir)
    monkeypatch.setattr(routes_module, "SESSIONS", {sid: cached_session})
    monkeypatch.setattr(routes_module, "_check_csrf", lambda _handler: True)
    monkeypatch.setattr(
        routes_module,
        "get_session",
        lambda *_args, **_kwargs: cached_session,
    )
    monkeypatch.setattr(routes_module, "_lookup_cli_session_metadata", lambda _sid: {})
    monkeypatch.setattr(
        routes_module, "_session_is_subagent_view_only", lambda _sid: False
    )
    monkeypatch.setattr(routes_module, "_is_messaging_session_id", lambda _sid: False)
    monkeypatch.setattr(
        routes_module, "_worktree_retained_payload_for_session_id", lambda _sid: {}
    )
    monkeypatch.setattr(
        routes_module, "_get_session_agent_lock", lambda _sid: ContendedLock()
    )
    monkeypatch.setattr(
        routes_module,
        "prune_session_from_index",
        lambda _sid: observed["mutations"].append("index"),
    )
    monkeypatch.setattr(
        routes_module,
        "_record_webui_deleted_session_tombstone",
        lambda _sid: observed["mutations"].append("tombstone"),
    )
    monkeypatch.setattr(
        routes_module,
        "_publish_session_list_changed",
        lambda *_args, **_kwargs: observed["mutations"].append("publish"),
    )
    monkeypatch.setattr(
        models_module,
        "delete_cli_session",
        lambda _sid: observed["mutations"].append("state-db"),
    )

    handler = _DeleteJSONHandler({"session_id": sid})
    routes_module.handle_post(handler, SimpleNamespace(path="/api/session/delete"))

    payload = json.loads(handler.wfile.getvalue())
    assert handler.status == 503
    assert payload == {"error": "Session busy, try again"}
    assert observed == {"acquire_timeouts": [5], "released": 0, "mutations": []}
    assert sidecar.exists()
    assert routes_module.SESSIONS[sid] is cached_session


def test_session_lock_registry_reuses_live_lock_and_reclaims_unused_entry():
    config_module = pytest.importorskip("api.config")
    sid = "weak-session-lock"
    with config_module.SESSION_AGENT_LOCKS_LOCK:
        config_module.SESSION_AGENT_LOCKS.pop(sid, None)

    first = config_module._get_session_agent_lock(sid)
    first_ref = weakref.ref(first)
    assert config_module._get_session_agent_lock(sid) is first

    del first
    gc.collect()

    assert first_ref() is None
    with config_module.SESSION_AGENT_LOCKS_LOCK:
        assert sid not in config_module.SESSION_AGENT_LOCKS


def test_compression_lock_alias_keeps_old_and_new_ids_on_one_live_lock():
    config_module = pytest.importorskip("api.config")
    old_sid = "compression-old-lock"
    new_sid = "compression-new-lock"
    with config_module.SESSION_AGENT_LOCKS_LOCK:
        config_module.SESSION_AGENT_LOCKS.pop(old_sid, None)
        config_module.SESSION_AGENT_LOCKS.pop(new_sid, None)

    compression_lock = config_module._get_session_agent_lock(old_sid)
    waiter_reference = compression_lock
    config_module._alias_session_agent_lock(
        old_sid,
        new_sid,
        compression_lock,
    )

    assert config_module._get_session_agent_lock(old_sid) is waiter_reference
    assert config_module._get_session_agent_lock(new_sid) is waiter_reference
    streaming_source = (Path(__file__).parents[1] / "api" / "streaming.py").read_text(
        encoding="utf-8"
    )
    assert "_alias_session_agent_lock(old_sid, new_sid, _agent_lock)" in streaming_source


def test_get_session_for_file_ops_does_not_fallback_existing_untrusted_workspace(
    models_module, monkeypatch, tmp_path
):
    """Recovery must not replace a non-missing trust rejection with a fallback."""
    profiles_module = pytest.importorskip("api.profiles")
    workspace_module = pytest.importorskip("api.workspace")
    home = tmp_path / "home"
    fallback = home / "fallback"
    fallback.mkdir(parents=True)
    outside = tmp_path / "outside"
    outside.mkdir()
    session = SimpleNamespace(
        session_id="untrusted-webui-sid",
        profile=None,
        workspace=str(outside),
    )

    monkeypatch.setattr(models_module, "get_session", lambda *args, **kwargs: session)
    monkeypatch.setattr(models_module, "get_last_workspace", lambda: str(fallback))
    monkeypatch.setattr(profiles_module, "_profiles_match", lambda *_args: True)
    monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default")
    monkeypatch.setattr(workspace_module, "_home_path", lambda: home)
    monkeypatch.setattr(workspace_module, "load_workspaces", lambda: [])
    monkeypatch.setattr(workspace_module, "_BOOT_DEFAULT_WORKSPACE", fallback)

    result = models_module.get_session_for_file_ops(session.session_id)

    assert result is session
    assert result.workspace == str(outside)


@pytest.mark.parametrize(
    "terminal_cfg",
    [
        pytest.param(
            {"backend": "ssh", "cwd": "/Users/joeyshiue"},
            id="cwd-absolute",
        ),
        pytest.param({"backend": "ssh"}, id="cwd-omitted"),
        pytest.param({"backend": "ssh", "cwd": ""}, id="cwd-empty"),
        pytest.param({"backend": "ssh", "cwd": "."}, id="cwd-dot"),
    ],
)
def test_get_session_for_file_ops_does_not_recover_remote_trust_rejection(
    models_module, monkeypatch, tmp_path, terminal_cfg
):
    """A local miss cannot prove that an out-of-scope remote path was deleted."""
    config_module = pytest.importorskip("api.config")
    profiles_module = pytest.importorskip("api.profiles")
    workspace_module = pytest.importorskip("api.workspace")
    candidate = "/Users/other/projects/demo"
    fallback_path = tmp_path / "fallback"
    fallback_path.mkdir()
    session = SimpleNamespace(
        session_id="remote-untrusted-webui-sid",
        profile=None,
        workspace=candidate,
    )
    fallback_calls = {"count": 0}

    monkeypatch.setattr(models_module, "get_session", lambda *args, **kwargs: session)
    monkeypatch.setattr(profiles_module, "_profiles_match", lambda *_args: True)
    monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default")
    monkeypatch.setattr(
        config_module,
        "get_config",
        lambda: {"terminal": terminal_cfg},
    )
    monkeypatch.setattr(workspace_module, "_home_path", lambda: tmp_path)

    def fallback():
        fallback_calls["count"] += 1
        return fallback_path

    monkeypatch.setattr(models_module, "get_last_workspace", fallback)

    result = models_module.get_session_for_file_ops(session.session_id)

    assert result is session
    assert result.workspace == candidate
    assert fallback_calls["count"] == 0


def test_get_session_for_file_ops_rejects_foreign_profile(
    models_module, monkeypatch, tmp_path, caplog
):
    """WebUI sessions must belong to the active profile before file access."""
    profiles_module = pytest.importorskip("api.profiles")
    foreign_session = SimpleNamespace(profile="research", workspace=str(tmp_path))
    called = {"get_session": 0, "profile_match": 0, "state_db": 0}

    def fake_get_session(sid, metadata_only=False):
        called["get_session"] += 1
        return foreign_session

    def fake_profiles_match(session_profile, active_profile):
        called["profile_match"] += 1
        assert session_profile == "research"
        assert active_profile == "default"
        return False

    def fake_has(_sid):
        called["state_db"] += 1
        return True

    monkeypatch.setattr(models_module, "get_session", fake_get_session)
    monkeypatch.setattr(models_module, "state_db_has_session", fake_has)
    monkeypatch.setattr(profiles_module, "_profiles_match", fake_profiles_match)
    monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default")

    with caplog.at_level(logging.DEBUG, logger=models_module.logger.name):
        with pytest.raises(KeyError):
            models_module.get_session_for_file_ops("foreign-webui-sid")
    # A found-but-foreign WebUI sidecar is an authorization failure, not a
    # missing-session condition that can fall through to the state.db fallback.
    assert called == {"get_session": 1, "profile_match": 1, "state_db": 0}
    assert "Rejected file-manager session for foreign profile" in caplog.text
    assert "foreign-webui-sid" in caplog.text
    assert "session_profile='research'" in caplog.text
    assert "active_profile='default'" in caplog.text


def test_file_read_rejects_foreign_profile_session(
    models_module, monkeypatch, tmp_path
):
    """A default-profile file route cannot read a named-profile workspace."""
    profiles_module = pytest.importorskip("api.profiles")
    routes_module = pytest.importorskip("api.routes")
    workspace = tmp_path / "named-workspace"
    workspace.mkdir()
    (workspace / "marker.txt").write_text("foreign profile marker")
    session = models_module.Session(
        session_id="foreign-profile-file-read",
        workspace=str(workspace),
        profile="research",
    )
    models_module.SESSIONS[session.session_id] = session

    class Handler:
        command = "GET"
        headers = {}

        def __init__(self):
            self.status = None
            self.headers_sent = []
            self.wfile = io.BytesIO()

        def send_response(self, code):
            self.status = code

        def send_header(self, key, value):
            self.headers_sent.append((key, value))

        def end_headers(self):
            pass

    monkeypatch.setattr(profiles_module, "get_active_profile_name", lambda: "default")
    try:
        handler = Handler()
        routes_module._handle_file_read(
            handler,
            urlparse(
                "/api/file?session_id=foreign-profile-file-read&path=marker.txt"
            ),
        )
        assert handler.status == 404
        assert b"foreign profile marker" not in handler.wfile.getvalue()
    finally:
        models_module.SESSIONS.pop(session.session_id, None)


def test_get_session_for_file_ops_state_db_fallback(
    models_module, monkeypatch, tmp_path
):
    """(b) state.db-only session — returns view with workspace populated."""
    db = tmp_path / "state.db"
    _make_state_db(db, "tg-123")
    workspace = tmp_path / "ws"
    workspace.mkdir()
    (workspace / "hello.txt").write_text("hi from telegram session")

    def raise_key(sid, metadata_only=False):
        raise KeyError(sid)

    monkeypatch.setattr(models_module, "get_session", raise_key)
    monkeypatch.setattr(models_module, "_active_state_db_path", lambda: db)
    monkeypatch.setattr(
        models_module, "get_last_workspace", lambda: str(workspace)
    )

    view = models_module.get_session_for_file_ops("tg-123")
    assert view.session_id == "tg-123"
    assert Path(view.workspace) == workspace
    # The workspace is real and readable — file-manager handlers will
    # successfully serve files relative to it instead of returning 404.
    assert (Path(view.workspace) / "hello.txt").read_text() == "hi from telegram session"


def test_get_session_for_file_ops_unknown_session_raises(
    models_module, monkeypatch, tmp_path
):
    """(c) Unknown session — KeyError propagates so callers still 404."""
    db = tmp_path / "state.db"
    _make_state_db(db, "tg-123")

    def raise_key(sid, metadata_only=False):
        raise KeyError(sid)

    monkeypatch.setattr(models_module, "get_session", raise_key)
    monkeypatch.setattr(models_module, "_active_state_db_path", lambda: db)
    monkeypatch.setattr(models_module, "get_last_workspace", lambda: str(tmp_path))

    with pytest.raises(KeyError):
        models_module.get_session_for_file_ops("does-not-exist")


def test_state_db_has_session_missing_db(models_module, monkeypatch, tmp_path):
    monkeypatch.setattr(
        models_module, "_active_state_db_path", lambda: tmp_path / "missing.db"
    )
    assert models_module.state_db_has_session("any") is False


def test_state_db_has_session_present(models_module, monkeypatch, tmp_path):
    db = tmp_path / "state.db"
    _make_state_db(db, "cli-9")
    monkeypatch.setattr(models_module, "_active_state_db_path", lambda: db)
    assert models_module.state_db_has_session("cli-9") is True
    assert models_module.state_db_has_session("nope") is False
