"""Local Autopilot daemon for scheduled scout scans."""

from __future__ import annotations

import json
import logging
import os
import random
import signal
import subprocess
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo

from g_gremlin.scout.engine import ScoutEngine
from g_gremlin.scout.loader import load_scan_config
from g_gremlin.scout.seen_store import SeenStore

from .approvals import ApprovalTokenStore, default_approval_ttl_seconds
from .anomaly import detect_anomalies
from .briefing import compose_briefing, write_briefing
from .control_plane_job_queue import ControlPlaneJobQueue, control_plane_job_queue_db_path
from .contracts import ActorContext
from .executor import PlanExecutor
from .llm_provider import resolve_provider
from .pending_actions import build_pending_action, write_pending_action
from .planner import build_plan
from .policy import AutonomyPolicy, apply_policy_overlays, compute_policy_hash, load_policy
from .receipts import ReceiptWriter
from .slack_notify import create_client as slack_create_client
from .slack_notify import post_anomaly_alert, post_approval_request, post_briefing, post_receipt_summary
from .tools import get_default_registry
from .state import resolve_state_dir
from .suppress_store import SuppressStore

RUNTIME_POLICY_FILENAME = "policy_runtime.yaml"
RUNTIME_REVISION_MARKER = "policy_revision.marker"
RUNTIME_RELOAD_SIGNAL = "policy_reload.signal.json"
RUNTIME_STATUS_FILENAME = "daemon_runtime_status.json"

logger = logging.getLogger(__name__)


@dataclass
class DaemonLock:
    lock_path: Path
    ttl_seconds: int = 6 * 60 * 60

    def acquire(self) -> bool:
        if self.lock_path.exists():
            data = _read_lock(self.lock_path)
            if data:
                pid = data.get("pid")
                started_at = _parse_iso(data.get("started_at"))
                if pid and _pid_alive(pid):
                    if started_at and (datetime.now(timezone.utc) - started_at).total_seconds() < self.ttl_seconds:
                        return False
                try:
                    self.lock_path.unlink()
                except Exception:
                    return False

        payload = {
            "pid": os.getpid(),
            "started_at": datetime.now(timezone.utc).isoformat(),
        }
        self.lock_path.parent.mkdir(parents=True, exist_ok=True)
        self.lock_path.write_text(json.dumps(payload), encoding="utf-8")
        return True

    def release(self) -> None:
        try:
            if self.lock_path.exists():
                self.lock_path.unlink()
        except Exception:
            pass


