import os
import sqlite3
import subprocess
import sys
import threading
import time

import pytest

from _chroma_palace_helper import make_minimal_chroma_sqlite

from mempalace import daemon
from mempalace import service

_LOCK_CONTENDER = """
from mempalace.palace import MineAlreadyRunning, mine_palace_lock
import sys
try:
    with mine_palace_lock(sys.argv[1]):
        raise SystemExit(0)
except MineAlreadyRunning:
    raise SystemExit(23)
"""

# POSIX file-mode bits (0600/0700) are not representable on Windows: os.chmod
# can only toggle the read-only attribute, so a "private" file still reports
# 0o666. The daemon relies on the user-profile directory ACLs for privacy
# there, so the owner-only assertions only make sense on POSIX.
_posix_only_perms = pytest.mark.skipif(
    os.name == "nt",
    reason="POSIX 0600/0700 file-mode bits are not representable on Windows (ACL-based privacy)",
)

# Env keys run_server mutates from its background thread, plus umask. If a
# lifecycle test times out before the server comes up, run_server's finally
# never runs and those mutations leak into the rest of the suite — every later
# test that reads MempalaceConfig().palace_path sees a stale deleted tmp path and
# fails (the 60+ test cascade seen on slow CI runners). The fixtures below force a
# clean baseline around every daemon test so a leaked thread can't poison the
# process for tests/test_mcp_server.py and friends (which have no such guard).
_LEAK_ENV_KEYS = ("MEMPALACE_PALACE_PATH", "MEMPALACE_BACKEND", "MEMPALACE_BACKEND_EXPLICIT")


@pytest.fixture(scope="module")
def _clean_env_snapshot():
    """Capture the true pre-suite values once, before any daemon test runs."""
    return {key: os.environ.get(key) for key in _LEAK_ENV_KEYS}


@pytest.fixture(autouse=True)
def _isolate_process_global_state(_clean_env_snapshot):
    """Restore the process-global env + umask to the pre-suite baseline after every
    daemon test, even if a leaked run_server thread is still holding them mutated.
    """
    prev_umask = os.umask(0o022)
    os.umask(prev_umask)  # read current umask without changing it
    yield
    for key, value in _clean_env_snapshot.items():
        if value is None:
            os.environ.pop(key, None)
        else:
            os.environ[key] = value
    os.umask(prev_umask)


def _raise_not_ready(*a, **kw):
    """Stand-in for DaemonClient when the spawned daemon must never come up."""
    raise daemon.DaemonError("not ready")


