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

from __future__ import annotations

import json
import logging
import os
import signal
import socket
import sys
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
from qc_trace.daemon.config import DaemonConfig
from qc_trace.daemon.push_status import init_session, 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

logger = logging.getLogger("quickcall")

_UPDATE_CHECK_INTERVAL = 300  # 5 minutes


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

    def run(self) -> None:
        """Main daemon loop."""
        self._setup_signals()
        self._write_pid()
        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()
                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")

    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

            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()

    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__)

    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