class AutopilotDaemon:
    def __init__(
        self,
        policy: AutonomyPolicy,
        *,
        state_dir: Path | None = None,
        profile: str = "nightly",
        only_new: bool = True,
        window_days: int = 7,
        config_path: Path | None = None,
        once: bool = False,
        bot_token: str | None = None,
        engine: ScoutEngine | None = None,
    ) -> None:
        self._base_policy = policy.model_copy(deep=True)
        self.policy = policy
        if state_dir is None:
            self.state_dir = resolve_state_dir(self._base_policy)
        else:
            self.state_dir = Path(state_dir)
        self.policy = apply_policy_overlays(self._base_policy, self.state_dir)
        self._runtime_policy_revision = -1
        self._runtime_policy_source = "base"
        self._active_policy_hash = compute_policy_hash(self.policy)
        self.profile = profile
        self.only_new = only_new
        self.window_days = window_days
        self.config_path = config_path
        self.once = once
        # self.state_dir is set above
        self.lock = DaemonLock(self.state_dir / "locks" / f"{self.policy.org_alias}.lock")
        self.seen_store = SeenStore(self.state_dir / "db" / "seen_store.sqlite")
        self.suppress_store = SuppressStore(self.state_dir / "db" / "suppress_store.sqlite")
        self.engine = engine
        self.registry = get_default_registry()
        self.slack_client = None
        self.slack_channel = None
        slack_cfg = _load_slack_file_config(self.state_dir)
        env_bot_token = os.getenv("G_GREMLIN_SLACK_BOT_TOKEN")
        env_channel = os.getenv("G_GREMLIN_SLACK_CHANNEL")
        resolved_bot_token = str(bot_token or env_bot_token or slack_cfg.get("bot_token") or "").strip()
        resolved_channel = (
            (env_channel or "").strip()
            or str(slack_cfg.get("channel") or "").strip()
            or (self.policy.defaults.slack_channel or "")
        )
        if resolved_bot_token and resolved_channel:
            try:
                self.slack_client = slack_create_client(resolved_bot_token)
                self.slack_channel = resolved_channel
            except Exception:
                self.slack_client = None
                self.slack_channel = None
        self._event_processor = None
        self._control_plane_job_queue = ControlPlaneJobQueue(control_plane_job_queue_db_path(self.state_dir))
        self._sync_runtime_policy(force=True)

    def _briefing_path_for_today(self) -> Path:
        day_key = datetime.now(timezone.utc).date().isoformat()
        return self.state_dir / "briefings" / f"{day_key}.json"

    def _generate_daily_briefing_if_needed(self) -> None:
        briefing_path = self._briefing_path_for_today()
        if briefing_path.exists():
            return
        try:
            llm = resolve_provider()
        except Exception:
            llm = None
        try:
            briefing = compose_briefing(self.state_dir, llm=llm, lookback_hours=24)
            write_briefing(self.state_dir, briefing, day_key=datetime.now(timezone.utc).date().isoformat())
            if self.slack_client and self.slack_channel:
                post_briefing(self.slack_client, self.slack_channel, briefing)
        except Exception:
            logger.warning("Failed to generate daily briefing in daemon loop.", exc_info=True)

    def _load_anomaly_alert_keys(self) -> set[str]:
        path = self.state_dir / "runtime" / "anomaly_alerts_sent.json"
        if not path.exists():
            return set()
        try:
            payload = json.loads(path.read_text(encoding="utf-8"))
        except Exception:
            return set()
        keys = payload.get("keys") if isinstance(payload, dict) else []
        if not isinstance(keys, list):
            return set()
        return {str(key).strip() for key in keys if str(key).strip()}

    def _save_anomaly_alert_keys(self, keys: set[str]) -> None:
        path = self.state_dir / "runtime" / "anomaly_alerts_sent.json"
        path.parent.mkdir(parents=True, exist_ok=True)
        payload = {
            "updated_at": datetime.now(timezone.utc).isoformat(),
            "keys": sorted(keys),
        }
        path.write_text(json.dumps(payload, indent=2), encoding="utf-8")

    def _post_anomaly_alerts_if_needed(self) -> None:
        if not self.slack_client or not self.slack_channel:
            return
        try:
            alerts = detect_anomalies(self.state_dir)
        except Exception:
            logger.warning("Failed to detect anomalies in daemon loop.", exc_info=True)
            return
        if not alerts:
            return

        today_key = datetime.now(timezone.utc).date().isoformat()
        sent_keys = self._load_anomaly_alert_keys()
        unsent_alerts = []
        unsent_keys: set[str] = set()
        for alert in alerts:
            metric_name = str(getattr(alert, "metric_name", "") or "").strip()
            direction = str(getattr(alert, "direction", "") or "").strip()
            key = f"{today_key}:{metric_name}:{direction}"
            if key in sent_keys:
                continue
            unsent_alerts.append(
                {
                    "metric_name": metric_name,
                    "current_value": float(getattr(alert, "current_value", 0.0) or 0.0),
                    "baseline_mean": float(getattr(alert, "baseline_mean", 0.0) or 0.0),
                    "baseline_stddev": float(getattr(alert, "baseline_stddev", 0.0) or 0.0),
                    "deviation_factor": float(getattr(alert, "deviation_factor", 0.0) or 0.0),
                    "direction": direction,
                    "message": str(getattr(alert, "message", "") or "").strip(),
                }
            )
            unsent_keys.add(key)
        if not unsent_alerts:
            return
        try:
            post_anomaly_alert(self.slack_client, self.slack_channel, unsent_alerts)
            self._save_anomaly_alert_keys(sent_keys.union(unsent_keys))
        except Exception:
            logger.warning("Failed to post anomaly alerts to Slack.", exc_info=True)

    def run(self) -> None:
        if not self.lock.acquire():
            raise RuntimeError("Autopilot daemon already running.")
        try:
            self._generate_daily_briefing_if_needed()
            while True:
                self._sync_runtime_policy()
                self._process_pending_crm_events()
                self.run_once()
                self._generate_daily_briefing_if_needed()
                self._post_anomaly_alerts_if_needed()
                last_scan_at = datetime.now(timezone.utc).isoformat()
                if self.once:
                    scan_cfg = load_scan_config(self.config_path)
                    _write_heartbeat(
                        self.state_dir,
                        self.profile,
                        last_scan_at=last_scan_at,
                        active_policy_hash=self._active_policy_hash,
                        runtime_policy_revision=self._runtime_policy_revision,
                        runtime_policy_source=self._runtime_policy_source,
                        schedule_interval_hours=_coerce_positive_int(scan_cfg.schedule_interval_hours),
                    )
                    break
                scan_cfg = load_scan_config(self.config_path)
                schedule_interval_hours = _coerce_positive_int(scan_cfg.schedule_interval_hours)
                sleep_seconds = _next_sleep_seconds(
                    self.policy,
                    self.profile,
                    schedule_interval_hours=schedule_interval_hours,
                )
                next_run_at = (datetime.now(timezone.utc) + timedelta(seconds=sleep_seconds)).isoformat()
                _write_heartbeat(
                    self.state_dir,
                    self.profile,
                    next_run_at=next_run_at,
                    last_scan_at=last_scan_at,
                    active_policy_hash=self._active_policy_hash,
                    runtime_policy_revision=self._runtime_policy_revision,
                    runtime_policy_source=self._runtime_policy_source,
                    schedule_interval_hours=schedule_interval_hours,
                )
                time.sleep(sleep_seconds)
        finally:
            _remove_heartbeat(self.state_dir)
            self.lock.release()

    def _process_pending_crm_events(self, *, limit: int = 25) -> None:
        try:
            from .event_processor import CrmEventProcessor

            if self._event_processor is None:
                self._event_processor = CrmEventProcessor(
                    state_dir=self.state_dir,
                    policy=self.policy,
                    job_queue=self._control_plane_job_queue,
                )
            else:
                self._event_processor.policy = self.policy.model_copy(deep=True)
            self._event_processor.process_pending(limit=max(1, int(limit)))
        except Exception:
            # Fail-open: webhook processing must never stop scheduled scans.
            logger.warning("Failed to process pending CRM webhook events; continuing daemon loop.", exc_info=True)
            return

    def _sync_runtime_policy(self, *, force: bool = False) -> None:
        marker_revision = read_runtime_policy_revision(self.state_dir)
        if not force and marker_revision == self._runtime_policy_revision:
            return

        runtime_path = Path(self.state_dir) / RUNTIME_POLICY_FILENAME
        candidate_policy = self._base_policy
        source = "base"
        if runtime_path.exists():
            try:
                candidate_policy = load_policy(runtime_path)
                source = "runtime"
            except Exception:
                candidate_policy = self._base_policy
                source = "base"

        self.policy = apply_policy_overlays(candidate_policy, self.state_dir)
        self._runtime_policy_revision = marker_revision
        self._runtime_policy_source = source
        self._active_policy_hash = compute_policy_hash(self.policy)
        _write_runtime_status(
            self.state_dir,
            policy_hash=self._active_policy_hash,
            revision=self._runtime_policy_revision,
            source=self._runtime_policy_source,
        )

    def run_once(self) -> Path:
        scan_cfg = load_scan_config(self.config_path)
        resolved_crm = (scan_cfg.crm or os.getenv("G_GREMLIN_CRM") or "salesforce").lower()

        engine = self.engine or ScoutEngine(
            user_credentials=_get_user_credentials(),
            state_dir=self.state_dir,
        )
        scout_names = scan_cfg.scouts_enabled or [s.name for s in engine.list_scouts()]

        scan_result = engine.run_scan(
            scout_names,
            top_n=scan_cfg.top_n,
            per_scout_params=scan_cfg.per_scout_params,
            org_alias=self.policy.org_alias or scan_cfg.org_alias,
            crm=resolved_crm,
            hubspot_profile=scan_cfg.hubspot_profile,
            dynamics_profile=scan_cfg.dynamics_profile,
        )

        original_findings = scan_result.findings
        findings = original_findings
        if self.only_new:
            findings = self.seen_store.filter_new(findings, window_days=self.window_days)
        findings = self.suppress_store.filter_suppressed(findings)
        scan_result.findings = findings

        mark_seen_error = None
        try:
            self.seen_store.mark_seen(original_findings)
        except Exception as exc:
            mark_seen_error = str(exc)

        run_dir = self.state_dir / "receipts" / scan_result.run_id
        run_dir.mkdir(parents=True, exist_ok=True)
        scan_path = run_dir / "scan.json"
        with open(scan_path, "w", encoding="utf-8") as f:
            json.dump(scan_result.to_dict(include_fields=True, include_reason=True), f, indent=2)

        log_path = run_dir / "daemon.log"
        with open(log_path, "a", encoding="utf-8") as f:
            f.write(f"scan_id={scan_result.run_id} findings={sum(f.findings_count for f in findings)}\n")
            if mark_seen_error:
                f.write(f"warn=seen_store_mark_failed error={mark_seen_error}\n")

        plan = build_plan(scan_result, self.registry)
        if not plan.steps:
            with open(log_path, "a", encoding="utf-8") as f:
                f.write("info=no_actionable_findings\n")
            return run_dir

        executor = PlanExecutor(self.policy, self.registry, self.state_dir)
        receipt = executor.execute(plan)
        writer = ReceiptWriter(self.state_dir)
        writer.write(receipt, plan)

        if receipt.outcome == "approval_required":
            pending_dir = self.state_dir / "pending" / plan.run_id
            pending_dir.mkdir(parents=True, exist_ok=True)
            pending_plan_path = pending_dir / "plan.json"
            with open(pending_plan_path, "w", encoding="utf-8") as f:
                json.dump(plan.to_dict(), f, indent=2)
            write_pending_action(
                self.state_dir,
                build_pending_action(
                    plan=plan,
                    actor=ActorContext(
                        actor_id="daemon:scheduler",
                        roles=["admin"],
                        auth_source="daemon",
                    ),
                ),
            )
            store = ApprovalTokenStore(self.state_dir / "db" / "approval_tokens.sqlite")
            token = store.create_token(
                plan_hash=plan.plan_hash or "",
                policy_hash=plan.policy_hash or "",
                run_id=plan.run_id,
                ttl_seconds=default_approval_ttl_seconds(),
            ).token
            if self.slack_client and self.slack_channel:
                post_approval_request(self.slack_client, self.slack_channel, plan, token)
            return run_dir

        if receipt.outcome in ("denied", "budget_exceeded"):
            return run_dir

        if self.slack_client and self.slack_channel:
            post_receipt_summary(self.slack_client, self.slack_channel, receipt)

        return run_dir


