"""Tests for crank_cli.daemon — run with: python -m pytest tests/test_daemon.py"""

import json
import os
import shutil
import signal
import socket
import sqlite3
import stat
import tempfile
import threading
import time
import unittest
from datetime import datetime
from pathlib import Path
from unittest.mock import patch

from crank_cli.daemon import SCHEMA, CrankDaemon, _parse_cron_field, _cron_matches


def _make_daemon(tmpdir):
    """Create a CrankDaemon rooted in tmpdir with the required directory structure."""
    home = Path(tmpdir)
    (home / "cfg" / "jobs").mkdir(parents=True, exist_ok=True)
    (home / "run").mkdir(exist_ok=True)
    d = CrankDaemon(str(home))
    return d


def _create_job(home, name, script="#!/bin/bash\necho hello\n", exit_code=0):
    """Write a job script into the jobs dir."""
    job_path = Path(home) / "cfg" / "jobs" / f"{name}.run"
    if exit_code != 0:
        script = f"#!/bin/bash\nexit {exit_code}\n"
    job_path.write_text(script)
    job_path.chmod(0o755)


def _send_command(sock_path, command, timeout=10):
    """Send a line-protocol command to the daemon and return the response."""
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.settimeout(timeout)
    try:
        sock.connect(str(sock_path))
        sock.sendall((command + "\n").encode())
        sock.shutdown(socket.SHUT_WR)
        data = b""
        while True:
            chunk = sock.recv(4096)
            if not chunk:
                break
            data += chunk
    finally:
        sock.close()
    return data.decode("utf-8", errors="replace").strip()


