from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
from fnmatch import fnmatch
from pathlib import Path
import hashlib
import json
import logging
import os
import shutil
import uuid

from .config import AppConfig
from .state import FileState, StateStore

logger = logging.getLogger(__name__)


@dataclass(slots=True)
class FileSnapshot:
    rel_path: str
    abs_path: Path
    size: int
    mtime_ns: int
    _hash: str | None = None

    @property
    def digest(self) -> str:
        if self._hash is None:
            self._hash = sha256_file(self.abs_path)
        return self._hash


@dataclass(slots=True)
class SyncStats:
    copied_to_source: int = 0
    copied_to_target: int = 0
    deleted_from_source: int = 0
    deleted_from_target: int = 0
    conflicts: int = 0
    backed_up: int = 0
    skipped: int = 0
    errors: int = 0
    dry_run: bool = False

    def to_dict(self) -> dict[str, int | bool]:
        return {
            "copied_to_source": self.copied_to_source,
            "copied_to_target": self.copied_to_target,
            "deleted_from_source": self.deleted_from_source,
            "deleted_from_target": self.deleted_from_target,
            "conflicts": self.conflicts,
            "backed_up": self.backed_up,
            "skipped": self.skipped,
            "errors": self.errors,
            "dry_run": self.dry_run,
        }


