"""File watcher — monitors project for changes and auto-syncs.

Uses watchdog for OS-native file system events (FSEvents on macOS,
inotify on Linux). Changes are debounced and batched before uploading.
"""

import logging
import os
import threading
import time
from pathlib import Path
from typing import Dict, Optional, Set

try:
    from watchdog.observers import Observer
    from watchdog.events import FileSystemEventHandler, FileSystemEvent
    HAS_WATCHDOG = True
except ImportError:
    Observer = None
    FileSystemEventHandler = object
    HAS_WATCHDOG = False

from .config import load_config
from .scanner import _sha256_file, _build_spec, _is_binary, scan_project, read_files, _matches_include
from .syncer import Syncer, SyncError

logger = logging.getLogger(__name__)


class _ChangeHandler(FileSystemEventHandler):
    """Collects file change events for debounced batch sync."""

    def __init__(self, project_root: Path, watcher: "ProjectWatcher",
                 include_patterns: list = None):
        self.project_root = project_root
        self.watcher = watcher
        self._spec = _build_spec(project_root)
        self._include_patterns = include_patterns or ["**/*.py"]

    def _should_skip(self, path: str) -> bool:
        """Check if a file should be ignored."""
        try:
            rel = str(Path(path).relative_to(self.project_root))
        except ValueError:
            return True
        rel = rel.replace("\\", "/")

        # Skip hidden files
        if any(p.startswith(".") for p in rel.split("/")):
            return True

        # Skip non-matching files (use config include patterns)
        if not _matches_include(rel, self._include_patterns):
            return True

        # Skip ignored files
        if self._spec is not None:
            return self._spec.match_file(rel)
        return False

    def on_modified(self, event: "FileSystemEvent"):
        if event.is_directory:
            return
        if not self._should_skip(event.src_path):
            self.watcher.mark_changed(event.src_path)

    def on_created(self, event: "FileSystemEvent"):
        if event.is_directory:
            return
        if not self._should_skip(event.src_path):
            self.watcher.mark_changed(event.src_path)

    def on_deleted(self, event: "FileSystemEvent"):
        if event.is_directory:
            return
        if not self._should_skip(event.src_path):
            self.watcher.mark_deleted(event.src_path)