def _get_user_credentials() -> set[str]:
    try:
        from g_gremlin.credentials.provider import CredentialProvider

        provider = CredentialProvider()
        return set(provider.list_configured_integrations())
    except Exception:
        return set()


def _pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    if sys.platform == "win32":
        try:
            import ctypes
            from ctypes import wintypes

            kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
            kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
            kernel32.OpenProcess.restype = wintypes.HANDLE
            kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
            kernel32.CloseHandle.restype = wintypes.BOOL

            PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
            handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
            if not handle:
                return False
            kernel32.CloseHandle(handle)
            return True
        except Exception:
            return False
    try:
        os.kill(pid, 0)
        return True
    except Exception:
        return False


def _read_lock(path: Path) -> dict[str, Any] | None:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return None


def _parse_iso(value: str | None) -> datetime | None:
    if not value:
        return None
    try:
        return datetime.fromisoformat(value.replace("Z", "+00:00"))
    except Exception:
        return None


def _next_sleep_seconds(
    policy: AutonomyPolicy,
    profile: str,
    *,
    schedule_interval_hours: int | None = None,
) -> int:
    if schedule_interval_hours and schedule_interval_hours > 0:
        return max(60, int(schedule_interval_hours) * 60 * 60)

    schedule = policy.schedules.get(profile)
    now_utc = datetime.now(timezone.utc)

    if schedule and schedule.cron:
        parsed = _parse_simple_cron(schedule.cron)
        if parsed:
            minute, hour = parsed
            tz = _resolve_timezone(schedule.timezone)
            now_local = now_utc.astimezone(tz)
            next_run_local = _next_daily_time(now_local, hour, minute)
            next_run_local = _apply_quiet_hours(next_run_local, schedule.quiet_hours)
            next_run_local = _apply_jitter(next_run_local, schedule.jitter_minutes)
            next_run_local = _apply_quiet_hours(next_run_local, schedule.quiet_hours)
            next_run = next_run_local.astimezone(timezone.utc)
            delta = max((next_run - now_utc).total_seconds(), 60)
            return int(delta)

    if profile == "hourly":
        return 3600
    if profile == "nightly":
        next_run = _next_daily_time(now_utc, 2, 0)
        delta = max((next_run - now_utc).total_seconds(), 60)
        return int(delta)

    return 3600