def test_prune_terminal_drops_old_terminal_jobs_keeps_active(tmp_path, monkeypatch):
    """Terminal jobs older than the retention window are pruned; queued/running
    and fresh terminal jobs are untouched. Bounded queue growth for the DB that
    holds verbatim payloads."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))

    old_term = store.enqueue("mine", {"source": "old"})
    store.finish(old_term.id, state="succeeded", result={"success": True})
    fresh_term = store.enqueue("mine", {"source": "fresh"})
    store.finish(fresh_term.id, state="succeeded", result={"success": True})
    queued = store.enqueue("mine", {"source": "queued"})

    from datetime import datetime, timedelta, timezone

    cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
    with store._lock, store._connect() as conn:
        conn.execute(
            "UPDATE jobs SET finished_at = ? WHERE id = ?",
            (cutoff, old_term.id),
        )

    pruned = store.prune_terminal(older_than_days=7)
    assert pruned == 1
    # The old terminal job is gone; the fresh terminal and queued jobs survive.
    with pytest.raises(daemon.DaemonError):
        store.get(old_term.id)
    assert store.get(fresh_term.id).state == "succeeded"
    assert store.get(queued.id).state == "queued"


def test_queue_dedupes_and_recovers_running_jobs(tmp_path, monkeypatch):
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()

    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    first = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
    second = store.enqueue("mine", {"source": "a"}, dedupe_key="same")

    assert second.id == first.id

    claimed = store.claim_next()
    assert claimed.id == first.id
    assert claimed.state == "running"

    recovered = store.recover_running()
    assert recovered == 1
    assert store.get(first.id).state == "queued"


def test_daemon_http_lifecycle_executes_job(tmp_path, monkeypatch):
    calls = []

    def fake_execute(kind, payload):
        calls.append((kind, payload))
        return {"success": True, "exit_code": 0, "stdout": "done\n"}

    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)

    health = client.health()
    assert health["ok"] is True
    assert health["palace_path"] == daemon.canonical_palace_path(str(palace))

    job = client.submit("mine", {"source": "src"}, dedupe_key="job")
    finished = client.wait(job["id"], timeout=5)

    assert finished["state"] == "succeeded"
    assert finished["result"]["stdout"] == "done\n"
    assert calls == [("mine", {"source": "src", "palace_path": str(palace.resolve())})]

    _stop_server(client, thread, holders)


def test_daemon_holds_local_backend_writer_lease_for_lifetime(tmp_path, monkeypatch):
    monkeypatch.setenv("HOME", str(tmp_path / "home"))
    client, thread, palace, holders = _start_server(
        tmp_path, monkeypatch, lambda kind, payload: {"success": True, "exit_code": 0}
    )
    try:
        result = subprocess.run(
            [sys.executable, "-c", _LOCK_CONTENDER, str(palace)],
            check=False,
            env=os.environ.copy(),
            timeout=10,
        )
        assert result.returncode == 23
    finally:
        _stop_server(client, thread, holders)

    released = subprocess.run(
        [sys.executable, "-c", _LOCK_CONTENDER, str(palace)],
        check=False,
        env=os.environ.copy(),
        timeout=10,
    )
    assert released.returncode == 0


def test_submit_job_uses_client_and_waits(monkeypatch, tmp_path):
    palace = tmp_path / "palace"
    palace.mkdir()

    class DummyClient:
        def __init__(self):
            self.submitted = None

        def submit(self, kind, payload, dedupe_key=None, priority=0):
            self.submitted = (kind, payload, dedupe_key, priority)
            return {"id": "job-1", "state": "queued"}

        def wait(self, job_id, timeout=daemon.DEFAULT_WAIT_TIMEOUT, stop_on_lock_deferral=False):
            assert job_id == "job-1"
            return {
                "id": "job-1",
                "state": "succeeded",
                "result": {"success": True, "exit_code": 0},
            }

    dummy = DummyClient()
    monkeypatch.setattr(daemon, "ensure_client", lambda *a, **kw: dummy)

    job = daemon.submit_job(
        "mine",
        {"source": "src"},
        palace_path=str(palace),
        dedupe_key="dedupe",
        wait=True,
    )

    assert job["state"] == "succeeded"
    assert dummy.submitted[0] == "mine"
    # palace_path is overridden (not trusted from the payload), never appended.
    assert dummy.submitted[1]["palace_path"] == daemon.canonical_palace_path(str(palace))
    assert dummy.submitted[2] == "dedupe"


def test_service_tool_classification():
    assert service.classify_tool("mempalace_search") == "read"
    assert service.classify_tool("mempalace_add_drawer") == "write"
    assert service.classify_tool("mempalace_checkpoint") == "write"
    assert service.classify_tool("mempalace_delete_by_source") == "write"
    assert service.classify_tool("mempalace_mine") == "maintenance"
    assert service.classify_tool("unknown") == "unknown"


# --- helpers for HTTP-lifecycle tests ---


def _capture_httpd(monkeypatch):
    """Capture the httpd instance run_server creates.

    run_server defines a local ``class _Server(ThreadingHTTPServer)``; by
    monkeypatching ``daemon.ThreadingHTTPServer`` before run_server runs, that
    subclass inherits from a capturing base that records each instance. The
    httpd can then be force-stopped from the test thread (see _stop_server) so a
    slow/failed ``client.shutdown()`` POST can never leave the server thread
    alive — which on Windows hangs the interpreter at process exit on an open
    listening socket.
    """
    holders: list = []
    base = daemon.ThreadingHTTPServer

    class _CapturingServer(base):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            holders.append(self)

    monkeypatch.setattr(daemon, "ThreadingHTTPServer", _CapturingServer)
    return holders


def _stop_server(client, thread, holders, *, join_timeout=5.0):
    """Shut the daemon down deterministically and assert the thread died.

    First try the normal path (POST /shutdown). If the server thread is still
    alive afterwards — the POST was slow, lost, or the drain overran the join —
    call httpd.shutdown() directly from this thread (stdlib-safe: it is a
    different thread than serve_forever) to force serve_forever to return, then
    re-join. The assert turns a leak into a visible failure instead of a silent
    interpreter-exit hang.
    """
    try:
        client.shutdown()
    except Exception:  # noqa: BLE001 - best-effort; we force-shutdown below
        pass
    thread.join(timeout=join_timeout)
    if thread.is_alive() and holders:
        httpd = holders[-1]
        try:
            httpd.shutdown()
        except Exception:  # noqa: BLE001
            pass
        try:
            httpd.server_close()
        except Exception:  # noqa: BLE001
            pass
        thread.join(timeout=join_timeout)
    assert not thread.is_alive(), "daemon server thread did not shut down"


def _start_server(tmp_path, monkeypatch, execute_fn):
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    monkeypatch.setattr(service, "execute_job", execute_fn)
    holders = _capture_httpd(monkeypatch)

    # Capture any exception run_server raises in its thread. Without this a
    # startup crash is invisible: the poll below would just spin for 30s and
    # fail with a bare ``assert client is not None`` giving no cause.
    server_error: list = []

    def _serve():
        try:
            daemon.run_server(palace_path=str(palace), port=0)
        except BaseException as exc:  # noqa: BLE001 - re-surfaced to the test thread
            server_error.append(exc)

    thread = threading.Thread(target=_serve, name="test-daemon-server", daemon=True)
    thread.start()
    client = None
    deadline = time.monotonic() + 30
    while time.monotonic() < deadline:
        if server_error:
            raise AssertionError(f"run_server crashed during startup: {server_error[0]!r}")
        client = daemon.get_client_if_running(str(palace))
        if client is not None:
            break
        time.sleep(0.05)
    if client is None:
        raise AssertionError(
            "daemon did not become ready within 30s "
            f"(thread_alive={thread.is_alive()}, httpd_bound={bool(holders)}, "
            f"endpoint_exists={daemon.endpoint_path(str(palace)).exists()})"
        )
    return client, thread, palace, holders


# --- ship-blocker regressions ---


def test_systemexit_in_job_does_not_kill_worker(tmp_path, monkeypatch):
    """A SystemExit (BaseException, not Exception) must be caught, the job
    marked failed, and the worker kept alive for the next job. Regression for
    the critical worker-death bug."""
    state = {"first": True}

    def fake_execute(kind, payload):
        if state["first"]:
            state["first"] = False
            raise SystemExit("boom")
        return {"success": True, "exit_code": 0}

    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
    try:
        first = client.submit("mine", {"source": "src"})
        finished_first = client.wait(first["id"], timeout=5)
        assert finished_first["state"] == "failed"
        assert finished_first["error"]["error_class"] == "SystemExit"

        # Worker must still be alive — health reports it and a second job runs.
        assert client.health()["worker_alive"] is True
        second = client.submit("mine", {"source": "src2"})
        finished_second = client.wait(second["id"], timeout=5)
        assert finished_second["state"] == "succeeded"
    finally:
        _stop_server(client, thread, holders)


def test_shutdown_cancels_active_job(tmp_path, monkeypatch):
    """POST /shutdown must not leave an in-flight job 'running' for blind
    re-queue on next start. The worker is drained (bounded), then the active
    job is marked 'cancelled' so recover_running won't re-run it.

    In production the serve process exits immediately after run_server returns,
    killing the daemon worker thread before it can overwrite the cancelled
    state. The test mirrors that by asserting the cancelled state *before*
    releasing the blocked worker.
    """
    block = threading.Event()

    def fake_execute(kind, payload):
        # Simulate a long-running job that never finishes on its own.
        block.wait(30)
        return {"success": True, "exit_code": 0}

    monkeypatch.setattr(daemon, "SHUTDOWN_DRAIN_SECONDS", 0.2)
    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
    job = client.submit("mine", {"source": "src"}, dedupe_key="x")
    # Wait until the worker has claimed it (state flips to running).
    deadline = time.monotonic() + 5
    while time.monotonic() < deadline:
        if client.get_job(job["id"])["state"] == "running":
            break
        time.sleep(0.02)
    assert client.get_job(job["id"])["state"] == "running"

    _stop_server(client, thread, holders)

    # The interrupted job must be cancelled (terminal), not left running.
    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    final = store.get(job["id"])
    assert final.state == "cancelled"
    # And recover_running must not re-queue a cancelled job.
    assert store.recover_running() == 0

    # The server thread is gone, but its timed-out worker is still executing.
    # Writer ownership must stay with that worker until it truly exits.
    contender = subprocess.run(
        [sys.executable, "-c", _LOCK_CONTENDER, str(palace)],
        check=False,
        env=os.environ.copy(),
        timeout=10,
    )
    assert contender.returncode == 23

    # Release the blocked worker so it (and the daemon thread) can exit.
    block.set()
    deadline = time.monotonic() + 5
    while time.monotonic() < deadline:
        released = subprocess.run(
            [sys.executable, "-c", _LOCK_CONTENDER, str(palace)],
            check=False,
            env=os.environ.copy(),
            timeout=10,
        )
        if released.returncode == 0:
            break
        time.sleep(0.02)
    assert released.returncode == 0


def test_recover_running_dead_letters_exhausted_jobs(tmp_path, monkeypatch):
    """A job that has crashed MAX_ATTEMPTS times must be dead-lettered to
    'failed', not re-queued — non-idempotent kinds (diary_write) would
    otherwise duplicate verbatim content on every restart."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    job = store.enqueue("diary_write", {"entry": "x"})
    # Simulate MAX_ATTEMPTS claims that each crashed (running, attempts=MAX).
    with store._lock, store._connect() as conn:
        conn.execute(
            "UPDATE jobs SET state='running', attempts=? WHERE id=?",
            (daemon.MAX_ATTEMPTS, job.id),
        )

    recovered = store.recover_running()
    assert recovered == 0  # not re-queued
    final = store.get(job.id)
    assert final.state == "failed"
    assert final.attempts == daemon.MAX_ATTEMPTS


