"""Main daemon loop: poll → collect → push → save state → sleep."""

from __future__ import annotations

import json
import logging
import os
import signal
import socket
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
import time
import urllib.error
import urllib.parse
import urllib.request

from qc_trace import __version__
from qc_trace.daemon.collector import CollectResult, collect_file, extract_session_meta
from qc_trace.daemon.config import DaemonConfig
from qc_trace.daemon.push_status import get_recent_errors_for_heartbeat, init_session, read_push_status, record_push, record_push_metrics, record_update_check
from qc_trace.daemon.pusher import Pusher
from qc_trace.daemon.state import StateManager
from qc_trace.daemon.watcher import ChangedFile, FileWatcher
from qc_trace.utils.repo_resolver import resolve_global_identity, resolve_repo

logger = logging.getLogger("quickcall")

_UPDATE_CHECK_INTERVAL = 300  # 5 minutes
_HEARTBEAT_INTERVAL = 300  # 5 minutes between heartbeat POSTs


class Daemon:
    """The quickcall daemon process."""

    def __init__(self, config: DaemonConfig | None = None) -> None:
        self.config = config or DaemonConfig()
        self.state_mgr = StateManager(state_file=self.config.state_file)
        self.watcher = FileWatcher(self.config, self.state_mgr)
        self.pusher = Pusher(config=self.config)
        self._shutdown = False
        self.device_name: str | None = None
        self.device_id: str | None = None
        self.global_email: str | None = None
        self.global_name: str | None = None
        self._last_update_check: float = 0.0
        self._last_heartbeat: float = 0.0
        self._pending_commands: list[dict] = []
        self._paused: bool = False

    def run(self) -> None:
        """Main daemon loop."""
        self._setup_signals()
        self._write_pid()
        self._update_wrapper()
        self._update_service()
        self.state_mgr.load()
        self._reconcile()
        self.state_mgr.prune_missing_files()
        self.device_name = socket.gethostname()
        self.device_id = self.config.device_id
        self.global_email, self.global_name = resolve_global_identity()
        init_session()

        logger.info(
            "quickcall daemon started v%s (poll_interval=%.1fs, ingest=%s)",
            __version__,
            self.config.poll_interval,
            self.config.ingest_url,
        )

        try:
            while not self._shutdown:
                self._poll_cycle()
                self._check_for_update()
                if self._pending_commands:
                    self._paused = True
                    try:
                        self._handle_commands(self._pending_commands)
                    finally:
                        self._pending_commands = []
                        self._paused = False
                self._sleep(self.config.poll_interval)
        except KeyboardInterrupt:
            pass
        finally:
            self._cleanup()

    def _reconcile(self) -> None:
        """Reconcile local state against server's file_progress markers.

        The server tracks the daemon's reported read position (last_line_read)
        via POST /api/file-progress. On restart, we use this to resume from
        where we left off rather than re-pushing data the server already has.

        Rules:
        - If server has a last_line_read for a file, use it as the resume point
          (this is the daemon's own reported position from a previous run).
        - If server has messages but no last_line_read, use max_line as fallback.
        - If server has nothing for a file, keep local state (conservative —
          don't reset, since ON CONFLICT dedup means messages may already exist).
        - For hash-based sources (gemini, cursor), only re-process if server
          has 0 messages AND no content_hash in file_progress.
        """
        try:
            server_state = self._fetch_server_state()
        except Exception as e:
            logger.warning("Reconciliation skipped — server unreachable: %s", e)
            return

        adjusted_count = 0
        for file_path, local in self.state_mgr.all_states().items():
            server = server_state.get(file_path)

            if server is None:
                # Server has no record at all — keep local state.
                # The daemon already pushed these messages (ON CONFLICT DO NOTHING
                # deduplicates), so resetting would just waste time re-pushing.
                logger.debug(
                    "Reconciliation: %s — no server record, keeping local (line %d)",
                    file_path, local.last_line_processed,
                )
                continue

            if local.source in ("claude_code", "codex_cli"):
                # JSONL sources: use server's last_line_read (daemon's reported
                # position) as authoritative, fall back to max_line from messages
                server_line = server.get("last_line_read") or server.get("max_line", 0)
                if server_line and server_line != local.last_line_processed:
                    logger.info(
                        "Reconciliation: %s — adjusting from line %d to server's %d",
                        file_path, local.last_line_processed, server_line,
                    )
                    local.last_line_processed = server_line
                    self.state_mgr.set_state(local)
                    adjusted_count += 1

            elif local.source in ("gemini_cli", "cursor"):
                # Hash-based sources: only re-process if server truly has nothing
                server_hash = server.get("content_hash")
                if server["message_count"] == 0 and not server_hash:
                    logger.info(
                        "Reconciliation: %s — server has 0 messages and no hash, re-processing",
                        file_path,
                    )
                    self.state_mgr.reset_state(file_path)
                    adjusted_count += 1

        if adjusted_count > 0:
            logger.info(
                "Reconciliation: adjusted %d file(s)", adjusted_count
            )
            self.state_mgr.save()
        else:
            logger.info("Reconciliation: all files in sync")

        self._backfill_session_metadata()

    @staticmethod
    def _read_first_timestamp(file_path: str) -> str:
        """Read the first timestamp from a session file.

        Claude Code / Codex CLI: JSONL with top-level "timestamp" field.
        Returns ISO 8601 string, or current UTC time as fallback.
        """
        fallback = datetime.now(timezone.utc).isoformat()
        try:
            with open(file_path, "r", encoding="utf-8", errors="replace") as f:
                for raw_line in f:
                    raw_line = raw_line.strip()
                    if not raw_line:
                        continue
                    try:
                        data = json.loads(raw_line)
                    except json.JSONDecodeError:
                        continue
                    ts = data.get("timestamp", "")
                    if ts and ts > "2000":
                        return ts
        except OSError:
            pass
        return fallback

    def _backfill_session_metadata(self) -> None:
        """Backfill git_branch/repo_name for sessions where resolve_repo previously failed.

        This handles the macOS TCC case: the daemon couldn't access protected folders
        (e.g. ~/Documents) when sessions were first processed. After the user grants
        access, this fills in the missing git metadata on daemon restart.
        """
        from qc_trace.schemas.unified import NormalizedMessage, SessionContext

        updated = 0
        skipped = 0
        failed = 0

        for file_path, fstate in self.state_mgr.all_states().items():
            if fstate.source == "gemini_cli":
                continue

            session_id, cwd = extract_session_meta(file_path, fstate.source)
            if not cwd:
                skipped += 1
                continue

            try:
                repo_info = resolve_repo(cwd)
            except Exception:
                failed += 1
                continue

            if not repo_info.git_branch and not repo_info.repo_name:
                skipped += 1
                continue

            if not session_id:
                skipped += 1
                continue

            ctx = SessionContext(
                cwd=cwd,
                git_branch=repo_info.git_branch,
                repo_name=repo_info.repo_name,
                repo_url=repo_info.repo_url,
                git_commit=repo_info.git_commit,
                org=self.config.org,
            )
            msg = NormalizedMessage(
                id=f"backfill-{session_id}",
                session_id=session_id,
                source=fstate.source,
                source_schema_version=1,
                msg_type="system",
                timestamp=self._read_first_timestamp(file_path),
                content="[backfill: git metadata]",
                session_context=ctx,
                raw_file_path=file_path,
            )
            if self.pusher.push([msg]):
                updated += 1
            else:
                failed += 1

        if updated > 0 or failed > 0:
            logger.info(
                "Backfill: updated %d sessions, skipped %d, failed %d",
                updated, skipped, failed,
            )

    def _fetch_server_state(self) -> dict[str, dict]:
        """GET /api/sync from the ingest server, return dict keyed by file_path."""
        url = urllib.parse.urljoin(self.config.ingest_url, "/api/sync")
        req = urllib.request.Request(url, method="GET")
        if self.config.api_key:
            req.add_header("X-API-Key", self.config.api_key)
        with urllib.request.urlopen(req, timeout=10) as resp:
            rows = json.loads(resp.read())
        return {row["raw_file_path"]: row for row in rows}

    _MAX_ACCUMULATE = 2000  # Cap in-memory messages before mid-cycle flush

    def _poll_cycle(self) -> None:
        """One poll-collect-push cycle (accumulate-then-flush).

        Phase 1: Collect messages from all changed files
        Phase 2: Push accumulated messages in batch_size chunks
        Phase 3: Bulk-report progress for successfully-pushed files
        Phase 4: Advance state only for files whose messages were fully pushed
        """
        changed_files = self.watcher.get_changed_files()

        # Process smaller sources first so they don't starve behind large backlogs
        changed_files.sort(key=lambda f: f.size)

        # Phase 1: Collect all messages and build progress reports
        collected: list[tuple[list, CollectResult, ChangedFile]] = []
        all_messages: list[tuple[object, int]] = []  # (message, file_index)
        total_message_count = 0

        for changed in changed_files:
            if self._shutdown:
                break

            existing_state = self.state_mgr.get_state(changed.path)

            try:
                result = collect_file(
                    changed,
                    existing_state,
                    device_name=self.device_name,
                    device_id=self.device_id,
                    global_email=self.global_email,
                    global_name=self.global_name,
                    org=self.config.org,
                )
            except Exception as e:
                logger.error("Failed to collect %s: %s", changed.path, e)
                if existing_state:
                    existing_state.retry_count += 1
                    existing_state.last_error = str(e)
                    existing_state.last_mtime = changed.mtime
                    existing_state.last_size = changed.size
                    self.state_mgr.set_state(existing_state)
                continue

            # Scoped mode: skip files whose session cwd is not in scope
            # Gemini uses projectHash instead of cwd, so exempt it from filtering
            if self.config.scoped_mode and result.messages and changed.source != "gemini_cli":
                cwd = None
                for msg in result.messages:
                    if msg.session_context and msg.session_context.cwd:
                        cwd = msg.session_context.cwd
                        break
                if not self.config.is_path_in_scope(cwd):
                    logger.debug(
                        "Scoped filter: skipping %s (cwd=%s)", changed.path, cwd
                    )
                    # Advance local state so we don't re-process, but don't
                    # push messages or report progress to server
                    self.state_mgr.set_state(result.new_state)
                    continue

            file_idx = len(collected)
            collected.append((result.messages, result, changed))

            for msg in result.messages:
                all_messages.append((msg, file_idx))
            total_message_count += len(result.messages)

            # Mid-cycle flush if accumulation exceeds cap
            if total_message_count >= self._MAX_ACCUMULATE:
                remaining = len(changed_files) - len(collected)
                logger.info(
                    "Accumulation cap reached (%d messages) — deferring %d file(s) to next cycle",
                    total_message_count, remaining,
                )
                break

        # Phase 2: Push accumulated messages in batch_size chunks
        # Track which file indices had all their messages pushed
        failed_at: int | None = None  # index into all_messages where failure occurred

        for i in range(0, len(all_messages), self.config.batch_size):
            if self._shutdown:
                failed_at = i
                break
            batch_pairs = all_messages[i : i + self.config.batch_size]
            batch_msgs = [msg for msg, _ in batch_pairs]
            if not self.pusher.push(batch_msgs):
                failed_at = i
                break

        # Determine which files had ALL their messages successfully pushed
        if failed_at is not None:
            # Find which file indices were in the failed batch or later
            failed_file_indices = {idx for _, idx in all_messages[failed_at:]}
            successfully_pushed_indices = {
                idx for idx in range(len(collected))
                if idx not in failed_file_indices and collected[idx][0]  # has messages
            }
        else:
            successfully_pushed_indices = {
                idx for idx in range(len(collected)) if collected[idx][0]
            }

        # Also include files with no messages (state-only updates)
        no_message_indices = {
            idx for idx in range(len(collected)) if not collected[idx][0]
        }

        # Phase 3: Advance state and build progress reports for successful files
        progress_reports: list[dict] = []
        total_pushed = 0

        for idx in range(len(collected)):
            msgs, result, changed = collected[idx]

            if idx in successfully_pushed_indices or idx in no_message_indices:
                self.state_mgr.set_state(result.new_state)
                progress_reports.append({
                    "raw_file_path": changed.path,
                    "source": result.new_state.source,
                    "last_line_read": result.new_state.last_line_processed,
                    "content_hash": result.new_state.content_hash or None,
                })
                if msgs:
                    total_pushed += len(msgs)
            else:
                logger.warning(
                    "Push failed for %s — state not advanced", changed.path
                )

        # Record push stats
        if total_pushed > 0:
            # Group by source for record_push
            source_counts: dict[str, int] = {}
            for idx in successfully_pushed_indices:
                msgs, result, _ = collected[idx]
                src = result.new_state.source
                source_counts[src] = source_counts.get(src, 0) + len(msgs)
            for src, count in source_counts.items():
                record_push(src, count)

        # Bulk-report progress
        if progress_reports:
            if not self.pusher.report_progress_bulk(progress_reports):
                logger.warning(
                    "Bulk progress report failed for %d file(s) — server may re-push on restart",
                    len(progress_reports),
                )

        # Record queue metrics
        record_push_metrics(self.pusher.queue_size, self.pusher.current_backoff)

        self.state_mgr.save()

        # Send heartbeat (best-effort, never block on failure)
        self._send_heartbeat()

    def _send_heartbeat(self) -> None:
        """Send daemon heartbeat to server. Best-effort, failures silently ignored."""
        now = time.time()
        if now - self._last_heartbeat < _HEARTBEAT_INTERVAL:
            return
        self._last_heartbeat = now
        try:
            status_data = read_push_status()
            queue_size = self.pusher.queue_size
            backoff = self.pusher.current_backoff

            if backoff > 0 or queue_size > 0:
                status = "degraded"
            elif status_data.get("messages_this_session", 0) > 0:
                status = "pushing"
            else:
                status = "idle"

            # Build source stats from state.json (same as quickcall status)
            source_stats: dict[str, dict] = {}
            for _path, fstate in self.state_mgr.all_states().items():
                src = fstate.source
                if src not in source_stats:
                    source_stats[src] = {"files": 0, "lines": 0}
                source_stats[src]["files"] += 1
                source_stats[src]["lines"] += fstate.last_line_processed

            # Merge last_push_at from push_status by_source
            by_source = status_data.get("by_source", {})
            for src, pdata in by_source.items():
                if src not in source_stats:
                    source_stats[src] = {"files": 0, "lines": 0}
                source_stats[src]["last_push_at"] = pdata.get("last_push_at", 0)

            # Uptime from PID file mtime
            uptime_seconds = 0.0
            try:
                uptime_seconds = now - self.config.pid_file.stat().st_mtime
            except OSError:
                pass

            payload = {
                "device_id": self.device_id or "unknown",
                "device_name": self.device_name,
                "org": self.config.org,
                "daemon_version": __version__,
                "status": status,
                "queue_size": queue_size,
                "current_backoff": round(backoff, 1),
                "messages_this_session": status_data.get("messages_this_session", 0),
                "last_push_at": status_data.get("last_push_at", 0),
                "uptime_seconds": round(uptime_seconds),
                "source_stats": source_stats,
                "recent_errors": get_recent_errors_for_heartbeat(),
            }
            resp = self.pusher.send_heartbeat(payload)
            if resp:
                commands = resp.get("commands", [])
                if commands:
                    logger.info("Received %d command(s) from server", len(commands))
                    self._pending_commands.extend(commands)
        except Exception:
            pass  # heartbeat failures must never crash the daemon

    def _handle_commands(self, commands: list[dict]) -> None:
        """Dispatch and execute server-initiated commands."""
        for cmd in commands:
            if self._shutdown:
                break
            command = cmd.get("command")
            command_id = cmd.get("id")
            params = cmd.get("params", {})
            if command == "reingest":
                self._handle_reingest(command_id, params)
            else:
                logger.warning("Unknown command: %s (id=%s)", command, command_id)
                if command_id:
                    self.pusher.report_command_complete(
                        command_id, "failed", {"error": f"Unknown command: {command}"}
                    )

    def _handle_reingest(self, command_id: int, params: dict) -> None:
        """Execute a reingest command by resetting state and re-processing files."""
        logger.info("Executing reingest command %d with params: %s", command_id, params)
        try:
            scope = params.get("scope")
            source = params.get("source")
            file = params.get("file")
            since = params.get("since")

            if scope == "all":
                files_reset = self.state_mgr.reset_all()
            elif source:
                files_reset = self.state_mgr.reset_by_source(source)
            elif file:
                had_state = self.state_mgr.get_state(file) is not None
                self.state_mgr.reset_state(file)
                files_reset = 1 if had_state else 0
            elif since:
                files_reset = self.state_mgr.reset_since(since)
            else:
                files_reset = self.state_mgr.reset_all()

            self.state_mgr.save()
            logger.info("Reingest: reset %d file state(s)", files_reset)

            result = self._reingest_poll_cycle(max_duration=600)
            result["files_reset"] = files_reset

            if result.get("timed_out"):
                logger.warning(
                    "Reingest timed out after processing %d files (%d messages)",
                    result.get("files_processed", 0),
                    result.get("messages_pushed", 0),
                )

            self.pusher.report_command_complete(command_id, "completed", result)
            logger.info("Reingest command %d completed: %s", command_id, result)

        except Exception as e:
            logger.exception("Reingest command %d failed", command_id)
            self.pusher.report_command_complete(
                command_id, "failed", {"error": str(e)}
            )

    _REINGEST_MAX_DURATION = 600  # 10 minutes

    def _reingest_poll_cycle(self, max_duration: int = 600) -> dict:
        """Run a poll cycle for reingest with a time limit.

        Similar to _poll_cycle() but:
        - Checks elapsed time after each file and stops if exceeded
        - Does NOT send heartbeat (avoids recursion)
        - Returns stats instead of void
        """
        start = time.time()
        files_processed = 0
        messages_pushed = 0
        timed_out = False

        changed_files = self.watcher.get_changed_files()
        changed_files.sort(key=lambda f: f.size)

        collected: list[tuple[list, CollectResult, ChangedFile]] = []
        all_messages: list[tuple[object, int]] = []

        for changed in changed_files:
            if self._shutdown:
                break
            if time.time() - start > max_duration:
                timed_out = True
                break

            existing_state = self.state_mgr.get_state(changed.path)

            try:
                result = collect_file(
                    changed,
                    existing_state,
                    device_name=self.device_name,
                    device_id=self.device_id,
                    global_email=self.global_email,
                    global_name=self.global_name,
                    org=self.config.org,
                )
            except Exception as e:
                logger.error("Reingest: failed to collect %s: %s", changed.path, e)
                continue

            file_idx = len(collected)
            collected.append((result.messages, result, changed))

            for msg in result.messages:
                all_messages.append((msg, file_idx))
            files_processed += 1

        # Push accumulated messages
        failed_at: int | None = None
        for i in range(0, len(all_messages), self.config.batch_size):
            if self._shutdown:
                failed_at = i
                break
            batch_pairs = all_messages[i : i + self.config.batch_size]
            batch_msgs = [msg for msg, _ in batch_pairs]
            if not self.pusher.push(batch_msgs):
                failed_at = i
                break

        # Determine successful pushes and advance state
        if failed_at is not None:
            failed_file_indices = {idx for _, idx in all_messages[failed_at:]}
            successfully_pushed_indices = {
                idx for idx in range(len(collected))
                if idx not in failed_file_indices and collected[idx][0]
            }
        else:
            successfully_pushed_indices = {
                idx for idx in range(len(collected)) if collected[idx][0]
            }

        no_message_indices = {
            idx for idx in range(len(collected)) if not collected[idx][0]
        }

        progress_reports: list[dict] = []
        for idx in range(len(collected)):
            msgs, result, changed = collected[idx]
            if idx in successfully_pushed_indices or idx in no_message_indices:
                self.state_mgr.set_state(result.new_state)
                progress_reports.append({
                    "raw_file_path": changed.path,
                    "source": result.new_state.source,
                    "last_line_read": result.new_state.last_line_processed,
                    "content_hash": result.new_state.content_hash or None,
                })
                if msgs:
                    messages_pushed += len(msgs)

        if progress_reports:
            self.pusher.report_progress_bulk(progress_reports)

        self.state_mgr.save()

        return {
            "files_processed": files_processed,
            "messages_pushed": messages_pushed,
            "timed_out": timed_out,
        }

    def _check_for_update(self) -> None:
        """Periodically check PyPI for a newer version and exit to trigger restart."""
        now = time.time()
        if now - self._last_update_check < _UPDATE_CHECK_INTERVAL:
            return
        self._last_update_check = now

        newer = None
        try:
            base_url = self.config.ingest_url.rstrip("/ingest")
            url = f"{base_url}/api/latest-version"
            with urllib.request.urlopen(url, timeout=10) as resp:
                data = json.loads(resp.read())
                latest = data.get("version")
                if latest and latest != __version__:
                    newer = latest
        except Exception:
            pass  # network issues must never crash the daemon

        record_update_check(__version__, newer)

        if newer:
            logger.info("Update available: %s → %s, restarting...", __version__, newer)
            self._cleanup()
            sys.exit(0)  # systemd/launchd restarts → uvx fetches new version

    def _sleep(self, duration: float) -> None:
        """Sleep that respects shutdown signal."""
        end = time.monotonic() + duration
        while not self._shutdown and time.monotonic() < end:
            time.sleep(max(0, min(0.5, end - time.monotonic())))

    def _setup_signals(self) -> None:
        """Register signal handlers for graceful shutdown."""
        signal.signal(signal.SIGTERM, self._handle_signal)
        signal.signal(signal.SIGINT, self._handle_signal)

    def _handle_signal(self, signum: int, frame: object) -> None:
        logger.info("Received signal %d, shutting down...", signum)
        self._shutdown = True

    def _write_pid(self) -> None:
        """Write PID file, killing any existing daemon process first."""
        self.config.base_dir.mkdir(parents=True, exist_ok=True)
        if self.config.pid_file.exists():
            try:
                old_pid = int(self.config.pid_file.read_text().strip())
                if old_pid == os.getpid():
                    pass  # it's us, skip
                else:
                    os.kill(old_pid, 0)  # check if process exists
                    logger.warning("Killing existing daemon (PID %d)", old_pid)
                    os.kill(old_pid, signal.SIGTERM)
                    for _ in range(30):
                        try:
                            os.kill(old_pid, 0)
                        except ProcessLookupError:
                            break
                        time.sleep(0.1)
                    else:
                        try:
                            os.kill(old_pid, signal.SIGKILL)
                        except ProcessLookupError:
                            pass
                    logger.info("Previous daemon (PID %d) terminated", old_pid)
            except (ValueError, ProcessLookupError):
                logger.info("Removing stale PID file")
            except PermissionError:
                logger.warning("Cannot kill existing daemon (permission denied)")
        self.config.pid_file.write_text(str(os.getpid()))
        version_file = self.config.base_dir / "daemon_version"
        version_file.write_text(__version__)

    _WRAPPER_TEMPLATE = """\
#!/bin/sh
UPDATE_CHECK_INTERVAL=${{QC_UPDATE_INTERVAL:-300}}  # check for updates (default 5 min)

get_latest_version() {{
  # Try server first, fall back to PyPI
  V=$(curl -sf {trace_server}/api/latest-version 2>/dev/null | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
  if [ -z "$V" ]; then
    V=$(curl -sf https://pypi.org/pypi/qc-trace/json 2>/dev/null | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
  fi
  echo "$V"
}}

while true; do
  VERSION=$(get_latest_version)
  [ -z "$VERSION" ] && VERSION="latest"
  export PATH="$HOME/.local/bin:$PATH"

  # Kill any existing quickcall run processes to prevent duplicates
  pkill -f "quickcall run" 2>/dev/null || true
  sleep 1

  # Clear stale cache to prevent corrupted venv crashes
  uv cache clean qc-trace --force >/dev/null 2>&1 || true

  # Start daemon in background
  uvx --no-cache --from "qc-trace@${{VERSION}}" quickcall run &
  DAEMON_PID=$!
  echo "[quickcall] daemon started v${{VERSION}} (PID ${{DAEMON_PID}})"

  # Subloop: poll for updates while daemon is running
  while kill -0 "$DAEMON_PID" 2>/dev/null; do
    sleep "$UPDATE_CHECK_INTERVAL"
    NEW_VERSION=$(get_latest_version)
    [ -z "$NEW_VERSION" ] && continue
    if [ "$NEW_VERSION" != "$VERSION" ]; then
      echo "[quickcall] update available: v${{VERSION}} -> v${{NEW_VERSION}}, restarting..."
      kill "$DAEMON_PID" 2>/dev/null
      wait "$DAEMON_PID" 2>/dev/null
      break
    fi
  done

  # Wait for daemon to fully exit
  wait "$DAEMON_PID" 2>/dev/null

  # If the daemon exited (crash/update), wait briefly then restart
  sleep 5
done
"""

    def _update_service(self) -> None:
        """Patch service file to fix known issues without rewriting the whole file.

        Currently fixes: Restart=on-failure → Restart=always (systemd only).
        """
        if sys.platform != "linux":
            return
        unit_path = Path.home() / ".config/systemd/user/quickcall.service"
        try:
            if not unit_path.exists():
                return
            content = unit_path.read_text()
            if "Restart=on-failure" not in content:
                return  # already correct or custom
            new_content = content.replace("Restart=on-failure", "Restart=always")
            unit_path.write_text(new_content)
            subprocess.run(
                ["systemctl", "--user", "daemon-reload"],
                capture_output=True,
            )
            logger.info("Patched systemd unit: Restart=on-failure → Restart=always")
        except OSError as e:
            logger.debug("Failed to patch service file: %s", e)

    def _update_wrapper(self) -> None:
        """Update the quickcall-daemon wrapper script with the latest template.

        This ensures the wrapper self-heals (e.g. gains uv cache clean)
        even on machines that never re-run the installer.
        """
        wrapper_path = self.config.base_dir / "quickcall-daemon"
        trace_server = self.config.ingest_url.replace("/ingest", "")
        new_content = self._WRAPPER_TEMPLATE.format(trace_server=trace_server)
        try:
            if wrapper_path.exists():
                existing = wrapper_path.read_text()
                if existing == new_content:
                    return
            wrapper_path.write_text(new_content)
            wrapper_path.chmod(0o755)
            logger.info("Updated quickcall-daemon wrapper script")
        except OSError as e:
            logger.debug("Failed to update wrapper script: %s", e)

    def _cleanup(self) -> None:
        """Clean up on shutdown."""
        logger.info("quickcall daemon shutting down")
        self.state_mgr.save()
        try:
            self.config.pid_file.unlink(missing_ok=True)
        except OSError:
            pass