def _coerce_positive_int(value: Any) -> int | None:
    try:
        candidate = int(value)
    except Exception:
        return None
    if candidate <= 0:
        return None
    return candidate


def _parse_simple_cron(expr: str) -> tuple[int, int] | None:
    parts = expr.split()
    if len(parts) != 5:
        return None
    minute_str, hour_str, day_str, month_str, weekday_str = parts
    if any(part not in ("*", "*/1") for part in (day_str, month_str, weekday_str)):
        return None
    if minute_str.isdigit() and hour_str.isdigit():
        return int(minute_str), int(hour_str)
    return None


def _next_daily_time(now: datetime, hour: int, minute: int) -> datetime:
    candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
    if candidate <= now:
        candidate = candidate + timedelta(days=1)
    return candidate


def _resolve_timezone(name: str) -> ZoneInfo:
    try:
        return ZoneInfo(name)
    except Exception:
        return ZoneInfo("UTC")


def _apply_jitter(next_run: datetime, jitter_minutes: int) -> datetime:
    if not jitter_minutes or jitter_minutes <= 0:
        return next_run
    offset = random.randint(0, int(jitter_minutes * 60))
    return next_run + timedelta(seconds=offset)


def _parse_time_token(token: str) -> tuple[int, int] | None:
    if ":" not in token:
        return None
    parts = token.split(":")
    if len(parts) != 2:
        return None
    hour_str, minute_str = parts
    if not (hour_str.isdigit() and minute_str.isdigit()):
        return None
    hour = int(hour_str)
    minute = int(minute_str)
    if hour < 0 or hour > 23 or minute < 0 or minute > 59:
        return None
    return hour, minute


