"""NEUROVAULT background daemon - passive ingestion + queue orchestration."""

from __future__ import annotations

import asyncio
import json
import logging
import os
import signal
import socket
import threading
import time
from collections import defaultdict, deque
from hashlib import sha256
from pathlib import Path
from typing import Any

from neurovault.config import NeurovaultConfig
from neurovault.engine import NeurovaultEngine
from neurovault.events import NeurovaultEvents
from neurovault.plugins.loader import PluginManager
from neurovault.sensors.audio import AudioSensor
from neurovault.sensors.base import BaseSensor
from neurovault.sensors.browser import BrowserSensor
from neurovault.sensors.calendar import CalendarSensor
from neurovault.sensors.clipboard import ClipboardSensor
from neurovault.sensors.file_watcher import FileWatcherSensor
from neurovault.sensors.git import GitSensor
from neurovault.sensors.screenshot import ScreenshotSensor

logger = logging.getLogger("neurovault.daemon")


def _pid_file_path() -> Path:
    return Path.home() / ".neurovault" / "neurovault.pid"


def _current_uid() -> int | None:
    if hasattr(os, "getuid"):
        try:
            return int(os.getuid())
        except Exception as exc:
            logger.debug("Unable to read current uid: %s", exc)
            return None
    return None


def _parse_pid_file(raw: str) -> tuple[int, int | None]:
    text = raw.strip()
    if not text:
        raise ValueError("empty pid file")
    if text.startswith("{"):
        payload = json.loads(text)
        pid = int(payload["pid"])
        uid_raw = payload.get("uid")
        uid = int(uid_raw) if uid_raw is not None else None
        return pid, uid
    return int(text), None


class IngestionQueue:
    """Thread-safe ingestion queue with dedup and rate limiting."""

    MAX_SIZE = 1000
    DEDUP_WINDOW = 1000
    RATE_LIMIT_PER_MINUTE = 10
    BATCH_DELAY_SECONDS = 2

    def __init__(self):
        self._queue: deque[dict] = deque(maxlen=self.MAX_SIZE)
        self._lock = threading.Lock()
        self._recent_hashes: deque[str] = deque(maxlen=self.DEDUP_WINDOW)
        self._rate: dict[str, list[float]] = defaultdict(list)

    def submit(self, content: str, source: str, metadata: dict | None = None) -> None:
        content_hash = sha256(content.encode()).hexdigest()
        with self._lock:
            if content_hash in self._recent_hashes:
                return

            now = time.time()
            recent = [t for t in self._rate[source] if now - t < 60]
            if len(recent) >= self.RATE_LIMIT_PER_MINUTE:
                return

            self._recent_hashes.append(content_hash)
            self._rate[source] = recent + [now]
            self._queue.append(
                {
                    "content": content,
                    "source": source,
                    "metadata": metadata or {},
                    "timestamp": now,
                    "hash": content_hash,
                }
            )

    def drain(self) -> list[dict]:
        with self._lock:
            items = list(self._queue)
            self._queue.clear()
            return items


class SensorManager:
    """Manage sensor threads and dispatch events into the ingestion queue."""

    def __init__(self, queue: IngestionQueue, config: NeurovaultConfig):
        self._queue = queue
        self._config = config
        self._sensors: list[BaseSensor] = []
        self._threads: list[threading.Thread] = []
        self._running = False

    def register(self, sensor: BaseSensor) -> None:
        self._sensors.append(sensor)

    def start(self) -> None:
        self._running = True
        for sensor in self._sensors:
            if not sensor.is_enabled(self._config):
                continue
            try:
                sensor.start()
            except Exception as exc:  # pragma: no cover - hardware/env dependent
                logger.warning("Sensor start failed (%s): %s", sensor.name, exc)
                continue
            thread = threading.Thread(
                target=self._run_sensor,
                args=(sensor,),
                daemon=True,
                name=f"sensor-{sensor.name}",
            )
            self._threads.append(thread)
            thread.start()

    def _run_sensor(self, sensor: BaseSensor) -> None:
        while self._running:
            try:
                items = sensor.poll()
                for content, metadata in items:
                    self._queue.submit(content, sensor.source_tag, metadata)
            except Exception as exc:  # pragma: no cover - hardware/env dependent
                logger.warning("Sensor %s error: %s", sensor.name, exc)
            time.sleep(sensor.poll_interval)

    def stop(self) -> None:
        self._running = False
        for thread in self._threads:
            thread.join(timeout=5)
        for sensor in self._sensors:
            try:
                sensor.stop()
            except Exception as exc:
                logger.debug("Sensor stop error (%s): %s", sensor.name, exc)