# --- #2014: a lease refusal is a deferral, not a failure ---


def test_lease_refusal_defers_job_and_runs_it_on_the_next_claim(tmp_path, monkeypatch):
    """A LockHeldByOtherProcess refusal means the palace write never landed, so
    re-running it cannot duplicate content. It must be deferred and picked up
    again, not dead-lettered. Regression for #2014, where a lock conflict ended
    terminal 'failed' and only ever ran again if some hook happened to re-submit
    equivalent work."""
    calls = {"n": 0}

    def fake_execute(kind, payload):
        calls["n"] += 1
        if calls["n"] == 1:
            return {
                "success": False,
                "error": "palace /p is held by PID 999 (mempalace-mcp)",
                "error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
                "exit_code": 1,
            }
        return {"success": True, "exit_code": 0}

    monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 0.05)
    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
    try:
        job = client.submit("diary_write", {"entry": "verbatim"})
        finished = client.wait(job["id"], timeout=10)
        assert finished["state"] == "succeeded"
        assert calls["n"] == 2  # refused once, then executed for real
        # The refusal spent no attempt: only the successful claim counted.
        assert finished["attempts"] == 1
    finally:
        _stop_server(client, thread, holders)


def test_repeated_lease_refusal_never_exhausts_attempts(tmp_path, monkeypatch):
    """#2014's actual promise: a palace locked across many claims must never
    dead-letter the work. One refusal cannot show that -- MAX_ATTEMPTS is 3, so
    the job has to survive more refusals than that and still run."""
    calls = {"n": 0}
    refusals = daemon.MAX_ATTEMPTS + 2

    def fake_execute(kind, payload):
        calls["n"] += 1
        if calls["n"] <= refusals:
            return {
                "success": False,
                "error": "palace /p is held by PID 999",
                "error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
                "exit_code": 1,
            }
        return {"success": True, "exit_code": 0}

    monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 0.02)
    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
    try:
        job = client.submit("mine", {"source": "src"})
        finished = client.wait(job["id"], timeout=20)
        assert finished["state"] == "succeeded"
        assert calls["n"] == refusals + 1
        assert finished["attempts"] == 1  # every refusal refunded its claim
    finally:
        _stop_server(client, thread, holders)


def test_wait_returns_a_lock_deferred_job_instead_of_waiting_out_the_holder(tmp_path, monkeypatch):
    """An interactive caller must not block until a deferred job goes terminal --
    it never will while the holder lives, so the default hour-long wait would
    strand the terminal behind a peer session. Background waiters keep waiting."""

    def fake_execute(kind, payload):
        return {
            "success": False,
            "error": "palace /p is held by PID 999 (mempalace-mcp)",
            "error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
            "exit_code": 1,
        }

    monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 5.0)
    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
    try:
        job = client.submit("mine", {"source": "src"})
        parked = client.wait(job["id"], timeout=10, stop_on_lock_deferral=True)
        assert parked["state"] == "queued"  # returned early, not terminal
        assert daemon.job_deferred_by_lock(parked)
        assert "PID 999" in parked["error"]["message"]

        # Without the flag the same job is not terminal, so the wait times out
        # rather than returning -- the hang a naive deferral would introduce.
        with pytest.raises(daemon.DaemonError):
            client.wait(job["id"], timeout=0.5)
    finally:
        _stop_server(client, thread, holders)