def _parse_quiet_hours(windows: list[str]) -> list[tuple[tuple[int, int], tuple[int, int]]]:
    parsed: list[tuple[tuple[int, int], tuple[int, int]]] = []
    for window in windows:
        if "-" not in window:
            continue
        start_str, end_str = window.split("-", 1)
        start = _parse_time_token(start_str.strip())
        end = _parse_time_token(end_str.strip())
        if start and end:
            parsed.append((start, end))
    return parsed


def _in_quiet_hours(now: datetime, start: tuple[int, int], end: tuple[int, int]) -> bool:
    hour, minute = now.hour, now.minute
    start_h, start_m = start
    end_h, end_m = end
    now_minutes = hour * 60 + minute
    start_minutes = start_h * 60 + start_m
    end_minutes = end_h * 60 + end_m
    if start_minutes == end_minutes:
        return False
    if start_minutes < end_minutes:
        return start_minutes <= now_minutes < end_minutes
    return now_minutes >= start_minutes or now_minutes < end_minutes


def _next_after_quiet(now: datetime, start: tuple[int, int], end: tuple[int, int]) -> datetime:
    start_minutes = start[0] * 60 + start[1]
    end_minutes = end[0] * 60 + end[1]
    if start_minutes < end_minutes:
        return now.replace(hour=end[0], minute=end[1], second=0, microsecond=0)
    if now.hour * 60 + now.minute >= start_minutes:
        candidate = (now + timedelta(days=1)).replace(
            hour=end[0],
            minute=end[1],
            second=0,
            microsecond=0,
        )
        return candidate
    return now.replace(hour=end[0], minute=end[1], second=0, microsecond=0)