class TestDaemonDBInit(unittest.TestCase):
    def test_schema_creates_builds_table(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            db = sqlite3.connect(str(d.db_path))
            cur = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='builds'")
            self.assertIsNotNone(cur.fetchone())
            db.close()

    def test_init_db_is_idempotent(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            d._init_db()  # should not raise


class TestBuildNumbers(unittest.TestCase):
    def test_first_build_is_1(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            db = d._connect_db()
            with d.build_lock:
                n = d._next_build_number(db, "test-job")
            self.assertEqual(n, 1)
            db.close()

    def test_increments_after_insert(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            db = d._connect_db()
            db.execute(
                "INSERT INTO builds (name, number, result) VALUES (?, ?, ?)",
                ("test-job", 1, 5),
            )
            db.commit()
            with d.build_lock:
                n = d._next_build_number(db, "test-job")
            self.assertEqual(n, 2)
            db.close()

    def test_different_jobs_independent(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            db = d._connect_db()
            db.execute(
                "INSERT INTO builds (name, number, result) VALUES (?, ?, ?)",
                ("job-a", 5, 5),
            )
            db.commit()
            with d.build_lock:
                na = d._next_build_number(db, "job-a")
                nb = d._next_build_number(db, "job-b")
            self.assertEqual(na, 6)
            self.assertEqual(nb, 1)
            db.close()


class TestJobValidation(unittest.TestCase):
    def test_valid_names(self):
        d = CrankDaemon("/tmp/fake")
        for name in ["build", "my-job", "test_123", "A"]:
            self.assertTrue(d._validate_job_name(name), f"should accept {name!r}")

    def test_invalid_names(self):
        d = CrankDaemon("/tmp/fake")
        for name in ["bad;name", "../etc", "a b", "", "$(whoami)", "a|b"]:
            self.assertFalse(d._validate_job_name(name), f"should reject {name!r}")


class TestEnqueueJob(unittest.TestCase):
    def test_enqueue_creates_queued_row(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "hello")
            number, event = d._enqueue_job("hello", [])
            self.assertEqual(number, 1)

            db = d._connect_db()
            row = db.execute(
                "SELECT result FROM builds WHERE name='hello' AND number=1"
            ).fetchone()
            self.assertEqual(row[0], 1)  # queued
            db.close()

    def test_enqueue_invalid_name_raises(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            with self.assertRaises(ValueError):
                d._enqueue_job("bad;name", [])

    def test_enqueue_missing_script_raises(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            with self.assertRaises(FileNotFoundError):
                d._enqueue_job("nonexistent", [])


class TestJobExecution(unittest.TestCase):
    def test_successful_job(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "hello", script="#!/bin/bash\necho hello from crank\n")

            # Enqueue and run directly (bypass worker)
            number, event = d._enqueue_job("hello", [])
            d._run_job("hello", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result, output FROM builds WHERE name='hello' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 5)  # success
            self.assertIn("hello from crank", row[1])

    def test_failed_job(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "fail", exit_code=1)

            number, event = d._enqueue_job("fail", [])
            d._run_job("fail", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result FROM builds WHERE name='fail' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 4)  # failed

    def test_env_vars_passed(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "envtest", script="#!/bin/bash\necho $MY_VAR\n")

            number, event = d._enqueue_job("envtest", ["MY_VAR=testvalue"])
            d._run_job("envtest", number, ["MY_VAR=testvalue"])

            db = d._connect_db()
            row = db.execute(
                "SELECT output FROM builds WHERE name='envtest' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertIn("testvalue", row[0])

    def test_state_file_written(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "statetest")

            number, event = d._enqueue_job("statetest", [])
            d._run_job("statetest", number, [])

            state_file = Path(tmpdir) / "statetest.last"
            self.assertTrue(state_file.exists())
            self.assertEqual(state_file.read_text(), "success")

    def test_log_file_written(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "logtest", script="#!/bin/bash\necho logline\n")

            number, event = d._enqueue_job("logtest", [])
            d._run_job("logtest", number, [])

            log_file = Path(tmpdir) / "run" / "logtest" / str(number) / "log"
            self.assertTrue(log_file.exists())
            self.assertIn("logline", log_file.read_text())

    def test_completion_event_set(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "eventtest")

            number, event = d._enqueue_job("eventtest", [])
            self.assertFalse(event.is_set())
            d._run_job("eventtest", number, [])
            self.assertTrue(event.is_set())


class TestAbortJob(unittest.TestCase):
    def test_abort_queued_job(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "abortme")

            number, event = d._enqueue_job("abortme", [])
            result = d._abort_job("abortme", number)
            self.assertTrue(result)

            db = d._connect_db()
            row = db.execute(
                "SELECT result FROM builds WHERE name='abortme' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 3)  # aborted
            self.assertTrue(event.is_set())

    def test_abort_nonexistent_returns_false(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            result = d._abort_job("nope", 999)
            self.assertFalse(result)


class TestStaleSocket(unittest.TestCase):
    def test_stale_socket_removed(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            # Create a stale socket file
            d.sock_path.touch()
            d._check_stale_socket()
            self.assertFalse(d.sock_path.exists())


class TestSocketProtocol(unittest.TestCase):
    """Integration tests that start the daemon and communicate over the socket."""

    def _start_daemon(self, tmpdir):
        d = _make_daemon(tmpdir)
        d._init_db()
        t = threading.Thread(target=d.start, daemon=True)
        t.start()
        # Wait for socket to appear
        deadline = time.time() + 5
        while not d.sock_path.exists() and time.time() < deadline:
            time.sleep(0.05)
        # NOTE: caller must check d.sock_path.exists() AFTER getting d, t
        return d, t

    def test_queue_and_run(self):
        tmpdir = tempfile.mkdtemp(prefix="crank-test-")
        try:
            (Path(tmpdir) / "cfg" / "jobs").mkdir(parents=True)
            _create_job(tmpdir, "hello", script="#!/bin/bash\necho hello\n")
            d, t = self._start_daemon(tmpdir)
            try:
                self.assertTrue(d.sock_path.exists(), "Daemon socket did not appear")
                # Queue a job
                resp = _send_command(d.sock_path, "queue hello")
                self.assertTrue(resp.startswith("OK"))
                number = resp.split()[1]
                self.assertEqual(number, "1")

                # Wait for it to complete
                deadline = time.time() + 5
                while time.time() < deadline:
                    db = d._connect_db()
                    row = db.execute(
                        "SELECT result FROM builds WHERE name='hello' AND number=1"
                    ).fetchone()
                    db.close()
                    if row and row[0] not in (1, 2):
                        break
                    time.sleep(0.1)

                db = d._connect_db()
                row = db.execute(
                    "SELECT result, output FROM builds WHERE name='hello' AND number=1"
                ).fetchone()
                db.close()
                self.assertEqual(row[0], 5)
                self.assertIn("hello", row[1])
            finally:
                d._shutdown.set()
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)

    def test_blocking_run(self):
        tmpdir = tempfile.mkdtemp(prefix="crank-test-")
        try:
            (Path(tmpdir) / "cfg" / "jobs").mkdir(parents=True)
            _create_job(tmpdir, "fast", script="#!/bin/bash\necho done\n")
            d, t = self._start_daemon(tmpdir)
            try:
                self.assertTrue(d.sock_path.exists(), "Daemon socket did not appear")
                resp = _send_command(d.sock_path, "run fast")
                self.assertTrue(resp.startswith("OK"), f"Expected OK, got: {resp}")
            finally:
                d._shutdown.set()
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)

    def test_run_nonexistent_job(self):
        tmpdir = tempfile.mkdtemp(prefix="crank-test-")
        try:
            d, t = self._start_daemon(tmpdir)
            try:
                self.assertTrue(d.sock_path.exists(), "Daemon socket did not appear")
                resp = _send_command(d.sock_path, "queue nope")
                self.assertTrue(resp.startswith("ERR"))
            finally:
                d._shutdown.set()
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)

    def test_unknown_command(self):
        tmpdir = tempfile.mkdtemp(prefix="crank-test-")
        try:
            d, t = self._start_daemon(tmpdir)
            try:
                self.assertTrue(d.sock_path.exists(), "Daemon socket did not appear")
                resp = _send_command(d.sock_path, "badcmd foo")
                self.assertTrue(resp.startswith("ERR"))
                self.assertIn("unknown command", resp)
            finally:
                d._shutdown.set()
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)

    def test_socket_permissions(self):
        tmpdir = tempfile.mkdtemp(prefix="crank-test-")
        try:
            d, t = self._start_daemon(tmpdir)
            try:
                self.assertTrue(d.sock_path.exists(), "Daemon socket did not appear")
                mode = d.sock_path.stat().st_mode
                self.assertEqual(stat.S_IMODE(mode), 0o660)
            finally:
                d._shutdown.set()
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)

    def test_abort_via_socket(self):
        tmpdir = tempfile.mkdtemp(prefix="crank-test-")
        try:
            (Path(tmpdir) / "cfg" / "jobs").mkdir(parents=True)
            _create_job(tmpdir, "sleeper", script="#!/bin/bash\nsleep 60\n")
            d, t = self._start_daemon(tmpdir)
            try:
                self.assertTrue(d.sock_path.exists(), "Daemon socket did not appear")
                # Queue a long-running job
                resp = _send_command(d.sock_path, "queue sleeper")
                self.assertTrue(resp.startswith("OK"))

                # Wait for it to start running
                deadline = time.time() + 5
                while time.time() < deadline:
                    db = d._connect_db()
                    row = db.execute(
                        "SELECT result FROM builds WHERE name='sleeper' AND number=1"
                    ).fetchone()
                    db.close()
                    if row and row[0] == 2:
                        break
                    time.sleep(0.1)

                resp = _send_command(d.sock_path, "abort sleeper 1")
                self.assertEqual(resp, "OK")

                # Verify it was aborted
                deadline = time.time() + 10
                while time.time() < deadline:
                    db = d._connect_db()
                    row = db.execute(
                        "SELECT result FROM builds WHERE name='sleeper' AND number=1"
                    ).fetchone()
                    db.close()
                    if row and row[0] == 3:
                        break
                    time.sleep(0.1)
                self.assertEqual(row[0], 3)
            finally:
                d._shutdown.set()
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)


class TestD1ShutdownUnblocksRunClient(unittest.TestCase):
    """D1: event.wait() with shutdown check — client handler returns on shutdown."""

    def _start_daemon(self, tmpdir):
        d = _make_daemon(tmpdir)
        d._init_db()
        t = threading.Thread(target=d.start, daemon=True)
        t.start()
        deadline = time.time() + 5
        while not d.sock_path.exists() and time.time() < deadline:
            time.sleep(0.05)
        # NOTE: caller must check d.sock_path.exists() AFTER getting d, t
        return d, t

    def test_run_client_gets_shutdown_error(self):
        """If daemon shuts down while a blocking 'run' is waiting, client gets ERR."""
        tmpdir = tempfile.mkdtemp(prefix="crank-test-")
        try:
            (Path(tmpdir) / "cfg" / "jobs").mkdir(parents=True)
            _create_job(tmpdir, "slow", script="#!/bin/bash\nsleep 60\n")
            d, t = self._start_daemon(tmpdir)
            try:
                self.assertTrue(d.sock_path.exists(), "Daemon socket did not appear")
                # Send a blocking run in a thread so we can shut down mid-wait
                result_holder = [None]

                def do_run():
                    result_holder[0] = _send_command(d.sock_path, "run slow", timeout=15)

                client_thread = threading.Thread(target=do_run, daemon=True)
                client_thread.start()

                # Wait for job to be running
                deadline = time.time() + 5
                while time.time() < deadline:
                    db = d._connect_db()
                    row = db.execute(
                        "SELECT result FROM builds WHERE name='slow' AND number=1"
                    ).fetchone()
                    db.close()
                    if row and row[0] == 2:
                        break
                    time.sleep(0.1)

                # Trigger shutdown — the client handler should return with ERR
                d._shutdown.set()
                client_thread.join(timeout=10)
                self.assertFalse(client_thread.is_alive(), "Client thread should have returned")
                # Client should get an ERR response (shutdown or result)
                self.assertIsNotNone(result_holder[0])
                self.assertTrue(
                    result_holder[0].startswith("ERR"),
                    f"Expected ERR response, got: {result_holder[0]!r}",
                )
            finally:
                d._shutdown.set()
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)


class TestD2IncrementalOutput(unittest.TestCase):
    """D2: Incremental output reading with log file streaming."""

    def test_multiline_output_captured(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            script = "#!/bin/bash\nfor i in 1 2 3 4 5; do echo line$i; done\n"
            _create_job(tmpdir, "multi", script=script)

            number, event = d._enqueue_job("multi", [])
            d._run_job("multi", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result, output FROM builds WHERE name='multi' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 5)
            for i in range(1, 6):
                self.assertIn(f"line{i}", row[1])

    def test_log_file_matches_output(self):
        """Log file written incrementally should match DB output for small jobs."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "logmatch", script="#!/bin/bash\necho log-content\n")

            number, event = d._enqueue_job("logmatch", [])
            d._run_job("logmatch", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT output FROM builds WHERE name='logmatch' AND number=?",
                (number,),
            ).fetchone()
            db.close()

            log_file = Path(tmpdir) / "run" / "logmatch" / str(number) / "log"
            self.assertTrue(log_file.exists())
            self.assertEqual(log_file.read_text(), row[0])


class TestD3AbortRace(unittest.TestCase):
    """D3: Popen and running_procs registration are now atomic."""

    def test_proc_registered_before_lock_release(self):
        """After Popen, the process must be in running_procs before the lock
        is released (i.e., atomic registration). We verify by running a slow
        job, checking that it appears in running_procs, then aborting it."""
        tmpdir = tempfile.mkdtemp(prefix="crank-test-")
        try:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "racetest", script="#!/bin/bash\nsleep 30\n")

            number, event = d._enqueue_job("racetest", [])

            registered = threading.Event()

            def run_and_signal():
                d._run_job("racetest", number, [])

            t = threading.Thread(target=run_and_signal, daemon=True)
            t.start()

            try:
                # Wait until the proc appears in running_procs
                deadline = time.time() + 5
                while time.time() < deadline:
                    with d.running_procs_lock:
                        if ("racetest", number) in d.running_procs:
                            registered.set()
                            break
                    time.sleep(0.05)

                self.assertTrue(registered.is_set(), "Process should be registered in running_procs")
            finally:
                # Abort to finish the test quickly
                d._abort_job("racetest", number)
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)


class TestD4OutputCap(unittest.TestCase):
    """D4: Output exceeding MAX_OUTPUT is truncated in memory (not on disk)."""

    @patch("crank_cli.daemon.MAX_OUTPUT", 1024)  # 1KB cap for fast testing
    def test_large_output_truncated_in_db(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            # Generate ~10KB of output — well over the patched 1KB cap
            script = "#!/bin/bash\nfor i in $(seq 1 200); do echo 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; done\n"
            _create_job(tmpdir, "bigout", script=script)

            number, event = d._enqueue_job("bigout", [])
            d._run_job("bigout", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result, output, outputLen FROM builds WHERE name='bigout' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 5)
            # The output should contain the truncation marker
            self.assertIn("... output truncated at 10MB ...", row[1])
            # The stored output length should be less than the full output
            # (log file on disk has everything, but in-memory/DB is capped)
            self.assertGreater(len(row[1]), 0)
            # Verify actual truncation occurred — DB output must be well under 2KB
            # given the 1KB MAX_OUTPUT patch (the extra is the truncation marker)
            self.assertLess(len(row[1]), 2048)


class TestD5ZombieBuildCleanup(unittest.TestCase):
    """D5: Builds killed during shutdown get result=3, not stuck at result=2."""

    def test_stale_running_builds_marked_aborted(self):
        """Start a real daemon with a long-running job, trigger shutdown,
        and verify the build doesn't stay stuck at result=2."""
        tmpdir = tempfile.mkdtemp(prefix="crank-zombie-")
        try:
            (Path(tmpdir) / "cfg" / "jobs").mkdir(parents=True, exist_ok=True)
            _create_job(tmpdir, "zombie-job", script="#!/bin/bash\nsleep 300\n")

            d = _make_daemon(tmpdir)
            d._init_db()
            daemon_thread = threading.Thread(target=d.start, daemon=True)
            daemon_thread.start()

            try:
                # Wait for the socket to appear
                deadline = time.time() + 5
                while not d.sock_path.exists() and time.time() < deadline:
                    time.sleep(0.05)
                self.assertTrue(d.sock_path.exists(), "Daemon socket did not appear")

                # Trigger the long-running job
                resp = _send_command(d.sock_path, "queue zombie-job")
                self.assertTrue(resp.startswith("OK"), f"Expected OK, got: {resp!r}")

                # Wait for running state
                deadline = time.time() + 10
                while time.time() < deadline:
                    db = d._connect_db()
                    row = db.execute(
                        "SELECT result FROM builds WHERE name='zombie-job' AND number=1"
                    ).fetchone()
                    db.close()
                    if row and row[0] == 2:
                        break
                    time.sleep(0.1)
                self.assertEqual(row[0], 2, "Job should be in running state")

                # Trigger graceful shutdown
                d._shutdown.set()
                daemon_thread.join(timeout=60)

                # Verify the build is NOT stuck at result=2
                db = d._connect_db()
                row = db.execute(
                    "SELECT result, completedAt FROM builds WHERE name='zombie-job' AND number=1"
                ).fetchone()
                db.close()
                self.assertIn(row[0], (3, 4), "Build should be aborted/failed, not stuck at running")
                self.assertIsNotNone(row[1], "completedAt should be set")
            finally:
                d._shutdown.set()
                daemon_thread.join(timeout=45)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)


def _create_conf(home, name, content):
    """Write a job conf file into the jobs dir."""
    conf_path = Path(home) / "cfg" / "jobs" / f"{name}.conf"
    conf_path.write_text(content)


class TestReadJobConf(unittest.TestCase):
    def test_reads_key_value_pairs(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            _create_conf(tmpdir, "myconf", "on_success=jobB\non_failure=jobC\n")
            result = d._read_job_conf("myconf")
            self.assertEqual(result, {"on_success": "jobB", "on_failure": "jobC"})

    def test_missing_conf_returns_empty(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            result = d._read_job_conf("nonexistent")
            self.assertEqual(result, {})

    def test_ignores_comments_and_blanks(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            _create_conf(tmpdir, "commented", "# this is a comment\n\nschedule=* * * * *\n\n# another\n")
            result = d._read_job_conf("commented")
            self.assertEqual(result, {"schedule": "* * * * *"})


class TestReadJobConfInlineComments(unittest.TestCase):
    def test_inline_comments_stripped(self):
        """Inline comments after values should be stripped."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            _create_conf(tmpdir, "inlinetest", "timeout=300 # 5 min\nschedule=*/5 * * * * # every 5 min\n")
            result = d._read_job_conf("inlinetest")
            self.assertEqual(result, {"timeout": "300", "schedule": "*/5 * * * *"})

    def test_bom_handled(self):
        """BOM-prefixed config files should be read correctly."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            # Write a config file with a UTF-8 BOM prefix
            conf_path = Path(tmpdir) / "cfg" / "jobs" / "bomtest.conf"
            conf_path.write_text("\ufefftimeout=60\non_success=deploy\n")
            result = d._read_job_conf("bomtest")
            self.assertEqual(result, {"timeout": "60", "on_success": "deploy"})


class TestCronParser(unittest.TestCase):
    def test_star(self):
        self.assertEqual(_parse_cron_field("*", 0, 59), set(range(60)))

    def test_specific(self):
        self.assertEqual(_parse_cron_field("5", 0, 59), {5})

    def test_comma_list(self):
        self.assertEqual(_parse_cron_field("1,3,5", 0, 59), {1, 3, 5})

    def test_range(self):
        self.assertEqual(_parse_cron_field("1-5", 0, 59), {1, 2, 3, 4, 5})

    def test_step(self):
        self.assertEqual(_parse_cron_field("*/15", 0, 59), {0, 15, 30, 45})

    def test_range_step(self):
        self.assertEqual(_parse_cron_field("1-10/3", 0, 59), {1, 4, 7, 10})

    def test_cron_matches(self):
        # 2026-03-18 14:30 is a Wednesday
        dt = datetime(2026, 3, 18, 14, 30)
        self.assertTrue(_cron_matches("30 14 * * *", dt))

    def test_cron_no_match(self):
        dt = datetime(2026, 3, 18, 14, 30)
        self.assertFalse(_cron_matches("15 14 * * *", dt))

    def test_cron_every_5_min(self):
        dt = datetime(2026, 3, 18, 14, 15)
        self.assertTrue(_cron_matches("*/5 * * * *", dt))

    def test_cron_dow(self):
        # 2026-03-18 is a Wednesday. Python weekday=2, cron dow=(2+1)%7=3
        dt = datetime(2026, 3, 18, 14, 30)
        self.assertTrue(_cron_matches("30 14 * * 3", dt))
        # Sunday check: 2026-03-22 is a Sunday. Python weekday=6, cron dow=(6+1)%7=0
        dt_sun = datetime(2026, 3, 22, 14, 30)
        self.assertTrue(_cron_matches("30 14 * * 0", dt_sun))
        self.assertFalse(_cron_matches("30 14 * * 3", dt_sun))


class TestEnqueueWithParams(unittest.TestCase):
    def test_params_stored_as_json(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "paramjob")
            number, event = d._enqueue_job("paramjob", ["FOO=bar", "BAZ=qux"])

            db = d._connect_db()
            row = db.execute(
                "SELECT params FROM builds WHERE name='paramjob' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            params = json.loads(row[0])
            self.assertEqual(params, {"FOO": "bar", "BAZ": "qux"})

    def test_parent_fields_stored(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "childjob")
            number, event = d._enqueue_job(
                "childjob", [], reason="pipeline:parent",
                parent_job="parent", parent_build=42,
            )

            db = d._connect_db()
            row = db.execute(
                "SELECT parentJob, parentBuild FROM builds WHERE name='childjob' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], "parent")
            self.assertEqual(row[1], 42)


class TestPipelineChaining(unittest.TestCase):
    def test_on_success_triggers_downstream(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "jobA", script="#!/bin/bash\necho A\n")
            _create_job(tmpdir, "jobB", script="#!/bin/bash\necho B\n")
            _create_conf(tmpdir, "jobA", "on_success=jobB\n")

            # Enqueue and run jobA
            number, event = d._enqueue_job("jobA", ["X=1"])
            d._run_job("jobA", number, ["X=1"])

            # Verify jobB was enqueued
            db = d._connect_db()
            row = db.execute(
                "SELECT name, reason, parentJob, parentBuild FROM builds WHERE name='jobB'"
            ).fetchone()
            db.close()
            self.assertIsNotNone(row, "jobB should have been enqueued")
            self.assertEqual(row[1], "pipeline:jobA")
            self.assertEqual(row[2], "jobA")
            self.assertEqual(row[3], number)

    def test_on_failure_triggers_downstream(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "jobA", exit_code=1)
            _create_job(tmpdir, "jobC", script="#!/bin/bash\necho C\n")
            _create_conf(tmpdir, "jobA", "on_failure=jobC\n")

            number, event = d._enqueue_job("jobA", [])
            d._run_job("jobA", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT name, reason FROM builds WHERE name='jobC'"
            ).fetchone()
            db.close()
            self.assertIsNotNone(row, "jobC should have been enqueued on failure")
            self.assertEqual(row[1], "pipeline:jobA")

    def test_no_conf_no_downstream(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "solo")

            number, event = d._enqueue_job("solo", [])
            d._run_job("solo", number, [])

            db = d._connect_db()
            rows = db.execute(
                "SELECT COUNT(*) FROM builds WHERE name != 'solo'"
            ).fetchone()
            db.close()
            self.assertEqual(rows[0], 0, "No downstream jobs should be enqueued")

    def test_skips_already_running(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "jobA", script="#!/bin/bash\necho A\n")
            _create_job(tmpdir, "jobB", script="#!/bin/bash\necho B\n")
            _create_conf(tmpdir, "jobA", "on_success=jobB\n")

            # Pre-insert a running build for jobB
            db = d._connect_db()
            now = int(time.time())
            db.execute(
                "INSERT INTO builds (name, number, queuedAt, startedAt, result) "
                "VALUES (?, ?, ?, ?, 2)",
                ("jobB", 1, now, now),
            )
            db.commit()
            db.close()

            # Run jobA
            number, event = d._enqueue_job("jobA", [])
            d._run_job("jobA", number, [])

            # jobB should NOT have a second build
            db = d._connect_db()
            row = db.execute(
                "SELECT COUNT(*) FROM builds WHERE name='jobB'"
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 1, "Should not re-enqueue already running jobB")


class TestScheduler(unittest.TestCase):
    def test_check_schedules_enqueues(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "cronjob")
            _create_conf(tmpdir, "cronjob", "schedule=* * * * *\n")

            d._check_schedules()

            db = d._connect_db()
            row = db.execute(
                "SELECT name, reason FROM builds WHERE name='cronjob'"
            ).fetchone()
            db.close()
            self.assertIsNotNone(row, "cronjob should be enqueued")
            self.assertEqual(row[1], "scheduled")

    def test_check_schedules_skips_running(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "cronjob")
            _create_conf(tmpdir, "cronjob", "schedule=* * * * *\n")

            # Pre-insert a running build
            db = d._connect_db()
            now = int(time.time())
            db.execute(
                "INSERT INTO builds (name, number, queuedAt, startedAt, result) "
                "VALUES (?, ?, ?, ?, 2)",
                ("cronjob", 1, now, now),
            )
            db.commit()
            db.close()

            d._check_schedules()

            db = d._connect_db()
            row = db.execute(
                "SELECT COUNT(*) FROM builds WHERE name='cronjob'"
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 1, "Should not enqueue when already running")

    def test_check_schedules_prevents_duplicate(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "cronjob")
            _create_conf(tmpdir, "cronjob", "schedule=* * * * *\n")

            d._check_schedules()
            # Manually complete the first build so the "already running" check doesn't skip
            db = d._connect_db()
            db.execute(
                "UPDATE builds SET result=5 WHERE name='cronjob' AND number=1"
            )
            db.commit()
            db.close()

            d._check_schedules()

            db = d._connect_db()
            row = db.execute(
                "SELECT COUNT(*) FROM builds WHERE name='cronjob'"
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 1, "Should not enqueue twice in the same minute")


def _create_hook(home, name, suffix, script="#!/bin/bash\nexit 0\n"):
    """Write a hook script (.before or .after) into the jobs dir."""
    hook_path = Path(home) / "cfg" / "jobs" / f"{name}.{suffix}"
    hook_path.write_text(script)
    hook_path.chmod(0o755)


class TestJobTimeout(unittest.TestCase):
    def test_job_completes_within_timeout(self):
        """Job with timeout=10 that finishes in <1s should succeed."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "fast", script="#!/bin/bash\necho fast\n")
            _create_conf(tmpdir, "fast", "timeout=10\n")

            number, event = d._enqueue_job("fast", [])
            d._run_job("fast", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result, output FROM builds WHERE name='fast' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 5)  # success
            self.assertIn("fast", row[1])

    def test_job_killed_on_timeout(self):
        """Job with timeout=1 that sleeps for 60s should be killed and result=4."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "slow", script="#!/bin/bash\necho starting\nsleep 60\n")
            _create_conf(tmpdir, "slow", "timeout=1\n")

            number, event = d._enqueue_job("slow", [])
            d._run_job("slow", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result, output FROM builds WHERE name='slow' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 4)  # failed (timeout)
            self.assertIn("timed out", row[1])

    def test_no_timeout_by_default(self):
        """Job without timeout conf runs normally."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "normal", script="#!/bin/bash\necho normal\n")

            number, event = d._enqueue_job("normal", [])
            d._run_job("normal", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result, output FROM builds WHERE name='normal' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 5)  # success
            self.assertIn("normal", row[1])


class TestBeforeHook(unittest.TestCase):
    def test_before_hook_runs(self):
        """Create .before script that creates a marker file. Verify marker exists."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "hookjob", script="#!/bin/bash\necho main\n")
            marker = Path(tmpdir) / "before_marker"
            _create_hook(tmpdir, "hookjob", "before",
                         f"#!/bin/bash\ntouch {marker}\n")

            number, event = d._enqueue_job("hookjob", [])
            d._run_job("hookjob", number, [])

            self.assertTrue(marker.exists(), ".before hook should have created marker file")

    def test_before_hook_failure_skips_job(self):
        """Create .before script that exits 1. Verify main job didn't run."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            marker = Path(tmpdir) / "main_marker"
            _create_job(tmpdir, "hookjob",
                        script=f"#!/bin/bash\ntouch {marker}\necho main\n")
            _create_hook(tmpdir, "hookjob", "before",
                         "#!/bin/bash\nexit 1\n")

            number, event = d._enqueue_job("hookjob", [])
            d._run_job("hookjob", number, [])

            self.assertFalse(marker.exists(), "Main job should not have run")
            db = d._connect_db()
            row = db.execute(
                "SELECT result, output FROM builds WHERE name='hookjob' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 4)  # failed
            self.assertIn(".before hook failed", row[1])

    def test_no_before_hook(self):
        """Job without .before runs normally."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "nohook", script="#!/bin/bash\necho ok\n")

            number, event = d._enqueue_job("nohook", [])
            d._run_job("nohook", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result FROM builds WHERE name='nohook' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 5)


class TestAfterHook(unittest.TestCase):
    def test_after_hook_runs(self):
        """Create .after script that creates a marker file. Verify marker exists."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "hookjob", script="#!/bin/bash\necho main\n")
            marker = Path(tmpdir) / "after_marker"
            _create_hook(tmpdir, "hookjob", "after",
                         f"#!/bin/bash\ntouch {marker}\n")

            number, event = d._enqueue_job("hookjob", [])
            d._run_job("hookjob", number, [])

            self.assertTrue(marker.exists(), ".after hook should have created marker file")

    def test_after_hook_gets_result(self):
        """Create .after script that writes $RESULT to a file. Verify file contains '5'."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "hookjob", script="#!/bin/bash\necho success\n")
            result_file = Path(tmpdir) / "result_code"
            _create_hook(tmpdir, "hookjob", "after",
                         f"#!/bin/bash\necho -n $RESULT > {result_file}\n")

            number, event = d._enqueue_job("hookjob", [])
            d._run_job("hookjob", number, [])

            self.assertTrue(result_file.exists())
            self.assertEqual(result_file.read_text(), "5")

    def test_after_hook_runs_on_failure(self):
        """Create .after with marker. Job fails. Verify marker still created."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "failjob", exit_code=1)
            marker = Path(tmpdir) / "after_marker"
            _create_hook(tmpdir, "failjob", "after",
                         f"#!/bin/bash\ntouch {marker}\n")

            number, event = d._enqueue_job("failjob", [])
            d._run_job("failjob", number, [])

            self.assertTrue(marker.exists(), ".after hook should run even when job fails")

    def test_after_hook_failure_doesnt_change_result(self):
        """.after hook fails. Verify main job result is still success."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "hookjob", script="#!/bin/bash\necho ok\n")
            _create_hook(tmpdir, "hookjob", "after",
                         "#!/bin/bash\nexit 1\n")

            number, event = d._enqueue_job("hookjob", [])
            d._run_job("hookjob", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result FROM builds WHERE name='hookjob' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 5, "After hook failure should not change result")


class TestOnCompleteHook(unittest.TestCase):
    def test_on_complete_runs(self):
        """Create on_complete script that creates marker. Verify marker."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "compjob", script="#!/bin/bash\necho done\n")
            marker = Path(tmpdir) / "complete_marker"
            notify_script = Path(tmpdir) / "cfg" / "jobs" / "notify.sh"
            notify_script.write_text(f"#!/bin/bash\ntouch {marker}\n")
            notify_script.chmod(0o755)
            _create_conf(tmpdir, "compjob", "on_complete=notify.sh\n")

            number, event = d._enqueue_job("compjob", [])
            d._run_job("compjob", number, [])

            self.assertTrue(marker.exists(), "on_complete script should have created marker")

    def test_on_complete_gets_env(self):
        """Script writes $RESULT_NAME to a file. Verify 'success'."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "compjob", script="#!/bin/bash\necho ok\n")
            result_file = Path(tmpdir) / "result_name"
            notify_script = Path(tmpdir) / "cfg" / "jobs" / "notify.sh"
            notify_script.write_text(f"#!/bin/bash\necho -n $RESULT_NAME > {result_file}\n")
            notify_script.chmod(0o755)
            _create_conf(tmpdir, "compjob", "on_complete=notify.sh\n")

            number, event = d._enqueue_job("compjob", [])
            d._run_job("compjob", number, [])

            self.assertTrue(result_file.exists())
            self.assertEqual(result_file.read_text(), "success")

    def test_on_complete_runs_on_failure(self):
        """Job fails. on_complete still runs. Verify marker."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "compjob", exit_code=1)
            marker = Path(tmpdir) / "complete_marker"
            notify_script = Path(tmpdir) / "cfg" / "jobs" / "notify.sh"
            notify_script.write_text(f"#!/bin/bash\ntouch {marker}\n")
            notify_script.chmod(0o755)
            _create_conf(tmpdir, "compjob", "on_complete=notify.sh\n")

            number, event = d._enqueue_job("compjob", [])
            d._run_job("compjob", number, [])

            self.assertTrue(marker.exists(), "on_complete should run even on failure")

    def test_on_complete_missing_script(self):
        """Configure a nonexistent script. Job still succeeds (warning logged)."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "compjob", script="#!/bin/bash\necho ok\n")
            _create_conf(tmpdir, "compjob", "on_complete=nonexistent.sh\n")

            number, event = d._enqueue_job("compjob", [])
            d._run_job("compjob", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result FROM builds WHERE name='compjob' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 5, "Job should still succeed with missing on_complete script")


# ===================================================================
# Fix: POSIX cron OR logic (dom AND dow both restricted)
# ===================================================================

class TestCronPosixOrLogic(unittest.TestCase):
    def test_dom_or_dow_both_restricted(self):
        """POSIX: '0 0 15 * 5' matches the 15th of any month OR any Friday."""
        # 2026-03-15 is a Sunday (not Friday), but it IS the 15th
        dt_15th = datetime(2026, 3, 15, 0, 0)
        self.assertTrue(_cron_matches("0 0 15 * 5", dt_15th),
                        "Should match: day-of-month is 15")

        # 2026-03-20 is a Friday, but NOT the 15th
        dt_friday = datetime(2026, 3, 20, 0, 0)
        self.assertTrue(_cron_matches("0 0 15 * 5", dt_friday),
                        "Should match: day-of-week is Friday")

        # 2026-03-18 is a Wednesday AND not the 15th — should NOT match
        dt_neither = datetime(2026, 3, 18, 0, 0)
        self.assertFalse(_cron_matches("0 0 15 * 5", dt_neither),
                         "Should not match: neither 15th nor Friday")

    def test_dom_star_dow_restricted_uses_and_logic(self):
        """When only dow is restricted (dom is *), use AND logic."""
        # 2026-03-20 is a Friday
        dt_friday = datetime(2026, 3, 20, 0, 0)
        self.assertTrue(_cron_matches("0 0 * * 5", dt_friday))

        # 2026-03-18 is a Wednesday
        dt_wed = datetime(2026, 3, 18, 0, 0)
        self.assertFalse(_cron_matches("0 0 * * 5", dt_wed))

    def test_dom_restricted_dow_star_uses_and_logic(self):
        """When only dom is restricted (dow is *), use AND logic."""
        dt_15th = datetime(2026, 3, 15, 0, 0)
        self.assertTrue(_cron_matches("0 0 15 * *", dt_15th))

        dt_20th = datetime(2026, 3, 20, 0, 0)
        self.assertFalse(_cron_matches("0 0 15 * *", dt_20th))


# ===================================================================
# Fix: Cron field validation rejects out-of-range values
# ===================================================================

class TestCronFieldValidation(unittest.TestCase):
    def test_minute_60_rejected(self):
        """Minute value 60 should not be in the result set (valid range 0-59)."""
        result = _parse_cron_field("60", 0, 59)
        self.assertEqual(result, set())

    def test_hour_24_rejected(self):
        result = _parse_cron_field("24", 0, 23)
        self.assertEqual(result, set())

    def test_day_0_rejected(self):
        result = _parse_cron_field("0", 1, 31)
        self.assertEqual(result, set())

    def test_month_13_rejected(self):
        result = _parse_cron_field("13", 1, 12)
        self.assertEqual(result, set())

    def test_dow_7_rejected(self):
        result = _parse_cron_field("7", 0, 6)
        self.assertEqual(result, set())

    def test_range_clamped_to_bounds(self):
        """A range like 55-65 should clamp to 55-59 for minutes."""
        result = _parse_cron_field("55-65", 0, 59)
        self.assertEqual(result, {55, 56, 57, 58, 59})

    def test_valid_boundary_values(self):
        """Boundary values should be accepted."""
        self.assertEqual(_parse_cron_field("0", 0, 59), {0})
        self.assertEqual(_parse_cron_field("59", 0, 59), {59})

    def test_malformed_cron_field_returns_empty(self):
        """Completely non-numeric cron field should return empty set."""
        result = _parse_cron_field("abc", 0, 59)
        self.assertEqual(result, set())


# ===================================================================
# Fix: _last_scheduled pruning (old entries cleaned up)
# ===================================================================

class TestLastScheduledPruning(unittest.TestCase):
    def test_stale_entries_pruned(self):
        """Old entries in _last_scheduled should be cleaned up by _check_schedules."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "prunejob")
            # No schedule in conf, so it won't match, but we can still test pruning
            _create_conf(tmpdir, "prunejob", "# no schedule\n")

            # Seed with stale entries (old minute stamps)
            d._last_scheduled[("oldjob", "2020-01-01T00:00")] = True
            d._last_scheduled[("oldjob2", "2020-01-01T00:01")] = True

            # Also add a "current" entry
            now_iso = datetime.now().strftime("%Y-%m-%dT%H:%M")
            d._last_scheduled[("currentjob", now_iso)] = True

            d._check_schedules()

            # Stale entries should be removed; current entry may or may not be
            # (depends on exact pruning logic), but stale ones definitely should be gone
            self.assertNotIn(("oldjob", "2020-01-01T00:00"), d._last_scheduled)
            self.assertNotIn(("oldjob2", "2020-01-01T00:01"), d._last_scheduled)


# ===================================================================
# Fix: Abort intent tracking (process catching SIGTERM still gets result=3)
# ===================================================================

class TestAbortIntentTracking(unittest.TestCase):
    def test_abort_intent_results_in_aborted(self):
        """If abort is requested, the build should get result=3 even if the
        process catches SIGTERM and exits with code 0."""
        tmpdir = tempfile.mkdtemp(prefix="crank-test-")
        try:
            d = _make_daemon(tmpdir)
            d._init_db()
            # Script that traps SIGTERM and exits cleanly (exit 0)
            script = (
                "#!/bin/bash\n"
                "trap 'exit 0' TERM\n"
                "sleep 60 &\n"
                "wait\n"
            )
            _create_job(tmpdir, "trapjob", script=script)

            number, event = d._enqueue_job("trapjob", [])

            def run_job():
                d._run_job("trapjob", number, [])

            t = threading.Thread(target=run_job, daemon=True)
            t.start()

            try:
                # Wait for the job to be running
                deadline = time.time() + 5
                while time.time() < deadline:
                    with d.running_procs_lock:
                        if ("trapjob", number) in d.running_procs:
                            break
                    time.sleep(0.05)

                # Abort — this sets the abort intent flag
                d._abort_job("trapjob", number)

                t.join(timeout=15)
                self.assertFalse(t.is_alive())

                db = d._connect_db()
                row = db.execute(
                    "SELECT result FROM builds WHERE name='trapjob' AND number=?",
                    (number,),
                ).fetchone()
                db.close()
                self.assertEqual(row[0], 3, "Build should be aborted (result=3) due to abort intent")
            finally:
                d._abort_job("trapjob", number)
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)


# ===================================================================
# Fix: Protocol first-line isolation (embedded newlines don't corrupt)
# ===================================================================

class TestProtocolFirstLineIsolation(unittest.TestCase):
    def test_embedded_newline_in_command(self):
        """Only the first line should be processed; data after \\n is ignored."""
        tmpdir = tempfile.mkdtemp(prefix="crank-proto-")
        try:
            d = _make_daemon(tmpdir)
            d._init_db()
            (Path(tmpdir) / "cfg" / "jobs").mkdir(parents=True, exist_ok=True)
            _create_job(tmpdir, "goodjob", script="#!/bin/bash\necho ok\n")

            t = threading.Thread(target=d.start, daemon=True)
            t.start()
            try:
                deadline = time.time() + 10
                while not d.sock_path.exists() and time.time() < deadline:
                    time.sleep(0.05)
                self.assertTrue(d.sock_path.exists())
                time.sleep(0.2)

                resp = _send_command(d.sock_path, "queue goodjob\nevil command")
                self.assertTrue(resp.startswith("OK"), f"Expected OK, got: {resp}")
            finally:
                d._shutdown.set()
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)


# ===================================================================
# Fix: Tab-separated protocol preserves spaces in env values
# ===================================================================

class TestTabSeparatedProtocol(unittest.TestCase):
    def test_env_value_with_spaces_preserved(self):
        """Tab-separated protocol should preserve spaces in env values."""
        tmpdir = tempfile.mkdtemp(prefix="crank-tab-")
        try:
            d = _make_daemon(tmpdir)
            d._init_db()
            (Path(tmpdir) / "cfg" / "jobs").mkdir(parents=True, exist_ok=True)
            _create_job(tmpdir, "spacetest",
                        script="#!/bin/bash\necho $MSG\n")

            t = threading.Thread(target=d.start, daemon=True)
            t.start()
            try:
                deadline = time.time() + 10
                while not d.sock_path.exists() and time.time() < deadline:
                    time.sleep(0.05)
                self.assertTrue(d.sock_path.exists())
                time.sleep(0.2)

                # Send tab-separated command via _send_command helper
                resp = _send_command(d.sock_path, "run\tspacetest\tMSG=hello world")
                self.assertTrue(resp.startswith("OK"), f"Expected OK, got: {resp}")

                # Verify the env var was set correctly
                db = d._connect_db()
                row = db.execute(
                    "SELECT output FROM builds WHERE name='spacetest' AND number=1"
                ).fetchone()
                db.close()
                self.assertIn("hello world", row[0])
            finally:
                d._shutdown.set()
                t.join(timeout=15)
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)


# ===================================================================
# Fix: Zombie build cleanup in finally block
# ===================================================================

class TestZombieBuildCleanupInFinally(unittest.TestCase):
    def test_zombie_running_build_marked_failed_on_exception(self):
        """If _run_job crashes after setting result=2, the finally block
        should mark the build as failed (result=4)."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "zombietest", script="#!/bin/bash\necho test\n")

            number, event = d._enqueue_job("zombietest", [])

            # Manually set the build to running in the DB (as _run_job would)
            db = d._connect_db()
            now = int(time.time())
            db.execute(
                "UPDATE builds SET result=2, startedAt=? "
                "WHERE name=? AND number=?",
                (now, "zombietest", number),
            )
            db.commit()
            db.close()

            # Simulate the finally block that runs after an exception:
            # This is the safety net in _run_job's finally clause
            db = d._connect_db()
            try:
                cur = db.execute(
                    "UPDATE builds SET result=4, completedAt=? "
                    "WHERE name=? AND number=? AND result=2",
                    (int(time.time()), "zombietest", number),
                )
                if cur.rowcount > 0:
                    db.commit()
            finally:
                db.close()

            # Verify the build was marked failed
            db = d._connect_db()
            row = db.execute(
                "SELECT result, completedAt FROM builds WHERE name='zombietest' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 4, "Zombie build should be marked failed")
            self.assertIsNotNone(row[1], "completedAt should be set")

    def test_completed_build_not_affected_by_finally(self):
        """A build that completed normally (result=5) should NOT be changed by
        the finally block's safety-net UPDATE."""
        with tempfile.TemporaryDirectory() as tmpdir:
            d = _make_daemon(tmpdir)
            d._init_db()
            _create_job(tmpdir, "normaltest", script="#!/bin/bash\necho ok\n")

            number, event = d._enqueue_job("normaltest", [])
            d._run_job("normaltest", number, [])

            db = d._connect_db()
            row = db.execute(
                "SELECT result FROM builds WHERE name='normaltest' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 5, "Successfully completed build should remain success")

            # Verify the safety-net UPDATE doesn't affect completed builds
            db = d._connect_db()
            cur = db.execute(
                "UPDATE builds SET result=4, completedAt=? WHERE name=? AND number=? AND result=2",
                (int(time.time()), "normaltest", number),
            )
            db.commit()
            self.assertEqual(cur.rowcount, 0, "Safety-net should not match since result=5, not 2")
            # Confirm result is still 5 after the safety-net attempt
            row = db.execute(
                "SELECT result FROM builds WHERE name='normaltest' AND number=?",
                (number,),
            ).fetchone()
            db.close()
            self.assertEqual(row[0], 5, "Build result must remain success after safety-net attempt")


if __name__ == "__main__":
    unittest.main()