def test_job_deferred_by_lock_ignores_ordinary_queued_and_failed_jobs(tmp_path, monkeypatch):
    """The predicate must not mistake a job merely awaiting its turn -- or a
    genuinely failed one -- for a lock deferral, or the CLI would bail out of a
    healthy queue."""
    assert not daemon.job_deferred_by_lock({"state": "queued", "error": None})
    assert not daemon.job_deferred_by_lock({"state": "queued", "error": {}})
    assert not daemon.job_deferred_by_lock(
        {"state": "failed", "error": {"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS}}
    )
    assert not daemon.job_deferred_by_lock(
        {"state": "queued", "error": {"error_class": "ValueError"}}
    )
    assert daemon.job_deferred_by_lock(
        {"state": "queued", "error": {"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS}}
    )


def test_claim_clears_a_stale_deferral_reason(tmp_path, monkeypatch):
    """The reason defer parks on a queued job describes the claim that ended.
    Leaving it would outlive its cause: the predicate would report a lock
    refusal for a job that later crashed, and recover_running's dead-letter
    (which COALESCEs error_json) would blame the lock instead of
    MaxAttemptsExceeded."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    job = store.enqueue("diary_write", {"entry": "x"})

    store.claim_next()
    store.defer(job.id, error={"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS, "message": "held"})
    assert daemon.job_deferred_by_lock(daemon.job_to_dict(store.get(job.id)))

    claimed = store.claim_next()
    assert claimed.error is None  # the refusal belonged to the previous claim

    # Now crash out at MAX_ATTEMPTS: the dead-letter must name the real cause.
    with store._lock, store._connect() as conn:
        conn.execute(
            "UPDATE jobs SET state='running', attempts=? WHERE id=?",
            (daemon.MAX_ATTEMPTS, job.id),
        )
    store.recover_running()
    final = store.get(job.id)
    assert final.state == "failed"
    assert final.error["error_class"] == "MaxAttemptsExceeded"


def test_defer_records_why_the_job_went_back_to_the_queue(tmp_path, monkeypatch):
    """A job parked behind the palace lock must be distinguishable from one
    merely awaiting its turn.

    The reason is what job_deferred_by_lock keys on, and what the foreground CLI
    and the hook log quote back at the operator; without it a parked job and a
    job simply waiting its turn are the same row. (`daemon jobs` prints only
    id/state/kind/created_at, so it does not show this -- surfacing it there is
    a separate change.)"""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    job = store.enqueue("mine", {"source": "src"})
    store.claim_next()

    deferred = store.defer(
        job.id,
        error={"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS, "message": "held by PID 999"},
    )
    assert deferred.state == "queued"
    assert deferred.error["error_class"] == daemon.LOCK_REFUSAL_ERROR_CLASS
    assert "PID 999" in deferred.error["message"]


def test_invalid_lock_backoff_env_falls_back_to_the_default(monkeypatch):
    """Anything that is not a positive, finite number yields the default.

    A typo must not crash the daemon at import; 0 or a negative value would make
    the cooldown expire instantly and spin the worker against a lock it cannot
    take; and inf/nan are the sharp edges of float() -- inf parses fine and is
    > 0, so a bare positivity check would park the job until the daemon
    restarts, which is exactly the dropped work #2014 is about."""
    for bad in ("not-a-float", "-5", "0", "", "inf", "-inf", "nan"):
        monkeypatch.setenv("MEMPALACE_DAEMON_LOCK_BACKOFF_SECONDS", bad)
        assert daemon._lock_defer_backoff_seconds() == daemon._DEFAULT_LOCK_BACKOFF_SECONDS, bad

    monkeypatch.setenv("MEMPALACE_DAEMON_LOCK_BACKOFF_SECONDS", "0.5")
    assert daemon._lock_defer_backoff_seconds() == 0.5

    monkeypatch.delenv("MEMPALACE_DAEMON_LOCK_BACKOFF_SECONDS")
    assert daemon._lock_defer_backoff_seconds() == daemon._DEFAULT_LOCK_BACKOFF_SECONDS


def test_defer_returns_job_to_queued_without_spending_an_attempt(tmp_path, monkeypatch):
    """defer must undo claim_next's increment, so a palace held across many
    claims cannot walk a job to MAX_ATTEMPTS on work that never landed."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    job = store.enqueue("mine", {"source": "src"})

    claimed = store.claim_next()
    assert claimed.attempts == 1
    assert claimed.state == "running"

    deferred = store.defer(job.id)
    assert deferred.state == "queued"
    assert deferred.attempts == 0
    assert deferred.started_at is None
    # Still claimable -- the work is held, not lost.
    assert store.claim_next().id == job.id


def test_defer_scoped_to_a_stale_claim_is_a_no_op(tmp_path, monkeypatch):
    """defer is scoped to the claim that was refused. If the row was meanwhile
    re-queued and re-claimed (recover_running on another daemon start), a late
    defer keyed to the old claim must not flip the newer claim back to 'queued'
    -- that would refund its attempt and re-run work whose outcome the newer
    claimant already owns."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    job = store.enqueue("mine", {"source": "s"})

    # started_at is the claim token this test compares, and adjacent claims can
    # land inside one clock tick on hosts with a coarse wall clock (Windows
    # datetime.now() ticks at ~15.6ms before 3.13). Make every stamp unique so
    # the assertions exercise the guard, not the clock.
    real_now = daemon._now
    stamps = iter(range(10_000))
    monkeypatch.setattr(daemon, "_now", lambda: f"{real_now()}#{next(stamps):04d}")

    stale = store.claim_next()  # the claim that got the refusal
    deferred = store.defer(job.id, claimed_started_at=stale.started_at)
    assert deferred.state == "queued"  # scoping to one's own claim still defers

    fresh = store.claim_next()  # the row is claimed again behind the first claimant
    assert fresh.state == "running"

    late = store.defer(job.id, claimed_started_at=stale.started_at)
    assert late.state == "running", "a defer scoped to a stale claim must not fire"
    assert late.attempts == fresh.attempts


def test_defer_preserves_attempts_spent_on_earlier_real_executions(tmp_path, monkeypatch):
    """The refund undoes one claim, not the job's whole history. An execution
    that crashed left an unknown outcome and must keep costing its attempt --
    otherwise an alternating crash/refusal pattern would defeat MAX_ATTEMPTS and
    re-file verbatim content forever."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    job = store.enqueue("diary_write", {"entry": "x"})

    store.claim_next()  # attempt 1: crashes mid-execution (outcome unknown)
    assert store.recover_running() == 1  # re-queued, attempt NOT refunded
    assert store.get(job.id).attempts == 1

    store.claim_next()  # attempt 2: refused the lock this time
    deferred = store.defer(job.id)
    assert deferred.attempts == 1  # only the refused claim was refunded, not the crash


def test_shutdown_while_a_job_is_cooling_leaves_it_queued(tmp_path, monkeypatch):
    """A job already deferred must survive shutdown as 'queued', not be swept
    into a terminal state and dropped.

    The worker does not hold a deferred job: it records the cooldown and moves
    on, so `finally` clears active_job_id and the drain (which joins the worker
    before reading it) finds nothing in flight to cancel. Pin that, because a
    shutdown that cancelled a deferred job would be #2014 again."""
    monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 5.0)

    def fake_execute(kind, payload):
        return {
            "success": False,
            "error": "palace /p is held by PID 999",
            "error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
            "exit_code": 1,
        }

    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
    try:
        job = client.submit("diary_write", {"entry": "verbatim"})
        # Wait for the REFUSAL, not merely for 'queued': a job is queued the moment
        # it is submitted, so waiting on the state alone would pass this test before
        # any deferral had happened. The marker only appears once defer ran.
        deadline = time.monotonic() + 10
        while time.monotonic() < deadline:
            if daemon.job_deferred_by_lock(client.get_job(job["id"])):
                break
            time.sleep(0.02)
        assert daemon.job_deferred_by_lock(client.get_job(job["id"])), "never reached a deferral"
    finally:
        _stop_server(client, thread, holders)

    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    final = store.get(job["id"])
    assert final.state == "queued", "a deferred job must not be cancelled by shutdown"
    assert store.claim_next().id == job["id"]  # still runnable on the next start


def test_defer_does_not_resurrect_a_cancelled_job(tmp_path, monkeypatch):
    """defer mirrors finish(only_if_running=True): once shutdown cancelled an
    in-flight job, a late deferral must not queue it for re-execution."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    job = store.enqueue("diary_write", {"entry": "x"})
    store.claim_next()
    store.finish(job.id, state="cancelled", result={})

    after = store.defer(job.id)
    assert after.state == "cancelled"
    assert store.claim_next() is None


def test_claim_next_skips_excluded_jobs_without_binding_a_parameter_each(tmp_path, monkeypatch):
    """The cooling set must not be spliced into the SQL as one host parameter per
    job: SQLITE_MAX_VARIABLE_NUMBER defaults to 999 below SQLite 3.32 and this
    project still supports Python 3.9. Over the cap the query raises into the
    worker's catch-all, which retries against a set that only ages out on the
    cooldown, so the queue stalls in cooldown-long fits. It also has to keep
    picking the right row: the oldest queued job that is not excluded."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))

    first = store.enqueue("mine", {"source": "first"})
    second = store.enqueue("mine", {"source": "second"})

    # Excluding the oldest hands back the next one, not None.
    claimed = store.claim_next(exclude={first.id})
    assert claimed is not None
    assert claimed.id == second.id

    # Excluding every queued job yields nothing rather than the wrong job.
    store.defer(second.id)
    assert store.claim_next(exclude={first.id, second.id}) is None

    if not hasattr(sqlite3.Connection, "setlimit"):  # setlimit is 3.11+
        pytest.skip("sqlite3.Connection.setlimit needed to pin the parameter cap")

    # Force the pre-3.32 cap of 999 rather than trusting whatever SQLite this
    # interpreter bundles: on a build with a large cap the old
    # `id NOT IN (?, ?, ...)` shape passes and this test would guard nothing.
    real_connect = sqlite3.connect

    def capped_connect(*args, **kwargs):
        conn = real_connect(*args, **kwargs)
        conn.setlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER, 999)
        return conn

    monkeypatch.setattr(sqlite3, "connect", capped_connect)

    huge = {f"cooling-{i:07d}" for i in range(5_000)}  # 5x the cap
    claimed = store.claim_next(exclude=huge)
    assert claimed is not None
    assert claimed.id == first.id  # priority DESC, created_at ASC still honoured

    # And priority still wins over age, with an exclusion in play.
    store.defer(first.id)
    urgent = store.enqueue("diary_write", {"entry": "x"}, priority=10)
    assert store.claim_next(exclude=huge).id == urgent.id


def test_submit_job_forwards_stop_on_lock_deferral_to_wait(monkeypatch):
    """submit_job is the only door the CLI and the hooks use, so if it drops the
    flag on the floor every caller silently goes back to waiting out a holder
    that may never let go. Nothing else in the suite covers the forwarding."""
    seen = {}

    class _Client:
        def submit(self, kind, payload, dedupe_key=None, priority=0):
            return {"id": "job-1", "state": "queued"}

        def wait(self, job_id, *, timeout=None, stop_on_lock_deferral=False):
            seen["stop_on_lock_deferral"] = stop_on_lock_deferral
            return {"id": job_id, "state": "queued"}

    monkeypatch.setattr(daemon, "ensure_client", lambda *a, **kw: _Client())

    daemon.submit_job("mine", {"source": "s"}, palace_path="/p", stop_on_lock_deferral=True)
    assert seen["stop_on_lock_deferral"] is True

    daemon.submit_job("mine", {"source": "s"}, palace_path="/p")
    assert seen["stop_on_lock_deferral"] is False  # default stays the old waiting behaviour


def test_a_deferred_job_does_not_stall_unrelated_queued_work(tmp_path, monkeypatch):
    """The worker is the only one, and claim_next hands back the oldest queued
    job -- which, after a refusal, is the deferred job itself. If the worker
    waited out the cooldown in line, that one job would hold the queue hostage
    for as long as the holder lived (the flock is held while the write runs, and
    a wedged write holds it indefinitely, #2024), and nothing else would run.
    The cooldown must skip the job, not block the loop."""
    monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 30.0)
    ran = []

    def fake_execute(kind, payload):
        ran.append(kind)
        if kind == "mine":  # older, and refused for the whole test
            return {
                "success": False,
                "error": "palace /p is held by PID 999",
                "error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
                "exit_code": 1,
            }
        return {"success": True, "exit_code": 0}

    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
    try:
        blocked = client.submit("mine", {"source": "src"})
        unrelated = client.submit("diary_write", {"entry": "verbatim"})

        # The unrelated job must run despite a 30s cooldown on the older one.
        done = client.wait(unrelated["id"], timeout=10)
        assert done["state"] == "succeeded"
        assert "diary_write" in ran

        # And the blocked job is still held, not lost.
        parked = client.get_job(blocked["id"])
        assert parked["state"] == "queued"
        assert daemon.job_deferred_by_lock(parked)
    finally:
        _stop_server(client, thread, holders)


def test_a_job_queued_behind_a_deferred_one_gets_its_own_refusal_marker(tmp_path, monkeypatch):
    """A caller waiting on a job that is merely queued behind a deferred job has
    nothing to key on: the job was never claimed, so it carries no marker, and
    stop_on_lock_deferral cannot fire. It would wait out its full timeout and
    then report a failure that never happened -- #2014 through the back door.
    Because the cooldown skips the deferred job instead of blocking the worker,
    the second job is claimed, refused, and marked on its own."""
    monkeypatch.setattr(daemon, "LOCK_DEFER_BACKOFF_SECONDS", 30.0)

    def fake_execute(kind, payload):
        return {
            "success": False,
            "error": "palace /p is held by PID 999 (mempalace-mcp)",
            "error_class": daemon.LOCK_REFUSAL_ERROR_CLASS,
            "exit_code": 1,
        }

    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
    try:
        first = client.submit("mine", {"source": "a"})
        deadline = time.monotonic() + 10
        while time.monotonic() < deadline:
            if daemon.job_deferred_by_lock(client.get_job(first["id"])):
                break
            time.sleep(0.02)
        assert daemon.job_deferred_by_lock(client.get_job(first["id"]))

        # Submitted while the first job is cooling: it must still be claimed.
        second = client.submit("mine", {"source": "b"})
        parked = client.wait(second["id"], timeout=10, stop_on_lock_deferral=True)
        assert parked["state"] == "queued"
        assert daemon.job_deferred_by_lock(parked), "the CLI/hook short-circuit cannot fire"
        assert "PID 999" in parked["error"]["message"]
    finally:
        _stop_server(client, thread, holders)


def test_failure_that_is_not_a_lease_refusal_stays_terminal(tmp_path, monkeypatch):
    """Only a lease refusal defers. A genuine failure has an unknown outcome --
    the daemon may have written before dying -- which is exactly what MAX_ATTEMPTS
    guards, so it must stay terminal and never be blindly re-run."""

    def fake_execute(kind, payload):
        return {"success": False, "error": "boom", "error_class": "ValueError", "exit_code": 1}

    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
    try:
        job = client.submit("diary_write", {"entry": "x"})
        finished = client.wait(job["id"], timeout=10)
        assert finished["state"] == "failed"
        assert finished["error"]["message"] == "boom"
    finally:
        _stop_server(client, thread, holders)


def test_claim_next_does_not_reclaim_running_job(tmp_path, monkeypatch):
    """The conditional UPDATE (WHERE state='queued') means a job already
    flipped to 'running' cannot be claimed again — the cross-process
    double-execution guard."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    job = store.enqueue("mine", {"source": "src"})
    first = store.claim_next()
    assert first.id == job.id
    # Manually re-mark it queued but leave a second claim attempt: claim_next
    # should still only ever return one running job per claim. After finishing
    # the first, the next claim returns None (queue empty).
    store.finish(first.id, state="succeeded", result={"success": True})
    assert store.claim_next() is None


@_posix_only_perms
def test_queue_db_file_is_owner_only(tmp_path, monkeypatch):
    """The queue DB holds verbatim payloads — it must be 0600, not the sqlite
    default 0644. Regression for the privacy-principle violation."""
    import os as _os

    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    store = daemon.QueueStore(daemon.queue_path(str(palace)))
    store.enqueue("diary_write", {"entry": "secret verbatim content"})
    mode = _os.stat(str(store.path)).st_mode & 0o777
    assert mode == 0o600, f"queue.sqlite3 is {oct(mode)}, expected 0600"


@_posix_only_perms
def test_token_file_is_owner_only(tmp_path, monkeypatch):
    import os as _os

    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    daemon.ensure_token(str(palace))
    token_path = daemon.state_dir(str(palace)) / "token"
    assert (_os.stat(str(token_path)).st_mode & 0o777) == 0o600


def test_health_rejects_missing_and_wrong_token(tmp_path, monkeypatch):
    from urllib import error as urlerror
    from urllib import request as urlrequest

    client, thread, palace, holders = _start_server(
        tmp_path, monkeypatch, lambda k, p: {"success": True}
    )
    try:
        base = f"http://127.0.0.1:{client.port}"
        # No Authorization header → 401.
        with pytest.raises(urlerror.HTTPError):
            urlrequest.urlopen(urlrequest.Request(base + "/health"), timeout=3)
        # Wrong token → 401.
        with pytest.raises(urlerror.HTTPError):
            urlrequest.urlopen(
                urlrequest.Request(base + "/health", headers={"Authorization": "Bearer wrong"}),
                timeout=3,
            )
    finally:
        _stop_server(client, thread, holders)


def test_worker_overrides_client_palace_path(tmp_path, monkeypatch):
    """An authenticated client must not be able to retarget the daemon at a
    different palace by stuffing palace_path into the payload."""
    seen = {}

    def fake_execute(kind, payload):
        seen["palace_path"] = payload.get("palace_path")
        return {"success": True, "exit_code": 0}

    client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
    try:
        job = client.submit(
            "mine", {"source": "src", "palace_path": "/tmp/other-palace"}, dedupe_key="p"
        )
        client.wait(job["id"], timeout=5)
    finally:
        _stop_server(client, thread, holders)
    assert seen["palace_path"] == daemon.canonical_palace_path(str(palace))
    assert seen["palace_path"] != "/tmp/other-palace"


def test_mcp_tool_allowlist_rejects_non_write_tools(tmp_path, monkeypatch):
    """The daemon queue is a durable write surface; read/maintenance/unknown
    tools must be rejected so verbatim content can't be exfiltrated into the
    queue or retried destructively."""
    # read tool → rejected
    out = service.run_mcp_tool({"name": "mempalace_search", "arguments": {}})
    assert out["success"] is False
    assert "only accepts write tools" in out["error"]
    # maintenance tool → rejected (has its own kinds: mine/sync)
    out = service.run_mcp_tool({"name": "mempalace_mine", "arguments": {}})
    assert out["success"] is False
    # unknown tool → rejected
    out = service.run_mcp_tool({"name": "mempalace_bogus", "arguments": {}})
    assert out["success"] is False
    # write tool → passes the allowlist (handler not called here since TOOLS
    # won't have it under the test name; but classification must let it through)
    assert service.classify_tool("mempalace_add_drawer") == "write"


def test_execute_job_isolates_env_per_job(monkeypatch):
    """A job that mutates MEMPALACE_BACKEND must not leak into the next job's
    env. Regression for the per-job isolation bug (_apply_backend poisoning)."""
    import os as _os

    monkeypatch.delenv("MEMPALACE_BACKEND", raising=False)
    monkeypatch.delenv("MEMPALACE_PALACE_PATH", raising=False)

    def fake_mine(payload):
        _os.environ["MEMPALACE_BACKEND"] = "leaked-backend"
        return {"success": True, "exit_code": 0}

    monkeypatch.setattr(service, "run_mine", fake_mine)
    service.execute_job("mine", {"palace_path": "/tmp/p", "source": "s"})
    assert _os.environ.get("MEMPALACE_BACKEND") is None


def test_daemon_client_raises_on_endpoint_missing_port(tmp_path, monkeypatch):
    """A malformed endpoint.json must raise DaemonError, not a bare KeyError."""
    import json as _json

    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    daemon.ensure_token(str(palace))
    # endpoint with no port
    daemon.state_dir(str(palace)).mkdir(parents=True, exist_ok=True)
    (daemon.state_dir(str(palace)) / "endpoint.json").write_text(
        _json.dumps({"host": "127.0.0.1", "pid": 1}) + "\n", encoding="utf-8"
    )

    with pytest.raises(daemon.DaemonError):
        daemon.DaemonClient(str(palace))


def test_pid_alive_probe_is_signal_free_and_correct():
    """``_pid_alive`` must be a pure liveness probe.

    On Windows ``os.kill(pid, 0)`` is NOT harmless — signal 0 is
    ``CTRL_C_EVENT``, so it emits a console Ctrl-C to the target's process
    group. The daemon client polls a same-process endpoint, so that Ctrl-C was
    delivered back to the interpreter and surfaced as a spurious
    ``KeyboardInterrupt`` that hung the whole test session on CI runners (which,
    unlike a detached dev shell, have an attached console). Assert the probe is
    both correct and emits no SIGINT even when hammered like the poll loop.
    """
    import signal

    assert daemon._pid_alive(os.getpid()) is True
    assert daemon._pid_alive(0) is False
    assert daemon._pid_alive(-1) is False
    # A pid that is almost certainly not running.
    assert daemon._pid_alive(2_000_000_000) is False

    # pytest runs tests on the main thread, so installing a SIGINT handler is
    # allowed. If the probe regresses to os.kill(pid, 0) on Windows, the repeated
    # calls below deliver CTRL_C_EVENT and this handler fires.
    fired = []
    previous = signal.getsignal(signal.SIGINT)
    signal.signal(signal.SIGINT, lambda *_: fired.append(1))
    try:
        for _ in range(25):
            daemon._pid_alive(os.getpid())
        time.sleep(0.25)
    finally:
        signal.signal(signal.SIGINT, previous)
    assert fired == [], "_pid_alive delivered a console control event (CTRL_C_EVENT)"


def test_start_daemon_kills_orphan_on_readiness_timeout(tmp_path, monkeypatch):
    """If the spawned daemon never becomes ready, start_daemon must kill and
    reap the orphaned subprocess rather than leaking it with the port/token."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    daemon.ensure_token(str(palace))

    monkeypatch.setattr(daemon, "get_client_if_running", lambda *a, **kw: None)

    class FakeProc:
        def __init__(self):
            self.killed = False
            self.returncode = None

        def poll(self):
            return self.returncode  # None == still alive

        def kill(self):
            self.killed = True

        def wait(self):
            self.returncode = -9
            return self.returncode

    fake = FakeProc()

    def fake_popen(*a, **kw):
        return fake

    monkeypatch.setattr(daemon.subprocess, "Popen", fake_popen)
    monkeypatch.setattr(daemon, "DaemonClient", _raise_not_ready)
    monkeypatch.setattr(daemon.time, "sleep", lambda *a, **kw: None)

    with pytest.raises(daemon.DaemonError):
        daemon.start_daemon(str(palace), timeout=0.05)
    assert fake.killed is True


# --- service.run_* happy-path coverage ---
# These close the draft PR's follow-up ("Add focused happy-path tests for
# service.run_mine / run_diary_write / run_mcp_tool") and, now that the daemon
# tests complete reliably, keep service.py's coverage above the CI gate. The
# capsys-using tests come first; the two that import mempalace.mcp_server (which
# rebinds sys.stdout) come last and do not use capsys, so the rebind can't break
# capture in this file or later files (capsys activates after the rebind).


def test_print_job_result_replays_stdout_stderr_and_returns_exit_code(capsys):
    from mempalace import service

    code = service.print_job_result(
        {"success": False, "error": "boom", "stdout": "out\n", "stderr": "err\n", "exit_code": 3}
    )
    assert code == 3
    captured = capsys.readouterr()
    assert "out" in captured.out
    assert "err" in captured.err


def test_print_job_result_prints_error_to_stderr_when_no_stderr(capsys):
    from mempalace import service

    code = service.print_job_result({"success": False, "error": "boom", "exit_code": 1})
    assert code == 1
    captured = capsys.readouterr()
    assert "mempalace: boom" in captured.err


def test_run_sync_returns_success_when_palace_dir_missing(tmp_path):
    from mempalace import service

    result = service.run_sync({"palace_path": str(tmp_path / "nope"), "dry_run": True})
    assert result["success"] is True
    assert result["exit_code"] == 0


def test_run_sync_returns_success_when_palace_has_no_backend_artifact(tmp_path):
    from mempalace import service

    palace = tmp_path / "palace"
    palace.mkdir()
    result = service.run_sync({"palace_path": str(palace), "dry_run": True})
    assert result["success"] is True
    assert result["exit_code"] == 0


def test_run_mine_invalid_mode_returns_structured_error(tmp_path):
    from mempalace import service

    palace = tmp_path / "palace"
    palace.mkdir()
    out = service.run_mine({"palace_path": str(palace), "mode": "bogus"})
    assert out["success"] is False
    assert "invalid mine mode" in out["error"]
    assert out["exit_code"] == 2


def test_run_mine_source_adapter_dispatches_through_adapter_runner(tmp_path, monkeypatch):
    """Daemon mine jobs preserve the CLI's explicit source-adapter dispatch."""
    from mempalace import cli, service

    received = {}

    def fake_mine_source_adapter(**kwargs):
        received.update(kwargs)
        return 3

    monkeypatch.setattr(cli, "mine_source_adapter", fake_mine_source_adapter)
    palace = tmp_path / "palace"
    out = service.run_mine(
        {
            "palace_path": str(palace),
            "source": "/source",
            "source_adapter": "fixture",
            "dry_run": True,
        }
    )

    assert received == {
        "source_name": "fixture",
        "source_path": "/source",
        "palace_path": str(palace.resolve()),
        "dry_run": True,
    }
    assert out == {
        "success": True,
        "kind": "mine",
        "mode": "source",
        "dry_run": True,
        "exit_code": 0,
    }


def test_run_mcp_tool_rejects_non_dict_arguments():
    from mempalace import service

    out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": "nope"})
    assert out["success"] is False
    assert "must be an object" in out["error"]
    assert out["exit_code"] == 2


def test_run_mcp_tool_dispatches_write_tool(monkeypatch):
    import mempalace.mcp_server as mcp
    from mempalace import service

    captured = {}

    def fake_handler(**arguments):
        captured["arguments"] = arguments
        return {"success": True, "written": True}

    monkeypatch.setattr(mcp, "TOOLS", {"mempalace_add_drawer": {"handler": fake_handler}})
    out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": {"x": 1}})
    assert out["success"] is True
    assert out["written"] is True
    assert out["exit_code"] == 0
    assert captured["arguments"] == {"x": 1}


def test_run_diary_write_forwards_args_and_sets_exit_code(monkeypatch):
    import mempalace.mcp_server as mcp
    from mempalace import service

    captured = {}

    def fake_diary(agent_name, entry, topic, wing):
        captured.update(agent_name=agent_name, entry=entry, topic=topic, wing=wing)
        return {"success": True}

    monkeypatch.setattr(mcp, "tool_diary_write", fake_diary)
    out = service.run_diary_write(
        {"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
    )
    assert out["success"] is True
    assert out["exit_code"] == 0
    assert captured == {"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}


def test_run_mine_applies_backend_before_mode_validation(tmp_path):
    """Covers _apply_backend (env set + get_backend_class validation) on the daemon
    path; the invalid mode short-circuits before any mining runs."""
    from mempalace import service

    palace = tmp_path / "palace"
    palace.mkdir()
    out = service.run_mine({"palace_path": str(palace), "mode": "bogus", "backend": "chroma"})
    assert out["success"] is False
    assert out["exit_code"] == 2


def test_execute_job_dispatches_diary_write_mcp_tool_and_unknown(monkeypatch):
    """Covers execute_job's kind dispatch for diary_write, mcp_tool, and the
    unknown-kind fallback."""
    import mempalace.mcp_server as mcp
    from mempalace import service

    monkeypatch.setattr(mcp, "tool_diary_write", lambda **kw: {"success": True})
    monkeypatch.setattr(
        mcp, "TOOLS", {"mempalace_add_drawer": {"handler": lambda **kw: {"success": True}}}
    )
    assert service.execute_job("diary_write", {"entry": "x"})["success"] is True
    assert (
        service.execute_job("mcp_tool", {"name": "mempalace_add_drawer", "arguments": {}})[
            "success"
        ]
        is True
    )
    unknown = service.execute_job("bogus_kind", {})
    assert unknown["success"] is False
    assert unknown["exit_code"] == 2


def test_run_sync_structured_errors_on_sync_failures(tmp_path, monkeypatch):
    """Covers run_sync's three exception handlers (MineAlreadyRunning, ValueError,
    generic Exception) so a failing sync_palace returns a structured error instead
    of propagating."""
    import mempalace.sync as sync_module
    from mempalace import service
    from mempalace.palace import MineAlreadyRunning

    palace = tmp_path / "palace"
    palace.mkdir()
    make_minimal_chroma_sqlite(palace)

    def _raise(exc):
        def fn(**kw):
            raise exc

        return fn

    monkeypatch.setattr(sync_module, "sync_palace", _raise(MineAlreadyRunning("locked")))
    r = service.run_sync({"palace_path": str(palace), "dry_run": True})
    assert r["success"] is False
    assert r["error_class"] == "LockHeldByOtherProcess"

    monkeypatch.setattr(sync_module, "sync_palace", _raise(ValueError("bad scope")))
    r = service.run_sync({"palace_path": str(palace), "dry_run": True})
    assert r["success"] is False
    assert r["exit_code"] == 2

    monkeypatch.setattr(sync_module, "sync_palace", _raise(RuntimeError("boom")))
    r = service.run_sync({"palace_path": str(palace), "dry_run": True})
    assert r["success"] is False
    assert "sync failed" in r["error"]


def test_run_mine_lease_refusal_returns_the_lock_error_class(tmp_path, monkeypatch):
    """run_mine is the sole source of the refusal marker for kind 'mine' -- the
    primary #2014 vector, submitted by the hooks and the CLI. The worker's
    defer-vs-dead-letter gate keys on this error_class, so if a refactor drops
    it (say, a broad handler reordered above MineAlreadyRunning), the daemon
    silently goes back to dead-lettering refused mines while every worker test
    stays green: they all inject the marker by hand."""
    import mempalace.miner as miner_module
    from mempalace import service
    from mempalace.palace import MineAlreadyRunning

    palace = tmp_path / "palace"
    palace.mkdir()

    def _locked(**kw):
        raise MineAlreadyRunning("palace is held by PID 999")

    monkeypatch.setattr(miner_module, "mine", _locked)
    r = service.run_mine(
        {"palace_path": str(palace), "source": str(tmp_path / "src"), "mode": "projects"}
    )
    assert r["success"] is False
    assert r["error_class"] == daemon.LOCK_REFUSAL_ERROR_CLASS
    assert r["exit_code"] == 1


def test_safe_defer_swallows_store_errors_and_leaves_the_job_running(tmp_path, monkeypatch):
    """_safe_defer mirrors _safe_finish: a queue-DB hiccup during defer must not
    kill the worker. The job stays 'running' for recover_running to re-queue on
    the next start. Dropping the swallow would let the error reach the worker's
    outer except, which dead-letters the lock-refused job -- the #2014 outcome
    this branch exists to remove."""
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()
    runtime = daemon.DaemonRuntime(str(palace))
    job = runtime.store.enqueue("mine", {"source": "s"})
    runtime.store.claim_next()

    def _boom(*args, **kwargs):
        raise sqlite3.OperationalError("database is locked")

    monkeypatch.setattr(runtime.store, "defer", _boom)
    runtime._safe_defer(job.id, error={"error_class": daemon.LOCK_REFUSAL_ERROR_CLASS})
    assert runtime.store.get(job.id).state == "running"


# --- post-merge review follow-ups (Copilot review on #1826) ---


@_posix_only_perms
def test_run_server_tightens_umask_before_building_queue(tmp_path, monkeypatch):
    """The owner-only umask must be active BEFORE the queue DB is built.

    SQLite's WAL/SHM sidecars hold un-checkpointed verbatim payloads and are
    created with the process umask, so a loose umask at DaemonRuntime/QueueStore
    construction time would leave them world-readable. Capture the umask at the
    moment DaemonRuntime is constructed and assert it is already 0o077.
    """
    monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
    palace = tmp_path / "palace"
    palace.mkdir()

    captured = {}

    def _spy_runtime(*args, **kwargs):
        current = os.umask(0o022)
        os.umask(current)  # restore without changing
        captured["umask"] = current
        raise RuntimeError("stop before binding a real socket")

    monkeypatch.setattr(daemon, "DaemonRuntime", _spy_runtime)
    with pytest.raises(RuntimeError):
        daemon.run_server(str(palace), port=0)
    assert captured["umask"] == 0o077


def test_negative_content_length_is_rejected_without_blocking(tmp_path, monkeypatch):
    """A POST with Content-Length: -1 must get a prompt 400, not hang the worker.

    rfile.read(-1) would read until the client closes the socket (an auth-gated
    DoS) and bypass the MAX_BODY_BYTES cap. The recv timeout below turns a
    regression into a failure instead of a hang.
    """
    import socket

    client, thread, palace, holders = _start_server(
        tmp_path, monkeypatch, lambda k, p: {"success": True}
    )
    try:
        sock = socket.create_connection((client.host, client.port), timeout=5)
        sock.settimeout(5)
        request = (
            "POST /jobs HTTP/1.1\r\n"
            "Host: daemon\r\n"
            f"Authorization: Bearer {client.token}\r\n"
            "Content-Length: -1\r\n"
            "Connection: close\r\n\r\n"
        )
        sock.sendall(request.encode("ascii"))
        status_line = sock.recv(4096).decode("latin-1").split("\r\n", 1)[0]
        sock.close()
        assert "400" in status_line, f"expected 400, got {status_line!r}"
    finally:
        _stop_server(client, thread, holders)


def test_run_mcp_tool_marks_bare_error_dict_as_failure(monkeypatch):
    """A write tool that returns {"error": ...} with no success flag must be
    recorded as a failed job, not succeeded (Copilot review)."""
    import mempalace.mcp_server as mcp
    from mempalace import service

    monkeypatch.setattr(
        mcp,
        "TOOLS",
        {"mempalace_create_tunnel": {"handler": lambda **kw: {"error": "bad endpoint"}}},
    )
    out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
    assert out["success"] is False
    assert out["exit_code"] == 1
    assert out["error"] == "bad endpoint"

    # A result with neither an explicit success flag nor an error is a success.
    monkeypatch.setattr(
        mcp, "TOOLS", {"mempalace_create_tunnel": {"handler": lambda **kw: {"tunnel_id": "t1"}}}
    )
    out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
    assert out["success"] is True
    assert out["exit_code"] == 0


def test_get_client_if_running_uses_short_probe_timeout(monkeypatch):
    """The hook liveness precheck must pass a short health timeout so a wedged
    daemon can't stall the hook past its budget (Copilot review)."""
    captured = {}

    class _FakeClient:
        def __init__(self, palace_path):
            pass

        def health(self, *, timeout):
            captured["timeout"] = timeout
            return {"ok": True}

    monkeypatch.setattr(daemon, "DaemonClient", _FakeClient)

    assert daemon.HOOK_PROBE_TIMEOUT <= 0.5
    client = daemon.get_client_if_running("/p", health_timeout=daemon.HOOK_PROBE_TIMEOUT)
    assert client is not None
    assert captured["timeout"] == daemon.HOOK_PROBE_TIMEOUT


# --- _detached_kwargs ---


def test_detached_kwargs_posix(tmp_path, monkeypatch):
    monkeypatch.setattr("mempalace.daemon.os.name", "posix")
    kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
    fh = kwargs["stdout"]
    try:
        assert kwargs.get("start_new_session") is True
        assert kwargs.get("stdin") is subprocess.DEVNULL
        assert kwargs.get("close_fds") is True
        assert "creationflags" not in kwargs
    finally:
        fh.close()


def test_detached_kwargs_windows(tmp_path, monkeypatch):
    monkeypatch.setattr("mempalace.daemon.os.name", "nt")
    monkeypatch.setattr("mempalace.daemon.subprocess.CREATE_NO_WINDOW", 0x08000000, raising=False)
    monkeypatch.setattr("mempalace.daemon.subprocess.DETACHED_PROCESS", 0x00000008, raising=False)
    monkeypatch.setattr(
        "mempalace.daemon.subprocess.CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False
    )
    monkeypatch.setattr(
        "mempalace.daemon.subprocess.CREATE_BREAKAWAY_FROM_JOB", 0x01000000, raising=False
    )
    kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
    fh = kwargs["stdout"]
    try:
        flags = kwargs.get("creationflags", 0)
        assert flags & 0x08000000, "CREATE_NO_WINDOW must be set"
        assert not (flags & 0x00000008), (
            "DETACHED_PROCESS must NOT be set (it suppresses CREATE_NO_WINDOW)"
        )
        assert flags & 0x00000200, "CREATE_NEW_PROCESS_GROUP preserved (Ctrl-Break group isolation)"
        assert flags & 0x01000000, (
            "CREATE_BREAKAWAY_FROM_JOB preserved (survive parent Job-Object close)"
        )
        assert kwargs.get("stdin") is subprocess.DEVNULL
        assert kwargs.get("close_fds") is True
        # creationflags + start_new_session are mutually exclusive on Windows
        # (Popen raises ValueError); the nt branch must not set the latter.
        assert "start_new_session" not in kwargs
    finally:
        fh.close()
