#!/usr/bin/env python3
"""crankd — Crank CI daemon.

Unix domain socket server that executes job scripts and records results
in a SQLite database. Protocol is line-based over the socket:

    run <job> [KEY=VAL ...]     — block until done, respond OK <number> or ERR <msg>
    queue <job> [KEY=VAL ...]   — enqueue, respond immediately OK <number>
    abort <job> <number>        — kill running proc, respond OK or ERR <msg>
"""

import json
import logging
import os
import queue
import re
import signal
import socket
import sqlite3
import subprocess
import sys
import threading
import time
from datetime import datetime
from pathlib import Path

log = logging.getLogger("crankd")

DEFAULT_CRANK_HOME = os.path.expanduser("~/.local/share/crank")
RESULTS = {0: "unknown", 1: "queued", 2: "running", 3: "aborted", 4: "failed", 5: "success"}
MAX_OUTPUT = 10 * 1024 * 1024  # 10MB cap for in-memory output

SCHEMA = """\
CREATE TABLE IF NOT EXISTS builds (
    name TEXT NOT NULL,
    number INTEGER NOT NULL,
    node TEXT DEFAULT '',
    queuedAt INTEGER,
    startedAt INTEGER,
    completedAt INTEGER,
    result INTEGER DEFAULT 0,
    output TEXT DEFAULT '',
    outputLen INTEGER DEFAULT 0,
    parentJob TEXT DEFAULT '',
    parentBuild INTEGER DEFAULT 0,
    reason TEXT DEFAULT '',
    params TEXT DEFAULT '',
    PRIMARY KEY (name, number)
)
"""

_POISON = None  # sentinel for worker shutdown


def _parse_cron_field(field, min_val, max_val):
    """Parse a single cron field and return a set of matching integers.

    Supports: *, specific numbers, comma lists, ranges (1-5), steps (*/5, 1-10/3).
    Values outside [min_val, max_val] are clamped. Malformed values are skipped.
    """
    result = set()
    try:
        for part in field.split(","):
            if "/" in part:
                range_part, step_str = part.split("/", 1)
                step = int(step_str)
                if step < 1:
                    continue
                if range_part == "*":
                    start, end = min_val, max_val
                elif "-" in range_part:
                    start, end = (int(x) for x in range_part.split("-", 1))
                else:
                    start, end = int(range_part), max_val
                start = max(start, min_val)
                end = min(end, max_val)
                result.update(range(start, end + 1, step))
            elif "-" in part:
                start, end = (int(x) for x in part.split("-", 1))
                start = max(start, min_val)
                end = min(end, max_val)
                result.update(range(start, end + 1))
            elif part == "*":
                result.update(range(min_val, max_val + 1))
            else:
                val = int(part)
                if min_val <= val <= max_val:
                    result.add(val)
    except (ValueError, TypeError):
        log.warning("Malformed cron field: %r", field)
    return result


def _cron_matches(cron_expr, dt):
    """Check if a datetime matches a 5-field cron expression (min hour dom month dow).

    Cron convention: Sunday=0. Python weekday: Monday=0. Convert with (weekday+1)%7.
    POSIX behavior: when both day-of-month and day-of-week are restricted (not *),
    the job runs if EITHER matches (OR logic). Otherwise AND logic.
    """
    fields = cron_expr.strip().split()
    if len(fields) != 5:
        return False
    minutes = _parse_cron_field(fields[0], 0, 59)
    hours = _parse_cron_field(fields[1], 0, 23)
    doms = _parse_cron_field(fields[2], 1, 31)
    months = _parse_cron_field(fields[3], 1, 12)
    dows = _parse_cron_field(fields[4], 0, 6)
    cron_dow = (dt.weekday() + 1) % 7

    if dt.minute not in minutes or dt.hour not in hours or dt.month not in months:
        return False

    dom_restricted = fields[2] != "*"
    dow_restricted = fields[4] != "*"

    if dom_restricted and dow_restricted:
        # POSIX: OR logic when both are restricted
        return dt.day in doms or cron_dow in dows
    else:
        return dt.day in doms and cron_dow in dows