class SyncEngine:
    def __init__(self, config: AppConfig) -> None:
        self.config = config
        self.source_root = config.paths.source_vault
        self.target_root = config.paths.synology_vault
        self.backup_root = self._resolve_backup_root(config.sync.backup_dir)
        self.state = StateStore(config.state_db_path)

    def close(self) -> None:
        self.state.close()

    def sync_once(self, *, dry_run: bool = False) -> SyncStats:
        stats = SyncStats(dry_run=dry_run)
        self.config.ensure_directories()

        source_files = self._scan_side(self.source_root)
        target_files = self._scan_side(self.target_root)
        all_paths = sorted(set(source_files) | set(target_files))

        collisions = detect_case_collisions(all_paths)
        if collisions:
            preview = "; ".join(f"{a} <-> {b}" for a, b in collisions[:3])
            raise RuntimeError(
                "Windows-safe filename collision detected (case-only difference): "
                + preview
            )

        for rel_path in all_paths:
            source_file = source_files.get(rel_path)
            target_file = target_files.get(rel_path)
            prev = self.state.get_file_state(rel_path)

            try:
                self._reconcile_path(rel_path, source_file, target_file, prev, stats, dry_run)
            except Exception as exc:  # noqa: BLE001
                stats.errors += 1
                logger.exception("Failed syncing %s: %s", rel_path, exc)

        if not dry_run:
            self._refresh_state()
            self.state.set_metadata("last_sync_at", utc_now_iso())
            self.state.set_metadata("last_sync_stats", json.dumps(stats.to_dict(), sort_keys=True))

        return stats

    def _reconcile_path(
        self,
        rel_path: str,
        source_file: FileSnapshot | None,
        target_file: FileSnapshot | None,
        prev: FileState | None,
        stats: SyncStats,
        dry_run: bool,
    ) -> None:
        if source_file and target_file:
            self._reconcile_both_exist(source_file, target_file, prev, stats, dry_run)
            return

        if source_file and not target_file:
            source_hash = source_file.digest
            if prev is None:
                self._copy_file(source_file.abs_path, self.target_root / rel_path, "target", rel_path, stats, dry_run)
                return

            source_changed = prev.source_hash != source_hash
            target_was_present = prev.target_hash is not None

            if target_was_present and not source_changed:
                self._delete_file(source_file.abs_path, "source", rel_path, stats, dry_run)
            else:
                self._copy_file(source_file.abs_path, self.target_root / rel_path, "target", rel_path, stats, dry_run)
            return

        if target_file and not source_file:
            target_hash = target_file.digest
            if prev is None:
                self._copy_file(target_file.abs_path, self.source_root / rel_path, "source", rel_path, stats, dry_run)
                return

            target_changed = prev.target_hash != target_hash
            source_was_present = prev.source_hash is not None

            if source_was_present and not target_changed:
                self._delete_file(target_file.abs_path, "target", rel_path, stats, dry_run)
            else:
                self._copy_file(target_file.abs_path, self.source_root / rel_path, "source", rel_path, stats, dry_run)
            return

    def _reconcile_both_exist(
        self,
        source_file: FileSnapshot,
        target_file: FileSnapshot,
        prev: FileState | None,
        stats: SyncStats,
        dry_run: bool,
    ) -> None:
        source_hash = source_file.digest
        target_hash = target_file.digest

        if source_hash == target_hash:
            stats.skipped += 1
            return

        source_changed = prev is None or prev.source_hash != source_hash
        target_changed = prev is None or prev.target_hash != target_hash

        if source_changed and not target_changed:
            self._copy_file(
                source_file.abs_path,
                target_file.abs_path,
                "target",
                source_file.rel_path,
                stats,
                dry_run,
            )
            return

        if target_changed and not source_changed:
            self._copy_file(
                target_file.abs_path,
                source_file.abs_path,
                "source",
                target_file.rel_path,
                stats,
                dry_run,
            )
            return

        winner = source_file if source_file.mtime_ns >= target_file.mtime_ns else target_file
        loser = target_file if winner is source_file else source_file
        loser_side = "target" if loser is target_file else "source"

        self._write_conflict_copy(loser.abs_path, loser_side, loser.rel_path, stats, dry_run)

        destination = target_file.abs_path if winner is source_file else source_file.abs_path
        direction = "target" if winner is source_file else "source"
        self._copy_file(winner.abs_path, destination, direction, winner.rel_path, stats, dry_run)
        stats.conflicts += 1

    def _copy_file(
        self,
        src: Path,
        dst: Path,
        dst_side: str,
        rel_path: str,
        stats: SyncStats,
        dry_run: bool,
    ) -> None:
        if dst.exists():
            self._backup_file(dst, dst_side, rel_path, stats, dry_run)

        if dry_run:
            logger.info("[dry-run] copy %s -> %s", src, dst)
        else:
            atomic_copy(src, dst)
            logger.info("Copied %s -> %s", src, dst)

        if dst_side == "source":
            stats.copied_to_source += 1
        else:
            stats.copied_to_target += 1

    def _delete_file(
        self,
        path: Path,
        side: str,
        rel_path: str,
        stats: SyncStats,
        dry_run: bool,
    ) -> None:
        if not path.exists():
            return

        self._backup_file(path, side, rel_path, stats, dry_run)

        if dry_run:
            logger.info("[dry-run] delete %s", path)
        else:
            path.unlink()
            cleanup_empty_parents(path.parent, self.source_root if side == "source" else self.target_root)
            logger.info("Deleted %s", path)

        if side == "source":
            stats.deleted_from_source += 1
        else:
            stats.deleted_from_target += 1

    def _write_conflict_copy(
        self,
        path: Path,
        side: str,
        rel_path: str,
        stats: SyncStats,
        dry_run: bool,
    ) -> None:
        stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        conflict_name = f"{path.name}.conflict-{side}-{stamp}"
        conflict_path = path.with_name(conflict_name)

        if dry_run:
            logger.warning("[dry-run] conflict copy %s -> %s", path, conflict_path)
            return

        atomic_copy(path, conflict_path)
        logger.warning("Conflict copy written for %s at %s", rel_path, conflict_path)

    def _backup_file(
        self,
        path: Path,
        side: str,
        rel_path: str,
        stats: SyncStats,
        dry_run: bool,
    ) -> None:
        stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        backup_path = self.backup_root / side / stamp / rel_path

        if dry_run:
            logger.info("[dry-run] backup %s -> %s", path, backup_path)
            stats.backed_up += 1
            return

        backup_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(path, backup_path)
        stats.backed_up += 1

    def _refresh_state(self) -> None:
        source_files = self._scan_side(self.source_root)
        target_files = self._scan_side(self.target_root)
        all_paths = sorted(set(source_files) | set(target_files))

        rows: list[FileState] = []
        for rel_path in all_paths:
            source_hash = source_files[rel_path].digest if rel_path in source_files else None
            target_hash = target_files[rel_path].digest if rel_path in target_files else None
            rows.append(FileState(rel_path=rel_path, source_hash=source_hash, target_hash=target_hash))

        self.state.upsert_file_states(rows)
        self.state.delete_not_in(set(all_paths))

    def _scan_side(self, root: Path) -> dict[str, FileSnapshot]:
        out: dict[str, FileSnapshot] = {}
        if not root.exists():
            return out

        for abs_path in root.rglob("*"):
            if not abs_path.is_file() or abs_path.is_symlink():
                continue

            rel_path = abs_path.relative_to(root).as_posix()
            if self._is_ignored_rel(rel_path):
                continue

            stat = abs_path.stat()
            out[rel_path] = FileSnapshot(
                rel_path=rel_path,
                abs_path=abs_path,
                size=stat.st_size,
                mtime_ns=stat.st_mtime_ns,
            )
        return out

    def should_ignore_absolute(self, abs_path: Path) -> bool:
        abs_resolved = abs_path.resolve()

        for root in (self.source_root, self.target_root):
            try:
                rel = abs_resolved.relative_to(root.resolve()).as_posix()
            except ValueError:
                continue
            return self._is_ignored_rel(rel)

        return False

    def _is_ignored_rel(self, rel_path: str) -> bool:
        rel = rel_path.replace("\\", "/")
        parts = rel.split("/")

        for pattern in self.config.sync.exclude:
            normalized = pattern.strip().replace("\\", "/")
            if not normalized:
                continue

            if "/" in normalized:
                clean = normalized.rstrip("/")
                if rel == clean or rel.startswith(clean + "/"):
                    return True
                if fnmatch(rel, normalized):
                    return True
            else:
                if fnmatch(rel, normalized):
                    return True
                for part in parts:
                    if fnmatch(part, normalized):
                        return True

        return False

    def _resolve_backup_root(self, backup_dir: str) -> Path:
        as_path = Path(backup_dir)
        if as_path.is_absolute():
            return as_path
        return (self.target_root / as_path).resolve()


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def atomic_copy(src: Path, dst: Path) -> None:
    dst.parent.mkdir(parents=True, exist_ok=True)
    tmp = dst.parent / f".{dst.name}.tmp-{uuid.uuid4().hex}"

    try:
        shutil.copy2(src, tmp)
        os.replace(tmp, dst)
    finally:
        if tmp.exists():
            tmp.unlink(missing_ok=True)


def detect_case_collisions(rel_paths: list[str]) -> list[tuple[str, str]]:
    lower_map: dict[str, str] = {}
    collisions: list[tuple[str, str]] = []

    for path in rel_paths:
        lowered = path.lower()
        prev = lower_map.get(lowered)
        if prev is not None and prev != path:
            collisions.append((prev, path))
        else:
            lower_map[lowered] = path

    return collisions


def cleanup_empty_parents(path: Path, stop_at: Path) -> None:
    stop = stop_at.resolve()
    current = path

    while True:
        try:
            current.relative_to(stop)
        except ValueError:
            return

        if current == stop:
            return

        try:
            current.rmdir()
        except OSError:
            return

        current = current.parent


def utc_now_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