class ProjectWatcher:
    """Watches a project directory and auto-syncs changes to the server.

    Usage:
        watcher = ProjectWatcher(project_root, config)
        watcher.start()  # blocks until Ctrl+C
    """

    def __init__(
        self,
        project_root: Path,
        server_url: str,
        project_name: str,
        debounce_ms: int = 500,
        token: str = "",
        include_patterns: list = None,
        max_file_size: int = 5_000_000,
    ):
        if not HAS_WATCHDOG:
            raise ImportError(
                "watchdog is required for watch mode. Install with: pip install watchdog"
            )

        self.project_root = project_root.resolve()
        self.syncer = Syncer(server_url, token=token)
        self.project_name = project_name
        self.debounce_s = debounce_ms / 1000.0
        self.include_patterns = include_patterns or ["**/*.py"]
        self.max_file_size = max_file_size

        self._changed: Set[str] = set()
        self._deleted: Set[str] = set()
        self._lock = threading.Lock()
        self._timer: Optional[threading.Timer] = None
        self._hash_cache: Dict[str, str] = {}
        self._observer: Optional["Observer"] = None

    def mark_changed(self, abs_path: str):
        """Record a file as changed (will be synced after debounce)."""
        with self._lock:
            self._changed.add(abs_path)
            self._deleted.discard(abs_path)
        self._schedule_sync()

    def mark_deleted(self, abs_path: str):
        """Record a file as deleted (will be removed on server after debounce)."""
        with self._lock:
            self._deleted.add(abs_path)
            self._changed.discard(abs_path)
        self._schedule_sync()

    def _schedule_sync(self):
        """Schedule a debounced sync. Resets the timer on each call."""
        if self._timer is not None:
            self._timer.cancel()
        self._timer = threading.Timer(self.debounce_s, self._do_sync)
        self._timer.daemon = True
        self._timer.start()

    def _do_sync(self):
        """Execute the sync — upload changed files, handle deletions."""
        with self._lock:
            changed = list(self._changed)
            deleted = list(self._deleted)
            self._changed.clear()
            self._deleted.clear()

        if not changed and not deleted:
            return

        # Read changed files and compute hashes
        files_to_upload: Dict[str, bytes] = {}
        deleted_rel: list = []

        for abs_path in changed:
            p = Path(abs_path)
            if not p.exists() or not p.is_file():
                continue
            try:
                if p.stat().st_size > self.max_file_size:
                    logger.debug(f"Skipping oversized file: {abs_path}")
                    continue
            except OSError:
                continue
            if _is_binary(p):
                continue
            try:
                rel = str(p.relative_to(self.project_root)).replace("\\", "/")
                content = p.read_bytes()
                files_to_upload[rel] = content
                logger.info(f"  {rel} changed")
            except (OSError, ValueError):
                continue

        for abs_path in deleted:
            try:
                rel = str(Path(abs_path).relative_to(self.project_root)).replace("\\", "/")
                deleted_rel.append(rel)
                logger.info(f"  {rel} deleted")
            except ValueError:
                continue

        if not files_to_upload and not deleted_rel:
            return

        # Upload
        try:
            ts = time.strftime("%H:%M:%S")
            result = self.syncer.upload_files(
                self.project_name, files_to_upload, deleted_rel or None
            )
            synced = result.get("synced", 0)
            graphs = result.get("graphs_rebuilt", [])
            logger.info(
                f"[{ts}] Synced {synced} file(s), "
                f"{len(graphs)} graph(s) rebuilt"
            )
        except SyncError as e:
            logger.error(f"Sync failed: {e}")
            # Put files back for retry on next change
            with self._lock:
                self._changed.update(changed)
                self._deleted.update(deleted)

    def start(self):
        """Start watching. Blocks until interrupted (Ctrl+C)."""
        handler = _ChangeHandler(self.project_root, self, self.include_patterns)
        self._observer = Observer()
        self._observer.schedule(handler, str(self.project_root), recursive=True)
        self._observer.start()

        logger.info(f"Watching {self.project_root} for changes...")
        logger.info("Press Ctrl+C to stop.\n")

        try:
            while self._observer.is_alive():
                self._observer.join(timeout=1)
        except KeyboardInterrupt:
            logger.info("\nStopping watcher...")
            # Flush any pending changes
            if self._timer is not None:
                self._timer.cancel()
            self._do_sync()
        finally:
            self._observer.stop()
            self._observer.join()
            logger.info("Watcher stopped.")

    @staticmethod
    def launch_daemon(project_root: Path, pid_file: Path, log_file: Path):
        """Launch a watcher as a detached background subprocess.

        Cross-platform:
        - macOS/Linux: uses start_new_session=True (calls setsid)
        - Windows: uses DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP |
          CREATE_NO_WINDOW creationflags to fully detach from console
        Returns the child PID.
        """
        import subprocess
        import sys

        # The daemon subprocess runs this module's _daemon_entry function
        script = (
            f"import sys; sys.path.insert(0, {str(project_root.parent)!r}); "
            f"from rubberduck_index.watcher import _daemon_entry; "
            f"_daemon_entry({str(project_root)!r}, {str(pid_file)!r}, {str(log_file)!r})"
        )

        # Build platform-specific Popen kwargs
        popen_kwargs = {}
        if os.name == "nt":
            # Windows: use creationflags to detach from console
            DETACHED_PROCESS = 0x00000008
            CREATE_NEW_PROCESS_GROUP = 0x00000200
            CREATE_NO_WINDOW = 0x08000000
            popen_kwargs["creationflags"] = (
                DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
            )
            popen_kwargs["close_fds"] = True
        else:
            # macOS/Linux: create new session (calls setsid)
            popen_kwargs["start_new_session"] = True

        with open(str(log_file), "a") as log_fd:
            proc = subprocess.Popen(
                [sys.executable, "-c", script],
                stdin=subprocess.DEVNULL,
                stdout=log_fd,
                stderr=log_fd,
                env={**os.environ, "PYTHONPATH": os.environ.get("PYTHONPATH", "")},
                **popen_kwargs,
            )
        pid_file.write_text(str(proc.pid))
        return proc.pid

    def stop(self):
        """Stop the watcher (for programmatic use)."""
        if self._observer is not None:
            self._observer.stop()


def _daemon_entry(project_root_str: str, pid_file_str: str, log_file_str: str):
    """Entry point for the daemon subprocess. Called by launch_daemon."""
    import signal

    project_root = Path(project_root_str)
    pid_file = Path(pid_file_str)
    log_file = Path(log_file_str)

    # Set up logging to file
    file_handler = logging.FileHandler(str(log_file))
    file_handler.setFormatter(logging.Formatter(
        "%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
    ))
    root_logger = logging.getLogger()
    for h in root_logger.handlers[:]:
        root_logger.removeHandler(h)
    root_logger.addHandler(file_handler)
    root_logger.setLevel(logging.INFO)

    # Load config
    config = load_config(project_root)
    token = config.get("token", "") or os.environ.get("RUBBERDUCK_TOKEN", "")

    watcher = ProjectWatcher(
        project_root=project_root,
        server_url=config["server"],
        project_name=config["project"],
        debounce_ms=config.get("watch_debounce_ms", 500),
        token=token,
        include_patterns=config.get("include", ["**/*.py"]),
        max_file_size=config.get("max_file_size", 5_000_000),
    )

    # Write actual PID (in case Popen returns a different one)
    pid_file.write_text(str(os.getpid()))

    def _handle_signal(signum, frame):
        logger.info("Received signal %d, stopping...", signum)
        watcher.stop()
        try:
            pid_file.unlink()
        except OSError:
            pass

    signal.signal(signal.SIGINT, _handle_signal)
    # SIGTERM works on macOS/Linux; on Windows os.kill(pid, SIGTERM)
    # calls TerminateProcess which bypasses handlers, but we register
    # it anyway in case the daemon is stopped via other means
    if hasattr(signal, "SIGTERM"):
        signal.signal(signal.SIGTERM, _handle_signal)

    logger.info("Daemon watcher started (PID %d) for project '%s'",
                os.getpid(), config["project"])
    watcher.start()