def _apply_quiet_hours(next_run: datetime, windows: list[str]) -> datetime:
    parsed = _parse_quiet_hours(windows)
    if not parsed:
        return next_run
    candidate = next_run
    while True:
        blocked = False
        for start, end in parsed:
            if _in_quiet_hours(candidate, start, end):
                candidate = _next_after_quiet(candidate, start, end)
                blocked = True
                break
        if not blocked:
            return candidate


def _load_slack_file_config(state_dir: Path) -> dict[str, Any]:
    config_path = Path(state_dir) / "config" / "slack.json"
    if not config_path.exists():
        return {}
    try:
        payload = json.loads(config_path.read_text(encoding="utf-8"))
        if isinstance(payload, dict):
            return payload
    except Exception:
        return {}
    return {}


def _runtime_dir(state_dir: Path, *, create: bool = True) -> Path:
    path = Path(state_dir) / "runtime"
    if create:
        path.mkdir(parents=True, exist_ok=True)
    return path


def _runtime_revision_marker_path(state_dir: Path) -> Path:
    return _runtime_dir(state_dir) / RUNTIME_REVISION_MARKER


def _runtime_reload_signal_path(state_dir: Path) -> Path:
    return _runtime_dir(state_dir) / RUNTIME_RELOAD_SIGNAL


def _runtime_status_path(state_dir: Path) -> Path:
    return _runtime_dir(state_dir) / RUNTIME_STATUS_FILENAME


def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = path.with_suffix(f"{path.suffix}.tmp")
    tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
    os.replace(str(tmp_path), str(path))


def _write_text_atomic(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = path.with_name(f"{path.name}.tmp")
    tmp_path.write_text(text, encoding="utf-8")
    os.replace(str(tmp_path), str(path))


def read_runtime_policy_revision(state_dir: Path) -> int:
    marker_path = _runtime_revision_marker_path(state_dir)
    if not marker_path.exists():
        return 0
    try:
        raw = marker_path.read_text(encoding="utf-8").strip() or "0"
        revision = int(raw)
        return revision if revision >= 0 else 0
    except Exception:
        return 0


def bump_runtime_policy_revision(state_dir: Path) -> int:
    next_revision = read_runtime_policy_revision(state_dir) + 1
    _write_text_atomic(_runtime_revision_marker_path(state_dir), f"{next_revision}\n")
    return next_revision


def write_runtime_reload_signal(state_dir: Path, *, revision: int | None = None) -> Path:
    resolved_revision = read_runtime_policy_revision(state_dir) if revision is None else max(0, revision)
    signal_path = _runtime_reload_signal_path(state_dir)
    payload = {
        "revision": resolved_revision,
        "signaled_at": datetime.now(timezone.utc).isoformat(),
    }
    _write_json_atomic(signal_path, payload)
    return signal_path


def _write_runtime_status(state_dir: Path, *, policy_hash: str, revision: int, source: str) -> None:
    payload = {
        "daemon_active_policy_hash": policy_hash,
        "runtime_policy_revision": max(0, revision),
        "runtime_policy_source": source,
        "updated_at": datetime.now(timezone.utc).isoformat(),
    }
    _write_json_atomic(_runtime_status_path(state_dir), payload)


def read_runtime_status(state_dir: Path) -> dict[str, Any]:
    path = _runtime_status_path(state_dir)
    if not path.exists():
        return {}
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}
    return payload if isinstance(payload, dict) else {}