class IdleDetector:
    """Detect user idle state for future consolidation scheduling."""

    IDLE_THRESHOLD_SECONDS = 300

    def __init__(self):
        self._last_activity = time.time()

    def activity(self) -> None:
        self._last_activity = time.time()

    def is_idle(self) -> bool:
        return (time.time() - self._last_activity) >= self.IDLE_THRESHOLD_SECONDS


class NeurovaultDaemon:
    """Background daemon: sensors -> queue -> ingest."""

    def __init__(
        self,
        vault_name: str = "default",
        base_dir: str | None = None,
        sensors: list[str] | None = None,
        enable_consolidation: bool = True,
        verbose: bool = False,
    ):
        self._vault_name = vault_name
        self._base_dir = base_dir
        self._sensor_filter = {s.strip() for s in sensors or [] if s and s.strip()}
        self._enable_consolidation = enable_consolidation
        self._verbose = verbose
        self._running = False
        self._pid_file = _pid_file_path()
        self._socket_path = Path.home() / ".neurovault" / "run" / "neurovault.sock"
        self._socket_server: socket.socket | None = None
        self._socket_thread: threading.Thread | None = None
        self._pipeline_stats_path = Path.home() / ".neurovault" / "pipeline_stats.json"

    def start(self) -> None:
        if self._pid_file.exists():
            try:
                existing_pid, owner_uid = _parse_pid_file(self._pid_file.read_text())
                current_uid = _current_uid()
                if owner_uid is not None and current_uid is not None and owner_uid != current_uid:
                    raise RuntimeError(
                        f"Daemon PID file owned by different user (uid={owner_uid})"
                    )
                os.kill(existing_pid, 0)
                raise RuntimeError(f"Daemon already running with PID {existing_pid}")
            except RuntimeError:
                raise
            except PermissionError as exc:
                raise RuntimeError("Daemon process is not owned by current user") from exc
            except OSError:
                self._pid_file.unlink(missing_ok=True)
            except Exception as exc:
                logger.debug("Invalid PID file encountered; removing %s: %s", self._pid_file, exc)
                self._pid_file.unlink(missing_ok=True)

        self._running = True
        self._write_pid()

        vault = NeurovaultEngine.open(self._vault_name, base_dir=self._base_dir)
        vault._daemon_start_time = time.time()

        config = NeurovaultConfig.load()
        queue = IngestionQueue()
        idle = IdleDetector()
        pipeline_stats: dict[str, Any] = {
            "enabled": bool(config.pipeline.get("enabled", False)),
            "ingested": 0,
            "filtered": 0,
            "deduplicated": 0,
            "errors": 0,
            "queue_size": 0,
            "last_updated": int(time.time() * 1000),
        }
        pipeline = None
        pipeline_loop: asyncio.AbstractEventLoop | None = None
        pipeline_thread: threading.Thread | None = None

        manager = SensorManager(queue, config)
        plugin_manager = PluginManager()
        fw_cfg = config.sensors.get("file_watcher", {})
        audio_cfg = config.sensors.get("audio", {})
        cal_cfg = config.sensors.get("calendar", {})
        git_cfg = config.sensors.get("git", {})
        if not isinstance(fw_cfg, dict):
            fw_cfg = {}
        if not isinstance(audio_cfg, dict):
            audio_cfg = {}
        if not isinstance(cal_cfg, dict):
            cal_cfg = {}
        if not isinstance(git_cfg, dict):
            git_cfg = {}

        all_sensors = [
            ClipboardSensor(),
            FileWatcherSensor(
                watch_paths=fw_cfg.get("watch_paths"),
                path_blocklist=config.privacy.get("path_blocklist", []),
            ),
            BrowserSensor(),
            ScreenshotSensor(),
            AudioSensor(watch_paths=audio_cfg.get("watch_paths")),
            CalendarSensor(watch_paths=cal_cfg.get("watch_paths")),
            GitSensor(repo_paths=git_cfg.get("repo_paths")),
        ]
        for sensor in all_sensors:
            sensor_cfg = config.sensors.get(sensor.name, {})
            if isinstance(sensor_cfg, dict):
                interval = sensor_cfg.get("poll_interval")
                if isinstance(interval, (int, float)):
                    try:
                        setattr(sensor, "poll_interval", float(interval))
                    except Exception as exc:
                        logger.debug("Invalid poll interval for sensor %s: %s", sensor.name, exc)
            if self._sensor_filter and sensor.name not in self._sensor_filter:
                continue
            manager.register(sensor)
        manager.start()

        if bool(config.pipeline.get("enabled", False)):
            try:
                from neurovault.cognitive.embedding_engine import EmbeddingEngine
                from neurovault.cognitive.semantic_dedup import SemanticDeduplicator
                from neurovault.pipeline.streaming import StreamingPipeline

                emb_cfg = config.embedding if isinstance(config.embedding, dict) else {}
                embedding = EmbeddingEngine(
                    config={
                        "embedding_backend": emb_cfg.get("backend", "auto"),
                        "embedding_model": emb_cfg.get("model", "all-MiniLM-L6-v2"),
                    }
                )
                dedup = SemanticDeduplicator(vault._nv, embedding)
                pipeline = StreamingPipeline(
                    content_filter=vault._filter,
                    embedding_engine=embedding,
                    deduplicator=dedup,
                    storage=vault._nv,
                    bridge=vault._bridge,
                )

                p_cfg = config.pipeline if isinstance(config.pipeline, dict) else {}
                batch_size = p_cfg.get("batch_size")
                batch_timeout = p_cfg.get("batch_timeout")
                if isinstance(batch_size, int) and batch_size > 0:
                    pipeline.BATCH_SIZE = batch_size
                if isinstance(batch_timeout, (int, float)) and batch_timeout > 0:
                    pipeline.BATCH_TIMEOUT = float(batch_timeout)

                pipeline_loop = asyncio.new_event_loop()

                def _pipeline_worker() -> None:
                    asyncio.set_event_loop(pipeline_loop)
                    pipeline_loop.run_until_complete(pipeline.start())
                    pipeline_loop.run_forever()
                    pipeline_loop.run_until_complete(pipeline.stop())
                    pipeline_loop.close()

                pipeline_thread = threading.Thread(
                    target=_pipeline_worker,
                    daemon=True,
                    name="neurovault-pipeline",
                )
                pipeline_thread.start()
                pipeline_stats["enabled"] = True
            except Exception as exc:
                logger.warning("Streaming pipeline unavailable: %s", exc)
                pipeline = None
                pipeline_stats["enabled"] = False

        plugin_states = {item["plugin_id"]: item for item in vault._nv.list_plugin_state()}
        for manifest in plugin_manager.discover():
            plugin_id = str(manifest.get("_id", ""))
            if not plugin_id:
                continue
            state = plugin_states.get(plugin_id, {})
            enabled = bool(state.get("enabled", True))
            if plugin_id in set(config.disabled_sensors) or not enabled:
                continue

            merged_config: dict[str, Any] = {}
            cfg_map = config.plugin_configs.get(plugin_id, {})
            if isinstance(cfg_map, dict):
                merged_config.update(cfg_map)
            state_cfg = state.get("config", {})
            if isinstance(state_cfg, dict):
                merged_config.update(state_cfg)

            try:
                plugin_manager.load(plugin_id, config=merged_config)
            except Exception as exc:
                logger.warning("Failed to load plugin %s: %s", plugin_id, exc)
                continue
        plugin_manager.start_all(queue.submit)
        self._start_socket_server(vault, queue)

        signal.signal(signal.SIGTERM, lambda *_: self.stop())
        signal.signal(signal.SIGINT, lambda *_: self.stop())

        logger.info("NEUROVAULT daemon started (PID %s)", os.getpid())
        try:
            vault.events.emit(NeurovaultEvents.DAEMON_STARTED, {"pid": os.getpid(), "vault": self._vault_name})
        except Exception as exc:
            logger.debug("Unable to emit daemon started event: %s", exc)

        try:
            last_flush = time.time()
            while self._running:
                now = time.time()
                if now - last_flush < IngestionQueue.BATCH_DELAY_SECONDS:
                    time.sleep(0.2)
                    continue
                last_flush = now

                items = queue.drain()
                pipeline_stats["queue_size"] = 0
                pipeline_stats["last_updated"] = int(time.time() * 1000)
                for item in items:
                    try:
                        if pipeline is not None:
                            pipeline.submit_sync(
                                content=item["content"],
                                source=item["source"],
                                metadata=item["metadata"],
                            )
                            if self._verbose:
                                logger.info("queued->pipeline source=%s hash=%s", item.get("source"), item.get("hash"))
                        else:
                            vault.ingest(
                                content=item["content"],
                                source=item["source"],
                                metadata=item["metadata"],
                            )
                            pipeline_stats["ingested"] += 1
                            if self._verbose:
                                logger.info("ingested source=%s hash=%s", item.get("source"), item.get("hash"))
                        idle.activity()
                    except Exception as exc:  # pragma: no cover
                        pipeline_stats["errors"] += 1
                        logger.warning("Ingest error: %s", exc)

                if self._enable_consolidation and idle.is_idle():
                    try:
                        report = vault.consolidate()
                        logger.info(
                            "Consolidation: %s entities, %s insights",
                            report.entities_extracted,
                            report.insights_generated,
                        )
                    except Exception as exc:  # pragma: no cover
                        logger.warning("Consolidation error: %s", exc)
                    finally:
                        idle.activity()

                if pipeline is not None:
                    try:
                        pstats = pipeline.stats
                        pipeline_stats["ingested"] = int(pstats.get("ingested", 0))
                        pipeline_stats["filtered"] = int(pstats.get("filtered", 0))
                        pipeline_stats["deduplicated"] = int(pstats.get("deduplicated", 0))
                        pipeline_stats["errors"] = int(pstats.get("errors", 0))
                    except Exception as exc:
                        logger.debug("Pipeline stats unavailable: %s", exc)

                pipeline_stats["queue_size"] = len(queue._queue)
                self._write_pipeline_stats(pipeline_stats)
                time.sleep(0.2)
        finally:
            manager.stop()
            plugin_manager.stop_all()
            if pipeline_loop is not None:
                try:
                    pipeline_loop.call_soon_threadsafe(pipeline_loop.stop)
                except Exception as exc:
                    logger.debug("Pipeline loop stop signal failed: %s", exc)
            if pipeline_thread is not None:
                pipeline_thread.join(timeout=3)
            pipeline_stats["queue_size"] = 0
            pipeline_stats["last_updated"] = int(time.time() * 1000)
            self._write_pipeline_stats(pipeline_stats)
            self._stop_socket_server()
            self._remove_pid()
            logger.info("NEUROVAULT daemon stopped")
            try:
                vault.events.emit(NeurovaultEvents.DAEMON_STOPPED, {"pid": os.getpid(), "vault": self._vault_name})
            except Exception as exc:
                logger.debug("Unable to emit daemon stopped event: %s", exc)

    def stop(self) -> None:
        self._running = False

    def _write_pid(self) -> None:
        self._pid_file.parent.mkdir(parents=True, exist_ok=True)
        payload = {
            "pid": os.getpid(),
            "uid": _current_uid(),
            "started_at": int(time.time()),
        }
        self._pid_file.write_text(json.dumps(payload))
        try:
            os.chmod(self._pid_file, 0o600)
        except Exception as exc:
            logger.debug("Unable to chmod PID file: %s", exc)

    def _remove_pid(self) -> None:
        self._pid_file.unlink(missing_ok=True)

    def _write_pipeline_stats(self, payload: dict[str, Any]) -> None:
        self._pipeline_stats_path.parent.mkdir(parents=True, exist_ok=True)
        self._pipeline_stats_path.write_text(json.dumps(payload))

    def _start_socket_server(self, vault: NeurovaultEngine, queue: IngestionQueue) -> None:
        if not hasattr(socket, "AF_UNIX"):
            return
        try:
            self._socket_path.parent.mkdir(parents=True, exist_ok=True)
            try:
                os.chmod(self._socket_path.parent, 0o700)
            except Exception as exc:
                logger.debug("Unable to chmod socket directory: %s", exc)
            self._socket_path.unlink(missing_ok=True)
            server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            server.bind(str(self._socket_path))
            try:
                os.chmod(self._socket_path, 0o600)
            except Exception as exc:
                logger.debug("Unable to chmod socket file: %s", exc)
            server.listen(1)
            server.settimeout(1.0)
            self._socket_server = server
            self._socket_thread = threading.Thread(
                target=self._socket_loop,
                args=(vault, queue),
                daemon=True,
                name="neurovault-ipc",
            )
            self._socket_thread.start()
        except Exception as exc:
            logger.warning("IPC socket unavailable: %s", exc)

    def _stop_socket_server(self) -> None:
        try:
            if self._socket_server is not None:
                self._socket_server.close()
        finally:
            self._socket_server = None
        if self._socket_thread is not None:
            self._socket_thread.join(timeout=2)
            self._socket_thread = None
        self._socket_path.unlink(missing_ok=True)

    def _socket_loop(self, vault: NeurovaultEngine, queue: IngestionQueue) -> None:
        while self._running and self._socket_server is not None:
            try:
                conn, _ = self._socket_server.accept()
            except socket.timeout:
                continue
            except Exception as exc:
                logger.debug("IPC accept loop exiting: %s", exc)
                break

            with conn:
                try:
                    data = conn.recv(2048).decode("utf-8", errors="ignore").strip().lower()
                    if data == "ping":
                        conn.sendall(b"pong\n")
                    elif data == "status":
                        payload = {
                            "running": self._running,
                            "vault": self._vault_name,
                            "queue_size": len(queue._queue),  # best-effort debug metric
                            "daemon_pid": os.getpid(),
                            "entity_count": vault.status.entity_count,
                            "relationship_count": vault.status.relationship_count,
                        }
                        conn.sendall((json.dumps(payload) + "\n").encode("utf-8"))
                    else:
                        conn.sendall(b"unknown_command\n")
                except Exception as exc:
                    logger.debug("IPC command handling failed: %s", exc)
                    continue


def stop_running_daemon() -> bool:
    """Stop daemon process using the PID file."""
    pid_file = _pid_file_path()
    if not pid_file.exists():
        return False
    try:
        pid, owner_uid = _parse_pid_file(pid_file.read_text())
        current_uid = _current_uid()
        if owner_uid is not None and current_uid is not None and owner_uid != current_uid:
            return False
        os.kill(pid, signal.SIGTERM)
        return True
    except PermissionError:
        return False
    except Exception as exc:
        logger.debug("Failed to stop daemon from PID file %s: %s", pid_file, exc)
        pid_file.unlink(missing_ok=True)
        return False