class CrankDaemon:
    def __init__(self, home):
        self.home = Path(home)
        self.jobs_dir = self.home / "cfg" / "jobs"
        self.db_path = self.home / "crank.sqlite"
        self.sock_path = self.home / "crankd.sock"

        try:
            num_executors = int(os.environ.get("CRANK_NUM_EXECUTORS", "2").split("#")[0].strip())
        except ValueError:
            num_executors = 2
        self.num_executors = max(1, num_executors)

        self.job_queue = queue.Queue()
        self.build_lock = threading.Lock()
        self.running_procs = {}  # (name, number) -> subprocess.Popen
        self.running_procs_lock = threading.Lock()
        self.completion_events = {}  # (name, number) -> threading.Event
        self.completion_events_lock = threading.Lock()

        self._shutdown = threading.Event()
        self._server_sock = None
        self._last_scheduled = {}
        self._aborted_builds = set()  # track abort intent for result code
        self._aborted_builds_lock = threading.Lock()
        self._conn_semaphore = threading.Semaphore(64)  # cap concurrent client connections

    # ------------------------------------------------------------------
    # Database
    # ------------------------------------------------------------------

    def _connect_db(self):
        """Return a new SQLite connection with WAL mode."""
        db = sqlite3.connect(str(self.db_path), timeout=10)
        db.execute("PRAGMA journal_mode=WAL")
        db.execute("PRAGMA busy_timeout=5000")
        return db

    def _init_db(self):
        db = self._connect_db()
        try:
            db.execute(SCHEMA)
            db.commit()
            try:
                db.execute("ALTER TABLE builds ADD COLUMN params TEXT DEFAULT ''")
                db.commit()
            except sqlite3.OperationalError:
                pass  # column may already exist
        finally:
            db.close()

    def _next_build_number(self, db, job_name):
        """Allocate the next build number for a job. Must be called under build_lock."""
        row = db.execute(
            "SELECT MAX(number) FROM builds WHERE name=?", (job_name,)
        ).fetchone()
        return (row[0] or 0) + 1

    # ------------------------------------------------------------------
    # Job execution
    # ------------------------------------------------------------------

    def _validate_job_name(self, name):
        return bool(re.match(r'^[a-zA-Z0-9_-]+$', name))

    def _enqueue_job(self, job_name, env_pairs, reason="socket", parent_job="", parent_build=0):
        """Insert a queued build row and put work on the queue. Returns build number."""
        if not self._validate_job_name(job_name):
            raise ValueError(f"Invalid job name: {job_name!r}")

        script = self.jobs_dir / f"{job_name}.run"
        if not script.exists():
            raise FileNotFoundError(f"Job script not found: {script}")

        params_json = json.dumps(dict(p.split("=", 1) for p in env_pairs if "=" in p))

        with self.build_lock:
            db = self._connect_db()
            try:
                number = self._next_build_number(db, job_name)
                now = int(time.time())
                db.execute(
                    "INSERT INTO builds (name, number, queuedAt, result, reason, params, parentJob, parentBuild) "
                    "VALUES (?, ?, ?, 1, ?, ?, ?, ?)",
                    (job_name, number, now, reason, params_json, parent_job, parent_build),
                )
                db.commit()
            finally:
                db.close()

            # Register event BEFORE putting on queue (inside build_lock)
            # to prevent a fast worker from finishing before the event exists
            event = threading.Event()
            with self.completion_events_lock:
                self.completion_events[(job_name, number)] = event

            self.job_queue.put((job_name, number, env_pairs))

        return number, event

    def _run_job(self, job_name, number, env_pairs):
        """Execute a job script, capture output, update DB."""
        db = self._connect_db()
        try:
            script = self.jobs_dir / f"{job_name}.run"
            workspace = self.home / "run" / job_name / str(number)
            workspace.mkdir(parents=True, exist_ok=True)

            # Read job conf for timeout, on_complete, etc.
            conf = self._read_job_conf(job_name)
            job_timeout = None
            timeout_str = conf.get("timeout", "").strip()
            if timeout_str:
                try:
                    job_timeout = int(timeout_str)
                except ValueError:
                    log.warning("%s: invalid timeout value: %r", job_name, timeout_str)

            # Atomically transition from queued to running; skip if already aborted
            now = int(time.time())
            cur = db.execute(
                "UPDATE builds SET result=2, startedAt=? "
                "WHERE name=? AND number=? AND result=1",
                (now, job_name, number),
            )
            db.commit()
            if cur.rowcount == 0:
                log.info("%s #%d was aborted while queued, skipping", job_name, number)
                with self._aborted_builds_lock:
                    self._aborted_builds.discard((job_name, number))
                return

            # Build environment
            env = os.environ.copy()
            env["JOB"] = job_name
            env["RUN"] = str(number)
            env["WORKSPACE"] = str(workspace)
            for pair in env_pairs:
                if "=" in pair:
                    k, v = pair.split("=", 1)
                    env[k] = v

            result_code = 4  # failed
            output = ""
            max_output = MAX_OUTPUT
            skip_main = False

            # Run .before hook if it exists
            before_script = self.jobs_dir / f"{job_name}.before"
            if before_script.exists():
                log.info("%s #%d: running .before hook", job_name, number)
                try:
                    before_proc = subprocess.run(
                        [str(before_script)],
                        env=env,
                        cwd=str(workspace),
                        capture_output=True,
                        text=True,
                        timeout=60,
                    )
                    if before_proc.returncode != 0:
                        output = f"crankd: .before hook failed (exit {before_proc.returncode})\n"
                        output += before_proc.stdout + before_proc.stderr
                        result_code = 4
                        skip_main = True
                except subprocess.TimeoutExpired:
                    output = "crankd: .before hook timed out\n"
                    result_code = 4
                    skip_main = True
                except Exception as exc:
                    output = f"crankd: .before hook error: {exc}\n"
                    result_code = 4
                    skip_main = True

            if not skip_main:
                try:
                    # D3: Hold lock around Popen + registration to prevent abort race
                    with self.running_procs_lock:
                        proc = subprocess.Popen(
                            [str(script)],
                            stdout=subprocess.PIPE,
                            stderr=subprocess.STDOUT,
                            env=env,
                            cwd=str(workspace),
                            start_new_session=True,
                        )
                        self.running_procs[(job_name, number)] = proc

                    try:
                        # D2+D4: Read output incrementally with 10MB cap,
                        # stream to log file via a reader thread (needed for timeout support)
                        log_file = workspace / "log"
                        output_chunks = []
                        total_len = 0
                        truncated = False

                        def _read_output():
                            nonlocal total_len, truncated
                            try:
                                with open(log_file, 'w') as lf:
                                    for line_bytes in iter(proc.stdout.readline, b''):
                                        decoded = line_bytes.decode("utf-8", errors="replace")
                                        lf.write(decoded)
                                        lf.flush()
                                        if not truncated:
                                            if total_len + len(decoded) > max_output:
                                                truncated = True
                                                output_chunks.append(
                                                    "\n... output truncated at 10MB ...\n"
                                                )
                                            else:
                                                output_chunks.append(decoded)
                                                total_len += len(decoded)
                            except (ValueError, OSError):
                                pass  # pipe closed by main thread to unblock us

                        reader_thread = threading.Thread(target=_read_output, daemon=True)
                        reader_thread.start()

                        timed_out = False
                        try:
                            proc.wait(timeout=job_timeout)
                        except subprocess.TimeoutExpired:
                            timed_out = True
                            log.warning("%s #%d timed out after %ds", job_name, number, job_timeout)
                            try:
                                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
                            except (ProcessLookupError, OSError):
                                proc.terminate()
                            try:
                                proc.wait(timeout=5)
                            except subprocess.TimeoutExpired:
                                try:
                                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                                except (ProcessLookupError, OSError):
                                    proc.kill()
                                proc.wait()

                        # Close stdout pipe first so reader thread unblocks
                        try:
                            proc.stdout.close()
                        except OSError:
                            pass
                        reader_thread.join(timeout=5)
                        # If reader thread is still alive, snapshot chunks to avoid data race
                        if reader_thread.is_alive():
                            output_chunks = list(output_chunks)
                        output = "".join(output_chunks)

                        if timed_out:
                            output += f"\ncrankd: job timed out after {job_timeout}s\n"
                            try:
                                with open(log_file, 'a') as lf:
                                    lf.write(f"\ncrankd: job timed out after {job_timeout}s\n")
                            except OSError:
                                pass

                        # Check if abort was requested
                        with self._aborted_builds_lock:
                            was_aborted = (job_name, number) in self._aborted_builds
                            self._aborted_builds.discard((job_name, number))

                        if timed_out:
                            result_code = 4  # failed (timeout)
                        elif was_aborted:
                            result_code = 3  # aborted (user requested)
                        elif proc.returncode == 0:
                            result_code = 5  # success
                        elif proc.returncode < 0:
                            result_code = 3  # aborted (killed by signal)
                        else:
                            result_code = 4  # failed
                    finally:
                        with self.running_procs_lock:
                            self.running_procs.pop((job_name, number), None)
                except Exception as exc:
                    output = f"crankd: failed to execute job: {exc}\n"
                    result_code = 4

            # Run .after hook (always, regardless of result)
            after_script = self.jobs_dir / f"{job_name}.after"
            if after_script.exists():
                after_env = env.copy()
                after_env["RESULT"] = str(result_code)
                after_env["RESULT_NAME"] = RESULTS.get(result_code, "unknown")
                log.info("%s #%d: running .after hook", job_name, number)
                try:
                    subprocess.run(
                        [str(after_script)], env=after_env, cwd=str(workspace),
                        capture_output=True, text=True, timeout=60,
                    )
                except Exception:
                    log.exception("%s #%d: .after hook failed", job_name, number)

            # on_complete hook from conf
            on_complete = conf.get("on_complete", "").strip()
            if on_complete:
                complete_script = Path(on_complete)
                if not complete_script.is_absolute():
                    complete_script = self.jobs_dir / on_complete
                if complete_script.exists():
                    complete_env = env.copy()
                    complete_env["RESULT"] = str(result_code)
                    complete_env["RESULT_NAME"] = RESULTS.get(result_code, "unknown")
                    complete_env["BUILD_OUTPUT"] = output[:4096]
                    log.info("%s #%d: running on_complete: %s", job_name, number, on_complete)
                    try:
                        subprocess.run(
                            [str(complete_script)], env=complete_env, cwd=str(workspace),
                            capture_output=True, text=True, timeout=60,
                        )
                    except Exception:
                        log.exception("%s #%d: on_complete hook failed", job_name, number)
                else:
                    log.warning("%s #%d: on_complete script not found: %s", job_name, number, complete_script)

            # Record completion
            completed = int(time.time())
            db.execute(
                "UPDATE builds SET result=?, completedAt=?, output=?, outputLen=? "
                "WHERE name=? AND number=?",
                (result_code, completed, output, len(output), job_name, number),
            )
            db.commit()

            # Write state file
            state_file = self.home / f"{job_name}.last"
            try:
                state_file.write_text(RESULTS.get(result_code, "unknown"))
            except OSError:
                pass

            log.info("%s #%d finished: %s", job_name, number, RESULTS.get(result_code, "unknown"))

            try:
                self._trigger_downstream(job_name, number, result_code, env_pairs)
            except Exception:
                log.exception("Error triggering downstream for %s #%d", job_name, number)
        finally:
            # Safety net: if the build is still queued or running, mark it failed
            try:
                now = int(time.time())
                cur = db.execute(
                    "UPDATE builds SET result=4, completedAt=? "
                    "WHERE name=? AND number=? AND result IN (1, 2)",
                    (now, job_name, number),
                )
                if cur.rowcount > 0:
                    db.commit()
                    log.warning("%s #%d: marking zombie build as failed", job_name, number)
            except Exception:
                pass
            db.close()
            # Always signal completion so blocked clients don't hang
            with self.completion_events_lock:
                event = self.completion_events.pop((job_name, number), None)
            if event:
                event.set()

    def _abort_job(self, job_name, number):
        """Kill a running job's process."""
        # Record abort intent so _run_job knows to set result=aborted
        # even if the process catches SIGTERM and exits cleanly
        with self._aborted_builds_lock:
            self._aborted_builds.add((job_name, number))
        # Hold the lock through the kill to prevent PID-reuse race
        with self.running_procs_lock:
            proc = self.running_procs.get((job_name, number))
            if proc is not None:
                try:
                    os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
                except (ProcessLookupError, OSError):
                    try:
                        proc.terminate()
                    except (ProcessLookupError, OSError):
                        pass
                return True

        # Not running — atomically mark as aborted only if still queued
        db = self._connect_db()
        try:
            now = int(time.time())
            cur = db.execute(
                "UPDATE builds SET result=3, completedAt=? "
                "WHERE name=? AND number=? AND result=1",
                (now, job_name, number),
            )
            db.commit()
            if cur.rowcount > 0:
                with self._aborted_builds_lock:
                    self._aborted_builds.discard((job_name, number))
                with self.completion_events_lock:
                    event = self.completion_events.pop((job_name, number), None)
                if event:
                    event.set()
                return True
            # TOCTOU guard: build may have transitioned to running between our checks
            # Retry the running_procs check before giving up
            with self.running_procs_lock:
                proc = self.running_procs.get((job_name, number))
                if proc is not None:
                    try:
                        os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
                    except (ProcessLookupError, OSError):
                        try:
                            proc.terminate()
                        except (ProcessLookupError, OSError):
                            pass
                    return True
            # Build is neither running nor queued — clean up abort intent
            with self._aborted_builds_lock:
                self._aborted_builds.discard((job_name, number))
            return False
        finally:
            db.close()

    # ------------------------------------------------------------------
    # Pipeline chaining
    # ------------------------------------------------------------------

    def _read_job_conf(self, job_name):
        """Read $CRANK_HOME/cfg/jobs/<name>.conf and return a dict of key=value pairs.

        Returns empty dict if file doesn't exist. Skips comments (#) and blank lines.
        """
        conf_path = self.jobs_dir / f"{job_name}.conf"
        if not conf_path.exists():
            return {}
        result = {}
        text = conf_path.read_text()
        # Strip BOM if present
        if text.startswith("\ufeff"):
            text = text[1:]
        for line in text.splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            # Strip inline comments (# not inside a value)
            if "=" in line:
                k, v = line.split("=", 1)
                # Strip inline comment from value
                comment_idx = v.find(" #")
                if comment_idx >= 0:
                    v = v[:comment_idx]
                result[k.strip()] = v.strip()
        return result

    def _trigger_downstream(self, job_name, number, result_code, env_pairs):
        """Trigger downstream jobs based on the job's conf file."""
        conf = self._read_job_conf(job_name)
        if not conf:
            return

        if result_code == 5:
            key = "on_success"
        elif result_code in (3, 4):
            key = "on_failure"
        else:
            return

        downstream = conf.get(key, "")
        if not downstream:
            return

        for ds_name in (n.strip() for n in downstream.split(",")):
            if not ds_name:
                continue
            if not self._validate_job_name(ds_name):
                log.warning("Invalid downstream job name: %r", ds_name)
                continue
            script = self.jobs_dir / f"{ds_name}.run"
            if not script.exists():
                log.warning("Downstream job script not found: %s", script)
                continue

            # Check if already queued/running (prevent pile-up)
            db = self._connect_db()
            try:
                row = db.execute(
                    "SELECT COUNT(*) FROM builds WHERE name=? AND result IN (1, 2)",
                    (ds_name,),
                ).fetchone()
                if row[0] > 0:
                    log.info("Downstream %s already queued/running, skipping", ds_name)
                    continue
            finally:
                db.close()

            self._enqueue_job(
                ds_name, env_pairs,
                reason=f"pipeline:{job_name}",
                parent_job=job_name,
                parent_build=number,
            )
            log.info("Enqueued downstream %s from %s #%d", ds_name, job_name, number)

    # ------------------------------------------------------------------
    # Cron / Scheduling
    # ------------------------------------------------------------------

    def _scheduler_loop(self):
        """Background loop that checks cron schedules every 30 seconds."""
        while not self._shutdown.is_set():
            try:
                self._check_schedules()
            except Exception:
                log.exception("Error in scheduler loop")
            # Sleep in 1s increments for 30s total so shutdown is responsive
            for _ in range(30):
                if self._shutdown.is_set():
                    return
                time.sleep(1)

    def _check_schedules(self):
        """Scan job confs for schedule= and enqueue matching jobs."""
        now = datetime.now()
        minute_iso = now.strftime("%Y-%m-%dT%H:%M")

        # Prune entries not from the current minute to prevent unbounded growth
        stale = [k for k in self._last_scheduled if k[1] != minute_iso]
        for k in stale:
            del self._last_scheduled[k]

        conf_files = list(self.jobs_dir.glob("*.conf"))
        for conf_path in conf_files:
            job_name = conf_path.stem
            conf = self._read_job_conf(job_name)
            schedule = conf.get("schedule", "")
            if not schedule:
                continue

            # Check that a .run script exists
            script = self.jobs_dir / f"{job_name}.run"
            if not script.exists():
                continue

            # Check cron match
            if not _cron_matches(schedule, now):
                continue

            # Prevent duplicate scheduling within the same minute
            sched_key = (job_name, minute_iso)
            if sched_key in self._last_scheduled:
                continue

            # Skip if already queued/running
            db = self._connect_db()
            try:
                row = db.execute(
                    "SELECT COUNT(*) FROM builds WHERE name=? AND result IN (1, 2)",
                    (job_name,),
                ).fetchone()
                if row[0] > 0:
                    continue
            finally:
                db.close()

            self._last_scheduled[sched_key] = True
            self._enqueue_job(job_name, [], reason="scheduled")
            log.info("Scheduled job %s at %s", job_name, minute_iso)

    # ------------------------------------------------------------------
    # Worker threads
    # ------------------------------------------------------------------

    def _worker(self, worker_id):
        """Worker loop: pull jobs from queue and execute them."""
        log.info("Worker %d started", worker_id)
        while not self._shutdown.is_set():
            try:
                item = self.job_queue.get(timeout=1)
            except queue.Empty:
                continue

            if item is _POISON:
                self.job_queue.task_done()
                break

            job_name, number, env_pairs = item
            try:
                self._run_job(job_name, number, env_pairs)
            except Exception:
                log.exception("Worker %d: unhandled error running %s #%d",
                              worker_id, job_name, number)
                # Ensure completion event is fired so blocked clients don't hang
                with self.completion_events_lock:
                    event = self.completion_events.pop((job_name, number), None)
                if event:
                    event.set()
            finally:
                self.job_queue.task_done()

        log.info("Worker %d stopped", worker_id)

    # ------------------------------------------------------------------
    # Socket server
    # ------------------------------------------------------------------

    def _handle_client(self, conn):
        """Handle a single client connection."""
        if not self._conn_semaphore.acquire(timeout=5):
            try:
                conn.sendall(b"ERR too many connections\n")
            except OSError:
                pass
            finally:
                conn.close()
            return
        try:
            conn.settimeout(30)
            data = b""
            while True:
                chunk = conn.recv(4096)
                if not chunk:
                    break
                data += chunk
                if len(data) > 65536:
                    conn.sendall(b"ERR request too large\n")
                    return
                if b"\n" in data:
                    break

            # Isolate the first line only (ignore anything after first newline)
            raw = data.split(b"\n", 1)[0].decode("utf-8", errors="replace").strip()
            if not raw:
                return

            # Support tab-separated protocol (preserves spaces in env values)
            # Fall back to space-split for backward compat with simple commands
            if "\t" in raw:
                parts = [p for p in raw.split("\t") if p]
            else:
                parts = raw.split()
            cmd = parts[0].lower()

            if cmd in ("run", "queue"):
                if len(parts) < 2:
                    conn.sendall(b"ERR missing job name\n")
                    return
                job_name = parts[1]
                env_pairs = parts[2:]
                try:
                    number, event = self._enqueue_job(job_name, env_pairs)
                except (ValueError, FileNotFoundError) as exc:
                    conn.sendall(f"ERR {exc}\n".encode())
                    return

                if cmd == "queue":
                    conn.sendall(f"OK {number}\n".encode())
                    return

                # Blocking run — wait for completion, but check for shutdown
                while not event.wait(timeout=2):
                    if self._shutdown.is_set():
                        conn.sendall(b"ERR daemon shutting down\n")
                        return
                db = self._connect_db()
                try:
                    row = db.execute(
                        "SELECT result FROM builds WHERE name=? AND number=?",
                        (job_name, number),
                    ).fetchone()
                finally:
                    db.close()
                result_code = row[0] if row else 4
                if result_code == 5:
                    conn.sendall(f"OK {number}\n".encode())
                else:
                    conn.sendall(f"ERR {number} {RESULTS.get(result_code, 'unknown')}\n".encode())

            elif cmd == "abort":
                if len(parts) < 3:
                    conn.sendall(b"ERR usage: abort <job> <number>\n")
                    return
                job_name = parts[1]
                try:
                    number = int(parts[2])
                except ValueError:
                    conn.sendall(b"ERR invalid build number\n")
                    return
                if self._abort_job(job_name, number):
                    conn.sendall(b"OK\n")
                else:
                    conn.sendall(b"ERR no running or queued build found\n")

            else:
                conn.sendall(f"ERR unknown command: {cmd[:64]}\n".encode())

        except Exception:
            log.exception("Error handling client")
            try:
                conn.sendall(b"ERR internal error\n")
            except OSError:
                pass
        finally:
            conn.close()
            self._conn_semaphore.release()

    def _accept_loop(self):
        """Accept connections on the Unix socket."""
        self._server_sock.settimeout(1.0)
        while not self._shutdown.is_set():
            try:
                conn, _ = self._server_sock.accept()
            except socket.timeout:
                continue
            except OSError:
                if self._shutdown.is_set():
                    break
                raise
            t = threading.Thread(target=self._handle_client, args=(conn,), daemon=True)
            t.start()

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def _check_stale_socket(self):
        """Check if a daemon is already running on the socket."""
        if not self.sock_path.exists():
            return
        test_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        try:
            test_sock.settimeout(2)
            test_sock.connect(str(self.sock_path))
            print(f"crankd: another instance is already running on {self.sock_path}", file=sys.stderr)
            sys.exit(1)
        except (ConnectionRefusedError, OSError):
            log.info("Removing stale socket: %s", self.sock_path)
            self.sock_path.unlink(missing_ok=True)
        finally:
            test_sock.close()

    def start(self):
        """Start the daemon."""
        log.info("Starting crankd (home=%s, executors=%d)", self.home, self.num_executors)

        self._init_db()
        self._check_stale_socket()

        # Create socket
        self._server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self._server_sock.bind(str(self.sock_path))
        self.sock_path.chmod(0o660)
        self._server_sock.listen(16)
        log.info("Listening on %s", self.sock_path)

        # Start workers
        workers = []
        for i in range(self.num_executors):
            t = threading.Thread(target=self._worker, args=(i,), daemon=True)
            t.start()
            workers.append(t)

        # Start scheduler thread
        scheduler_thread = threading.Thread(target=self._scheduler_loop, daemon=True)
        scheduler_thread.start()

        # Handle signals (only works on main thread)
        def _shutdown_handler(signum, frame):
            log.info("Received signal %d, shutting down...", signum)
            self._shutdown.set()

        if threading.current_thread() is threading.main_thread():
            signal.signal(signal.SIGTERM, _shutdown_handler)
            signal.signal(signal.SIGINT, _shutdown_handler)

        # Accept loop (runs on main thread)
        try:
            self._accept_loop()
        finally:
            self._server_sock.close()
            self.sock_path.unlink(missing_ok=True)

            # Poison pill workers
            for _ in workers:
                self.job_queue.put(_POISON)

            # Wait for workers to finish (with timeout)
            deadline = time.time() + 30
            for t in workers:
                remaining = max(0, deadline - time.time())
                t.join(timeout=remaining)

            # Wait for scheduler thread
            scheduler_thread.join(timeout=5)

            # Kill any remaining processes (entire process group)
            with self.running_procs_lock:
                stale_keys = list(self.running_procs.keys())
                for key, proc in list(self.running_procs.items()):
                    log.warning("Killing still-running job %s #%d", key[0], key[1])
                    try:
                        os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
                    except (ProcessLookupError, OSError):
                        try:
                            proc.kill()
                        except (ProcessLookupError, OSError):
                            pass

            # D5: Update zombie result=2 rows after SIGKILL so they don't stay
            # as "running" forever
            if stale_keys:
                try:
                    db = self._connect_db()
                    try:
                        now = int(time.time())
                        for job_name, number in stale_keys:
                            db.execute(
                                "UPDATE builds SET result=3, completedAt=?, "
                                "output=output||'\ncrankd: killed during shutdown\n' "
                                "WHERE name=? AND number=? AND result=2",
                                (now, job_name, number),
                            )
                        db.commit()
                    finally:
                        db.close()
                except (sqlite3.OperationalError, OSError):
                    log.warning("Could not update zombie builds (DB unavailable)")

            log.info("crankd stopped.")


def main():
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(name)s %(levelname)s %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )

    home = os.environ.get("CRANK_HOME", DEFAULT_CRANK_HOME)

    # Read config file if it exists ($CRANK_HOME/crank.conf)
    conf_path = Path(home) / "crank.conf"
    if conf_path.exists():
        text = conf_path.read_text()
        if text.startswith("\ufeff"):
            text = text[1:]
        for line in text.splitlines():
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                key, val = line.split("=", 1)
                # Strip inline comments
                comment_idx = val.find(" #")
                if comment_idx >= 0:
                    val = val[:comment_idx]
                os.environ.setdefault(key.strip(), val.strip())

    daemon = CrankDaemon(home)
    daemon.start()


if __name__ == "__main__":
    main()