def _write_heartbeat(
    state_dir: Path,
    profile: str,
    *,
    next_run_at: str | None = None,
    last_scan_at: str | None = None,
    active_policy_hash: str | None = None,
    runtime_policy_revision: int | None = None,
    runtime_policy_source: str | None = None,
    schedule_interval_hours: int | None = None,
) -> None:
    """Write daemon heartbeat file atomically."""
    heartbeat_path = state_dir / "daemon_status.json"
    tmp_path = heartbeat_path.with_suffix(".json.tmp")
    payload = {
        "pid": os.getpid(),
        "started_at": None,
        "last_scan_at": last_scan_at,
        "next_run_at": next_run_at,
        "profile": profile,
        "active_policy_hash": active_policy_hash,
        "runtime_policy_revision": runtime_policy_revision,
        "runtime_policy_source": runtime_policy_source,
        "schedule_interval_hours": schedule_interval_hours,
        "updated_at": datetime.now(timezone.utc).isoformat(),
    }
    # Preserve started_at from the lock file if possible
    lock_dir = state_dir / "locks"
    if lock_dir.exists():
        for lp in lock_dir.glob("*.lock"):
            lock_data = _read_lock(lp)
            if lock_data and lock_data.get("pid") == os.getpid():
                payload["started_at"] = lock_data.get("started_at")
                break
    try:
        tmp_path.parent.mkdir(parents=True, exist_ok=True)
        tmp_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
        os.replace(str(tmp_path), str(heartbeat_path))
    except Exception:
        # Best-effort; don't crash the daemon over a heartbeat write failure.
        try:
            tmp_path.unlink(missing_ok=True)
        except Exception:
            pass


def _remove_heartbeat(state_dir: Path) -> None:
    """Remove daemon heartbeat file on shutdown."""
    try:
        (state_dir / "daemon_status.json").unlink(missing_ok=True)
    except Exception:
        pass


def stop_daemon(lock_path: Path) -> bool:
    data = _read_lock(lock_path)
    if not data:
        return False
    pid = data.get("pid")
    if not pid:
        return False
    if sys.platform == "win32":
        try:
            result = subprocess.run(
                ["taskkill", "/PID", str(pid), "/T", "/F"],
                capture_output=True,
                text=True,
                timeout=10,
            )
            return result.returncode == 0
        except Exception:
            return False
    try:
        os.kill(pid, signal.SIGTERM)
        return True
    except Exception:
        return False


def daemon_status(lock_path: Path) -> dict[str, Any]:
    data = _read_lock(lock_path)
    if not data:
        return {"running": False}
    pid = data.get("pid")
    alive = bool(pid and _pid_alive(pid))
    return {
        "running": alive,
        "pid": pid,
        "started_at": data.get("started_at"),
    }


__all__ = [
    "AutopilotDaemon",
    "read_runtime_policy_revision",
    "bump_runtime_policy_revision",
    "write_runtime_reload_signal",
    "read_runtime_status",
    "stop_daemon",
    "daemon_status",
]
