from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from fnmatch import fnmatch
from pathlib import Path
from typing import Any, Callable
import hashlib
import json
import logging
import os
import re
import shutil
import time
import urllib.error
import urllib.request
import uuid

from .config import AppConfig
from .crypto import (
    CryptoUnavailableError,
    EncryptionConfigError,
    EncryptionManager,
    atomic_write_bytes,
    encrypted_artifact_path,
    encrypted_vault_path,
    is_encrypted_payload,
    logical_vault_path,
)
from .state import ActivityRecord, ConflictRecord, FileState, SnapshotRecord, StateStore

logger = logging.getLogger(__name__)

IMAGE_EXTENSIONS = {
    ".avif",
    ".bmp",
    ".gif",
    ".heic",
    ".heif",
    ".ico",
    ".jpeg",
    ".jpg",
    ".png",
    ".svg",
    ".tif",
    ".tiff",
    ".webp",
}
AUDIO_EXTENSIONS = {
    ".aac",
    ".aiff",
    ".flac",
    ".m4a",
    ".mp3",
    ".ogg",
    ".opus",
    ".wav",
    ".wma",
}
VIDEO_EXTENSIONS = {
    ".avi",
    ".flv",
    ".m4v",
    ".mkv",
    ".mov",
    ".mp4",
    ".webm",
    ".wmv",
}
ALWAYS_SYNC_EXTENSIONS = {
    ".canvas",
    ".excalidraw",
    ".md",
    ".txt",
}
OBSIDIAN_PREFIX = ".obsidian/"
MARKDOWN_EXTENSIONS = {".md", ".markdown"}
TAG_PATTERN = re.compile(r"(?<![\w/])#([A-Za-z0-9_/-]+)")
CONFLICT_COPY_NAME_PATTERN = re.compile(
    r"\.conflict-(source|target)-\d{8}T\d{6}Z(?:\.osync\.enc){0,2}$"
)


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

    @property
    def digest(self) -> str:
        if self._hash is None:
            if self.hash_loader is None:
                self._hash = sha256_file(self.abs_path)
            else:
                self._hash = self.hash_loader(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
    renamed_to_source: int = 0
    renamed_to_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,
            "renamed_to_source": self.renamed_to_source,
            "renamed_to_target": self.renamed_to_target,
            "conflicts": self.conflicts,
            "backed_up": self.backed_up,
            "skipped": self.skipped,
            "errors": self.errors,
            "dry_run": self.dry_run,
        }


@dataclass(slots=True)
class RestoreResult:
    conflict_id: int
    rel_path: str
    side: str
    destination: Path
    dry_run: bool
    backup_path: Path | None = None


@dataclass(slots=True)
class SnapshotRestoreResult:
    snapshot_id: int
    rel_path: str
    side: str
    destination: Path
    event_type: str
    dry_run: bool
    backup_path: Path | None = None


@dataclass(slots=True)
class AuditIssue:
    issue_type: str
    rel_path: str
    details: dict[str, Any]


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)
        self._encryption: EncryptionManager | None = None
        self._snapshot_dedup_keys: set[tuple[str, str, str, str]] = set()

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

    def list_conflicts(self, *, limit: int = 20, include_resolved: bool = False) -> list[ConflictRecord]:
        return self.state.list_conflicts(limit=limit, include_resolved=include_resolved)

    def list_snapshots(
        self,
        *,
        limit: int = 50,
        rel_path: str | None = None,
        event_type: str | None = None,
        include_restored: bool = True,
    ) -> list[SnapshotRecord]:
        return self.state.list_snapshots(
            limit=limit,
            rel_path=rel_path,
            event_type=event_type,
            include_restored=include_restored,
        )

    def list_deleted_files(self, *, limit: int = 50, include_restored: bool = False) -> list[SnapshotRecord]:
        return self.state.list_snapshots(
            limit=limit,
            event_type="deleted",
            include_restored=include_restored,
        )

    def list_activity(self, *, limit: int = 100, rel_path: str | None = None) -> list[ActivityRecord]:
        return self.state.list_activity(limit=limit, rel_path=rel_path)

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

        source_files = self._scan_side(self.source_root, side="source")
        target_files = self._scan_side(self.target_root, side="target")

        self._apply_rename_tracking(source_files, target_files, stats, dry_run=dry_run)

        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
                if _is_transient_sync_exception(exc):
                    stats.skipped += 1
                    logger.warning(
                        "Skipped %s due to transient read/decrypt lock: %s",
                        rel_path,
                        exc,
                    )
                    continue
                stats.errors += 1
                logger.exception("Failed syncing %s: %s", rel_path, exc)
                if not dry_run:
                    self._record_activity(
                        action="sync_error",
                        rel_path=rel_path,
                        side=None,
                        details={"error": str(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))
            self._maybe_send_alerts(stats)

        return stats

    def audit_integrity(self, *, repair: bool = False, dry_run: bool = False) -> list[AuditIssue]:
        issues = self._collect_integrity_issues()
        if repair and issues and not dry_run:
            self._record_activity(
                action="integrity_repair_started",
                rel_path=None,
                side=None,
                details={"issue_count": len(issues)},
            )
            self.sync_once(dry_run=False)
            remaining = self._collect_integrity_issues()
            self._record_activity(
                action="integrity_repair_finished",
                rel_path=None,
                side=None,
                details={"remaining_issue_count": len(remaining)},
            )
            return remaining
        return issues

    def resolve_open_conflicts(
        self,
        *,
        policy: str,
        dry_run: bool = False,
        limit: int | None = None,
    ) -> dict[str, int]:
        selected = policy.strip().lower()
        if selected not in {"latest", "source", "target"}:
            raise ValueError("Policy must be one of: latest, source, target")

        rows = self.state.get_unresolved_conflicts()
        if limit is not None and limit > 0:
            rows = rows[:limit]

        result = {"resolved": 0, "skipped": 0, "errors": 0}

        for row in rows:
            try:
                chosen_side = self._choose_resolution_side(row, selected)
                if chosen_side is None:
                    result["skipped"] += 1
                    continue

                rel_path = row.rel_path
                if chosen_side == "source":
                    src = self._resolve_vault_file_path("source", rel_path)
                    dst = self._resolve_vault_file_path("target", rel_path)
                    dst_side = "target"
                else:
                    src = self._resolve_vault_file_path("target", rel_path)
                    dst = self._resolve_vault_file_path("source", rel_path)
                    dst_side = "source"

                if not src.exists():
                    result["skipped"] += 1
                    continue

                stats = SyncStats(dry_run=dry_run)
                src_hash = self._hash_file_for_side(src, side=chosen_side)
                self._copy_file(src, dst, dst_side, rel_path, stats, dry_run, src_hash=src_hash)
                if not dry_run:
                    self.state.mark_conflict_restored(row.conflict_id, chosen_side)
                    self._record_activity(
                        action="resolve_conflict_batch",
                        rel_path=rel_path,
                        side=chosen_side,
                        details={"conflict_id": row.conflict_id, "policy": selected},
                    )
                result["resolved"] += 1
            except Exception as exc:  # noqa: BLE001
                result["errors"] += 1
                logger.exception("Failed resolving conflict #%s: %s", row.conflict_id, exc)
        return result

    def restore_conflict(
        self,
        conflict_id: int,
        *,
        side: str | None = None,
        dry_run: bool = False,
    ) -> RestoreResult:
        conflict = self.state.get_conflict(conflict_id)
        if conflict is None:
            raise ValueError(f"Conflict ID {conflict_id} was not found.")

        target_side = side or conflict.loser_side
        if target_side not in {"source", "target"}:
            raise ValueError("Restore side must be one of: source, target")

        conflict_copy = Path(conflict.conflict_copy_path)
        if not conflict_copy.exists():
            raise FileNotFoundError(
                f"Conflict copy is missing: {conflict.conflict_copy_path}"
            )

        destination = self._resolve_vault_file_path(target_side, conflict.rel_path)
        backup_path: Path | None = None
        if destination.exists():
            backup_path = self._backup_restore_target(
                destination,
                target_side,
                conflict.rel_path,
                dry_run=dry_run,
            )

        if dry_run:
            logger.info("[dry-run] restore conflict #%s from %s -> %s", conflict_id, conflict_copy, destination)
        else:
            artifact_side = conflict.loser_side if conflict.loser_side in {"source", "target"} else target_side
            self._restore_artifact_to_destination(
                conflict_copy,
                destination,
                artifact_side=artifact_side,
                destination_side=target_side,
                op=f"restore conflict #{conflict_id} to {destination}",
            )
            self.state.mark_conflict_restored(conflict_id, target_side)
            self._record_activity(
                action="restore_conflict",
                rel_path=conflict.rel_path,
                side=target_side,
                details={"conflict_id": conflict_id},
            )
            logger.info("Restored conflict #%s to %s", conflict_id, destination)

        return RestoreResult(
            conflict_id=conflict_id,
            rel_path=conflict.rel_path,
            side=target_side,
            destination=destination,
            backup_path=backup_path,
            dry_run=dry_run,
        )

    def restore_snapshot(
        self,
        snapshot_id: int,
        *,
        side: str | None = None,
        dry_run: bool = False,
    ) -> SnapshotRestoreResult:
        snapshot = self.state.get_snapshot(snapshot_id)
        if snapshot is None:
            raise ValueError(f"Snapshot ID {snapshot_id} was not found.")

        snapshot_file = Path(snapshot.snapshot_path)
        if not snapshot_file.exists():
            raise FileNotFoundError(f"Snapshot file is missing: {snapshot.snapshot_path}")

        target_side = side or snapshot.side
        if target_side not in {"source", "target"}:
            raise ValueError("Restore side must be one of: source, target")

        destination = self._resolve_vault_file_path(target_side, snapshot.rel_path)
        backup_path: Path | None = None
        if destination.exists():
            backup_path = self._backup_restore_target(
                destination,
                target_side,
                snapshot.rel_path,
                dry_run=dry_run,
            )

        if dry_run:
            logger.info(
                "[dry-run] restore snapshot #%s from %s -> %s",
                snapshot_id,
                snapshot_file,
                destination,
            )
        else:
            self._restore_artifact_to_destination(
                snapshot_file,
                destination,
                artifact_side=snapshot.side,
                destination_side=target_side,
                op=f"restore snapshot #{snapshot_id} to {destination}",
            )
            self.state.mark_snapshot_restored(snapshot_id, target_side)
            self._record_activity(
                action="restore_snapshot",
                rel_path=snapshot.rel_path,
                side=target_side,
                details={"snapshot_id": snapshot_id, "event_type": snapshot.event_type},
            )
            logger.info("Restored snapshot #%s to %s", snapshot_id, destination)

        return SnapshotRestoreResult(
            snapshot_id=snapshot_id,
            rel_path=snapshot.rel_path,
            side=target_side,
            destination=destination,
            event_type=snapshot.event_type,
            backup_path=backup_path,
            dry_run=dry_run,
        )

    def health_report(self) -> dict[str, Any]:
        now = datetime.now(timezone.utc)
        last_sync_raw = self.state.get_metadata("last_sync_at")
        stale = True
        age_seconds: float | None = None
        if last_sync_raw:
            try:
                last_sync = datetime.fromisoformat(last_sync_raw)
                age_seconds = (now - last_sync).total_seconds()
                stale = age_seconds > self.config.sync.health_stale_after_seconds
            except ValueError:
                stale = True

        one_day_ago = (now - timedelta(days=1)).replace(microsecond=0).isoformat()
        recent_errors = self.state.count_activity(action="sync_error", since_iso=one_day_ago)
        open_conflicts = self.state.count_conflicts(unresolved_only=True)
        deleted_recoveries = self.state.count_deleted_files(unresolved_only=True)

        return {
            "stale": stale,
            "last_sync_at": last_sync_raw,
            "age_seconds": age_seconds,
            "open_conflicts": open_conflicts,
            "deleted_recoveries": deleted_recoveries,
            "recent_errors_24h": recent_errors,
            "encryption_enabled": bool(self.config.sync.encryption_enabled),
            "vault_encryption_enabled": bool(self.config.sync.vault_encryption_enabled),
            "status": "ok" if not stale and recent_errors == 0 else "degraded",
        }

    def _collect_integrity_issues(self) -> list[AuditIssue]:
        source_files = self._scan_side(self.source_root, side="source")
        target_files = self._scan_side(self.target_root, side="target")
        tracked = {row.rel_path: row for row in self.state.list_file_states()}

        issues: list[AuditIssue] = []
        all_paths = sorted(set(source_files) | set(target_files) | set(tracked))

        for rel_path in all_paths:
            source = source_files.get(rel_path)
            target = target_files.get(rel_path)
            previous = tracked.get(rel_path)

            if source and not target:
                issues.append(AuditIssue("missing_in_target", rel_path, {"source_hash": source.digest}))
                continue
            if target and not source:
                issues.append(AuditIssue("missing_in_source", rel_path, {"target_hash": target.digest}))
                continue
            if source and target:
                if source.digest != target.digest:
                    issues.append(
                        AuditIssue(
                            "hash_mismatch",
                            rel_path,
                            {"source_hash": source.digest, "target_hash": target.digest},
                        )
                    )
                if previous and previous.source_hash and previous.source_hash != source.digest:
                    issues.append(
                        AuditIssue(
                            "source_drift_vs_state",
                            rel_path,
                            {"state_hash": previous.source_hash, "source_hash": source.digest},
                        )
                    )
                if previous and previous.target_hash and previous.target_hash != target.digest:
                    issues.append(
                        AuditIssue(
                            "target_drift_vs_state",
                            rel_path,
                            {"state_hash": previous.target_hash, "target_hash": target.digest},
                        )
                    )

        return issues

    def _apply_rename_tracking(
        self,
        source_files: dict[str, FileSnapshot],
        target_files: dict[str, FileSnapshot],
        stats: SyncStats,
        *,
        dry_run: bool,
    ) -> None:
        prev_states = self.state.list_file_states()
        if not prev_states:
            return

        source_unpaired = self._build_unpaired_hash_map(source_files, target_files)
        target_unpaired = self._build_unpaired_hash_map(target_files, source_files)

        for prev in prev_states:
            old_path = prev.rel_path

            if old_path not in source_files and old_path in target_files and prev.source_hash:
                new_path = self._pick_rename_candidate(source_unpaired, prev.source_hash, avoid=old_path, other_side=target_files)
                if new_path:
                    moved = self._mirror_rename(
                        side="target",
                        old_rel=old_path,
                        new_rel=new_path,
                        source_files=source_files,
                        target_files=target_files,
                        stats=stats,
                        dry_run=dry_run,
                    )
                    if moved:
                        self._consume_unpaired(source_unpaired, prev.source_hash, new_path)

            if old_path not in target_files and old_path in source_files and prev.target_hash:
                new_path = self._pick_rename_candidate(target_unpaired, prev.target_hash, avoid=old_path, other_side=source_files)
                if new_path:
                    moved = self._mirror_rename(
                        side="source",
                        old_rel=old_path,
                        new_rel=new_path,
                        source_files=source_files,
                        target_files=target_files,
                        stats=stats,
                        dry_run=dry_run,
                    )
                    if moved:
                        self._consume_unpaired(target_unpaired, prev.target_hash, new_path)

    def _build_unpaired_hash_map(
        self,
        side: dict[str, FileSnapshot],
        other_side: dict[str, FileSnapshot],
    ) -> dict[str, list[str]]:
        out: dict[str, list[str]] = {}
        for rel_path, snap in side.items():
            if rel_path in other_side:
                continue
            out.setdefault(snap.digest, []).append(rel_path)
        return out

    def _pick_rename_candidate(
        self,
        by_hash: dict[str, list[str]],
        file_hash: str,
        *,
        avoid: str,
        other_side: dict[str, FileSnapshot],
    ) -> str | None:
        for rel in by_hash.get(file_hash, []):
            if rel != avoid and rel not in other_side:
                return rel
        return None

    def _consume_unpaired(self, by_hash: dict[str, list[str]], file_hash: str, rel_path: str) -> None:
        bucket = by_hash.get(file_hash)
        if not bucket:
            return
        try:
            bucket.remove(rel_path)
        except ValueError:
            return
        if not bucket:
            by_hash.pop(file_hash, None)

    def _mirror_rename(
        self,
        *,
        side: str,
        old_rel: str,
        new_rel: str,
        source_files: dict[str, FileSnapshot],
        target_files: dict[str, FileSnapshot],
        stats: SyncStats,
        dry_run: bool,
    ) -> bool:
        root = self.source_root if side == "source" else self.target_root
        src = self._resolve_vault_file_path(side, old_rel)
        dst = self._resolve_vault_file_path(side, new_rel)

        if not src.exists() or dst.exists():
            return False

        if dry_run:
            logger.info("[dry-run] rename %s -> %s", src, dst)
            return True

        self._retry_io(lambda: dst.parent.mkdir(parents=True, exist_ok=True), op=f"mkdir for rename {dst.parent}")
        self._retry_io(lambda: os.replace(src, dst), op=f"rename {src} -> {dst}")
        cleanup_empty_parents(src.parent, root)

        stat = dst.stat()
        moved = FileSnapshot(rel_path=new_rel, abs_path=dst, size=stat.st_size, mtime_ns=stat.st_mtime_ns)
        side_map = source_files if side == "source" else target_files
        side_map.pop(old_rel, None)
        side_map[new_rel] = moved

        if side == "source":
            stats.renamed_to_source += 1
        else:
            stats.renamed_to_target += 1

        self._record_activity(
            action=f"rename_on_{side}",
            rel_path=new_rel,
            side=side,
            details={"from": old_rel, "to": new_rel},
        )
        return True

    def _choose_resolution_side(self, row: ConflictRecord, policy: str) -> str | None:
        rel = row.rel_path
        src = self._resolve_vault_file_path("source", rel)
        tgt = self._resolve_vault_file_path("target", rel)

        if policy == "source":
            if src.exists():
                return "source"
            if tgt.exists():
                return "target"
            return None
        if policy == "target":
            if tgt.exists():
                return "target"
            if src.exists():
                return "source"
            return None

        if src.exists() and tgt.exists():
            return "source" if src.stat().st_mtime_ns >= tgt.stat().st_mtime_ns else "target"
        if src.exists():
            return "source"
        if tgt.exists():
            return "target"
        return None

    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._resolve_vault_file_path("target", rel_path),
                    "target",
                    rel_path,
                    stats,
                    dry_run,
                    src_hash=source_hash,
                )
                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._resolve_vault_file_path("target", rel_path),
                    "target",
                    rel_path,
                    stats,
                    dry_run,
                    src_hash=source_hash,
                )
            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._resolve_vault_file_path("source", rel_path),
                    "source",
                    rel_path,
                    stats,
                    dry_run,
                    src_hash=target_hash,
                )
                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._resolve_vault_file_path("source", rel_path),
                    "source",
                    rel_path,
                    stats,
                    dry_run,
                    src_hash=target_hash,
                )
            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,
                src_hash=source_hash,
            )
            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,
                src_hash=target_hash,
            )
            return

        policy = self.config.sync.conflict_policy
        if policy == "source":
            winner = source_file
            loser = target_file
        elif policy == "target":
            winner = target_file
            loser = source_file
        elif policy == "manual":
            source_copy = self._write_conflict_copy(
                source_file.abs_path,
                "source",
                source_file.rel_path,
                stats,
                dry_run,
                file_hash=source_hash,
            )
            target_copy = self._write_conflict_copy(
                target_file.abs_path,
                "target",
                target_file.rel_path,
                stats,
                dry_run,
                file_hash=target_hash,
            )
            if not dry_run:
                conflict_path = str(source_copy or target_copy or "")
                conflict_id = self.state.add_conflict(
                    rel_path=source_file.rel_path,
                    winner_side="manual",
                    loser_side="manual",
                    winner_hash=source_hash,
                    loser_hash=target_hash,
                    conflict_copy_path=conflict_path,
                )
                self._record_activity(
                    action="conflict_detected",
                    rel_path=source_file.rel_path,
                    side="manual",
                    details={"conflict_id": conflict_id, "policy": "manual"},
                )
                logger.warning("Recorded manual conflict #%s for %s", conflict_id, source_file.rel_path)
            stats.conflicts += 1
            stats.skipped += 1
            return
        else:
            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

        winner_side = "source" if winner is source_file else "target"
        loser_side = "target" if loser is target_file else "source"

        conflict_copy = self._write_conflict_copy(
            loser.abs_path,
            loser_side,
            loser.rel_path,
            stats,
            dry_run,
            file_hash=loser.digest,
        )

        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,
            src_hash=winner.digest,
        )

        if not dry_run and conflict_copy is not None:
            conflict_id = self.state.add_conflict(
                rel_path=winner.rel_path,
                winner_side=winner_side,
                loser_side=loser_side,
                winner_hash=winner.digest,
                loser_hash=loser.digest,
                conflict_copy_path=str(conflict_copy),
            )
            self._record_activity(
                action="conflict_detected",
                rel_path=winner.rel_path,
                side=winner_side,
                details={"conflict_id": conflict_id, "loser_side": loser_side, "policy": policy},
            )
            logger.warning("Recorded conflict #%s for %s", conflict_id, winner.rel_path)
        stats.conflicts += 1

    def _copy_file(
        self,
        src: Path,
        dst: Path,
        dst_side: str,
        rel_path: str,
        stats: SyncStats,
        dry_run: bool,
        *,
        src_hash: str | None = None,
    ) -> None:
        source_side = "source" if dst_side == "target" else "target"
        dst_exists = dst.exists()
        if dst_exists:
            existing_hash = self._hash_file_for_side(dst, side=dst_side)
            self._backup_file(
                dst,
                dst_side,
                rel_path,
                stats,
                dry_run,
                event_type="snapshot",
                file_hash=existing_hash,
            )

        if dry_run:
            logger.info("[dry-run] copy %s -> %s", src, dst)
        else:
            used_delta = False
            if (
                dst_exists
                and self.config.sync.delta_sync_enabled
                and not self._uses_vault_transform(source_side=source_side, destination_side=dst_side)
            ):
                tracker = {"used": False}

                def delta_attempt() -> None:
                    tracker["used"] = self._delta_patch_copy(src, dst)

                self._retry_io(delta_attempt, op=f"delta copy {src} -> {dst}")
                used_delta = bool(tracker["used"])

            if not used_delta:
                self._copy_between_sides(
                    src,
                    source_side=source_side,
                    dst=dst,
                    destination_side=dst_side,
                    op=f"copy {src} -> {dst}",
                )

            source_hash = src_hash or self._hash_file_for_side(src, side=source_side)
            snapshot_id = self._capture_snapshot_from_file(
                src,
                rel_path,
                source_side,
                event_type="snapshot",
                file_hash=source_hash,
            )
            self._record_activity(
                action=f"copy_to_{dst_side}",
                rel_path=rel_path,
                side=dst_side,
                details={
                    "from_side": source_side,
                    "file_hash": source_hash,
                    "snapshot_id": snapshot_id,
                    "delta_used": used_delta,
                },
            )
            logger.info("Copied %s -> %s", src, dst)

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

    def _delta_patch_copy(self, src: Path, dst: Path) -> bool:
        src_stat = src.stat()
        dst_stat = dst.stat()
        if src_stat.st_size != dst_stat.st_size:
            return False
        if src_stat.st_size < self.config.sync.delta_min_file_size_bytes:
            return False

        chunk_size = self.config.sync.delta_chunk_size_bytes
        max_ratio = self.config.sync.delta_max_diff_ratio

        diff_offsets: list[tuple[int, bytes]] = []
        chunk_count = 0
        with src.open("rb") as src_f, dst.open("rb") as dst_f:
            offset = 0
            while True:
                src_chunk = src_f.read(chunk_size)
                dst_chunk = dst_f.read(chunk_size)
                if not src_chunk:
                    break
                chunk_count += 1
                if src_chunk != dst_chunk:
                    diff_offsets.append((offset, src_chunk))
                offset += len(src_chunk)

        if chunk_count == 0:
            return False
        if not diff_offsets:
            shutil.copystat(src, dst)
            return True

        diff_ratio = len(diff_offsets) / float(chunk_count)
        if diff_ratio > max_ratio:
            return False

        with dst.open("r+b") as dst_f:
            for offset, chunk in diff_offsets:
                dst_f.seek(offset)
                dst_f.write(chunk)

        shutil.copystat(src, dst)
        return True

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

        file_hash = self._hash_file_for_side(path, side=side)
        backup_path = self._backup_file(
            path,
            side,
            rel_path,
            stats,
            dry_run,
            event_type="deleted",
            file_hash=file_hash,
        )

        if dry_run:
            logger.info("[dry-run] delete %s", path)
        else:
            self._retry_io(path.unlink, op=f"delete {path}")
            cleanup_empty_parents(path.parent, self.source_root if side == "source" else self.target_root)
            self._record_activity(
                action=f"delete_from_{side}",
                rel_path=rel_path,
                side=side,
                details={"file_hash": file_hash, "snapshot_path": str(backup_path)},
            )
            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,
        *,
        file_hash: str,
    ) -> Path | None:
        stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        if self.config.sync.vault_encryption_enabled:
            conflict_path = self._artifact_path(
                self.backup_root / "conflicts" / side / stamp / rel_path
            )
        else:
            conflict_name = f"{path.name}.conflict-{side}-{stamp}"
            conflict_path = self._artifact_path(path.with_name(conflict_name))

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

        self._write_artifact_copy(
            path,
            conflict_path,
            src_side=side,
            op=f"write conflict copy {path} -> {conflict_path}",
        )
        self.state.add_snapshot(
            rel_path=rel_path,
            side=side,
            event_type="conflict_copy",
            file_hash=file_hash,
            snapshot_path=str(conflict_path),
        )
        logger.warning("Conflict copy written for %s at %s", rel_path, conflict_path)
        return conflict_path

    def _backup_file(
        self,
        path: Path,
        side: str,
        rel_path: str,
        stats: SyncStats,
        dry_run: bool,
        *,
        event_type: str | None = None,
        file_hash: str | None = None,
    ) -> Path:
        stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        backup_path = self._artifact_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

        backup_path.parent.mkdir(parents=True, exist_ok=True)
        self._write_artifact_copy(path, backup_path, src_side=side, op=f"backup {path} -> {backup_path}")
        if event_type:
            actual_hash = file_hash or self._hash_file_for_side(path, side=side)
            self.state.add_snapshot(
                rel_path=rel_path,
                side=side,
                event_type=event_type,
                file_hash=actual_hash,
                snapshot_path=str(backup_path),
            )
        stats.backed_up += 1
        return backup_path

    def _backup_restore_target(
        self,
        path: Path,
        side: str,
        rel_path: str,
        *,
        dry_run: bool,
    ) -> Path:
        stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        backup_path = self._artifact_path(self.backup_root / side / f"{stamp}-restore" / rel_path)
        if dry_run:
            logger.info("[dry-run] backup restore target %s -> %s", path, backup_path)
            return backup_path

        backup_path.parent.mkdir(parents=True, exist_ok=True)
        self._write_artifact_copy(
            path,
            backup_path,
            src_side=side,
            op=f"backup restore target {path} -> {backup_path}",
        )
        self.state.add_snapshot(
            rel_path=rel_path,
            side=side,
            event_type="restore_backup",
            file_hash=self._hash_file_for_side(path, side=side),
            snapshot_path=str(backup_path),
        )
        return backup_path

    def _capture_snapshot_from_file(
        self,
        path: Path,
        rel_path: str,
        side: str,
        *,
        event_type: str,
        file_hash: str,
    ) -> int | None:
        dedupe_key = (rel_path, side, event_type, file_hash)
        if dedupe_key in self._snapshot_dedup_keys:
            return None
        self._snapshot_dedup_keys.add(dedupe_key)

        stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
        snapshot_path = self._artifact_path(
            self.backup_root / "history" / side / event_type / stamp / rel_path
        )
        snapshot_path.parent.mkdir(parents=True, exist_ok=True)
        self._write_artifact_copy(
            path,
            snapshot_path,
            src_side=side,
            op=f"capture snapshot {path} -> {snapshot_path}",
        )
        return self.state.add_snapshot(
            rel_path=rel_path,
            side=side,
            event_type=event_type,
            file_hash=file_hash,
            snapshot_path=str(snapshot_path),
        )

    def _record_activity(
        self,
        *,
        action: str,
        rel_path: str | None,
        side: str | None,
        details: dict[str, Any],
    ) -> None:
        self.state.add_activity(action=action, rel_path=rel_path, side=side, details=details)

    def _refresh_state(self) -> None:
        source_files = self._scan_side(self.source_root, side="source")
        target_files = self._scan_side(self.target_root, side="target")
        all_paths = sorted(set(source_files) | set(target_files))
        previous = {row.rel_path: row for row in self.state.list_file_states()}

        rows: list[FileState] = []
        for rel_path in all_paths:
            prev = previous.get(rel_path)
            source_hash = self._safe_digest_for_state_refresh(
                source_files.get(rel_path),
                side="source",
                rel_path=rel_path,
                fallback_hash=prev.source_hash if prev else None,
            )
            target_hash = self._safe_digest_for_state_refresh(
                target_files.get(rel_path),
                side="target",
                rel_path=rel_path,
                fallback_hash=prev.target_hash if prev 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 _safe_digest_for_state_refresh(
        self,
        snapshot: FileSnapshot | None,
        *,
        side: str,
        rel_path: str,
        fallback_hash: str | None,
    ) -> str | None:
        if snapshot is None:
            return None
        try:
            return snapshot.digest
        except (OSError, ValueError, RuntimeError) as exc:
            logger.warning(
                "State refresh skipped %s hash for %s due to transient read/decrypt issue: %s",
                side,
                rel_path,
                exc,
            )
            return fallback_hash

    def _scan_side(self, root: Path, *, side: str) -> 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()
            logical_rel = rel_path
            hash_loader: Callable[[Path], str] | None = None

            if side == "target" and self.config.sync.vault_encryption_enabled:
                if not rel_path.endswith(".osync.enc"):
                    continue
                logical_rel = logical_vault_path(rel_path)
                hash_loader = self._hash_target_plaintext

            if self._is_ignored_rel(logical_rel, abs_path=abs_path if side == "source" else None):
                continue

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

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

        for root, side in ((self.source_root, "source"), (self.target_root, "target")):
            try:
                rel = abs_resolved.relative_to(root.resolve()).as_posix()
            except ValueError:
                continue

            logical_rel = rel
            abs_for_rules: Path | None = abs_resolved if abs_resolved.exists() else None
            if side == "target" and self.config.sync.vault_encryption_enabled:
                if not rel.endswith(".osync.enc"):
                    return True
                logical_rel = logical_vault_path(rel)
                abs_for_rules = None

            return self._is_ignored_rel(logical_rel, abs_path=abs_for_rules)

        return False

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

        if parts and _is_generated_conflict_copy_name(parts[-1]):
            return True

        if self._is_disabled_by_sync_feature(rel):
            return True
        if self._is_excluded_by_rules(rel, abs_path=abs_path):
            return True

        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 _is_excluded_by_rules(self, rel_path: str, *, abs_path: Path | None) -> bool:
        rules = self.config.sync

        if rules.rules_include_globs:
            if not any(fnmatch(rel_path, pat) for pat in rules.rules_include_globs):
                return True

        if rules.rules_exclude_globs:
            if any(fnmatch(rel_path, pat) for pat in rules.rules_exclude_globs):
                return True

        ext = Path(rel_path).suffix.lower()
        if rules.rules_include_extensions:
            if ext not in rules.rules_include_extensions:
                return True
        if rules.rules_exclude_extensions and ext in rules.rules_exclude_extensions:
            return True

        if (rules.rules_include_tags or rules.rules_exclude_tags) and abs_path is not None and ext in MARKDOWN_EXTENSIONS:
            tags = self._extract_tags(abs_path)
            if rules.rules_include_tags and not tags.intersection(rules.rules_include_tags):
                return True
            if rules.rules_exclude_tags and tags.intersection(rules.rules_exclude_tags):
                return True

        return False

    def _extract_tags(self, abs_path: Path) -> set[str]:
        try:
            text = abs_path.read_text(encoding="utf-8", errors="ignore")
        except OSError:
            return set()
        return {match.group(1).lower() for match in TAG_PATTERN.finditer(text)}

    def _is_disabled_by_sync_feature(self, rel_path: str) -> bool:
        cfg = self.config.sync
        if rel_path.startswith(OBSIDIAN_PREFIX):
            return self._is_disabled_obsidian_path(rel_path)

        category = self._attachment_category(rel_path)
        if category == "always":
            return False
        if category == "image" and not cfg.sync_images:
            return True
        if category == "audio" and not cfg.sync_audio:
            return True
        if category == "video" and not cfg.sync_videos:
            return True
        if category == "pdf" and not cfg.sync_pdfs:
            return True
        if category == "other" and not cfg.sync_other_types:
            return True
        return False

    def _is_disabled_obsidian_path(self, rel_path: str) -> bool:
        cfg = self.config.sync
        sub = rel_path[len(OBSIDIAN_PREFIX) :]

        if sub in {"appearance.json"}:
            return not cfg.sync_appearance_settings
        if sub in {"hotkeys.json"}:
            return not cfg.sync_hotkeys
        if sub in {"core-plugins.json", "community-plugins.json"}:
            return not cfg.sync_enabled_plugins
        if sub.startswith("plugins/"):
            return not cfg.sync_enabled_plugins
        if sub.startswith("themes/") or sub.startswith("snippets/"):
            return not cfg.sync_themes_and_snippets

        return not cfg.sync_main_settings

    def _attachment_category(self, rel_path: str) -> str:
        ext = Path(rel_path).suffix.lower()
        if ext in ALWAYS_SYNC_EXTENSIONS:
            return "always"
        if ext in IMAGE_EXTENSIONS:
            return "image"
        if ext in AUDIO_EXTENSIONS:
            return "audio"
        if ext in VIDEO_EXTENSIONS:
            return "video"
        if ext == ".pdf":
            return "pdf"
        return "other"

    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 _migrate_target_plaintext_to_vault_encryption(self, *, dry_run: bool) -> None:
        if not self.config.sync.vault_encryption_enabled:
            return
        if not self.target_root.exists():
            return

        manager = self._encryption or self._load_encryption_manager(required=True)
        self._encryption = manager

        for plain_path in self.target_root.rglob("*"):
            if not plain_path.is_file() or plain_path.is_symlink():
                continue

            rel = plain_path.relative_to(self.target_root).as_posix()
            if rel.endswith(".osync.enc"):
                continue
            if self._is_ignored_rel(rel, abs_path=None):
                continue

            enc_path = self._resolve_vault_file_path("target", rel)
            if enc_path.exists():
                try:
                    plain_hash = sha256_file(plain_path)
                    enc_hash = self._hash_target_plaintext(enc_path)
                except Exception as exc:  # noqa: BLE001
                    logger.warning("Skipping vault migration for %s: %s", rel, exc)
                    continue

                if plain_hash == enc_hash:
                    if dry_run:
                        logger.info("[dry-run] remove legacy plaintext target %s", plain_path)
                    else:
                        self._retry_io(plain_path.unlink, op=f"remove legacy plaintext {plain_path}")
                        cleanup_empty_parents(plain_path.parent, self.target_root)
                        self._record_activity(
                            action="migrate_target_remove_legacy_plain",
                            rel_path=rel,
                            side="target",
                            details={"encrypted_path": str(enc_path)},
                        )
                else:
                    logger.warning(
                        "Legacy plaintext target differs from encrypted file; keeping plaintext copy at %s",
                        plain_path,
                    )
                continue

            if dry_run:
                logger.info("[dry-run] migrate target plaintext %s -> %s", plain_path, enc_path)
                continue

            def migrate() -> None:
                manager.encrypt_file(plain_path, enc_path)
                plain_path.unlink()

            self._retry_io(migrate, op=f"migrate target plaintext {plain_path} -> {enc_path}")
            cleanup_empty_parents(plain_path.parent, self.target_root)
            self._record_activity(
                action="migrate_target_to_encrypted",
                rel_path=rel,
                side="target",
                details={"encrypted_path": str(enc_path)},
            )

    def _resolve_vault_file_path(self, side: str, rel_path: str) -> Path:
        root = self.source_root if side == "source" else self.target_root
        base = root / rel_path
        if side == "target" and self.config.sync.vault_encryption_enabled:
            return encrypted_vault_path(base)
        return base

    def _uses_vault_transform(self, *, source_side: str, destination_side: str) -> bool:
        if not self.config.sync.vault_encryption_enabled:
            return False
        return source_side == "target" or destination_side == "target"

    def _artifact_path(self, base_path: Path) -> Path:
        if not self.config.sync.encryption_enabled:
            return base_path
        return encrypted_artifact_path(base_path)

    def _hash_target_plaintext(self, path: Path) -> str:
        plaintext = self._read_plain_bytes_for_side(path, side="target")
        digest = hashlib.sha256()
        digest.update(plaintext)
        return digest.hexdigest()

    def _hash_file_for_side(self, path: Path, *, side: str) -> str:
        if side == "target" and self.config.sync.vault_encryption_enabled:
            return self._hash_target_plaintext(path)
        return sha256_file(path)

    def _copy_between_sides(
        self,
        src: Path,
        *,
        source_side: str,
        dst: Path,
        destination_side: str,
        op: str,
    ) -> None:
        if not self._uses_vault_transform(source_side=source_side, destination_side=destination_side):
            self._retry_io(lambda: atomic_copy(src, dst), op=op)
            return

        manager = self._encryption or self._load_encryption_manager(required=True)
        self._encryption = manager

        if source_side == "source" and destination_side == "target":
            self._retry_io(lambda: manager.encrypt_file(src, dst), op=op)
            return
        if source_side == "target" and destination_side == "source":
            self._retry_io(lambda: manager.decrypt_file(src, dst), op=op)
            return

        self._retry_io(lambda: atomic_copy(src, dst), op=op)

    def _read_plain_bytes_for_side(self, path: Path, *, side: str) -> bytes:
        if side == "target" and self.config.sync.vault_encryption_enabled:
            manager = self._encryption or self._load_encryption_manager(required=True)
            self._encryption = manager

            attempts = max(1, self.config.sync.io_retry_attempts)
            delay = max(0.05, self.config.sync.io_retry_backoff_seconds)
            max_delay = max(delay, self.config.sync.io_retry_max_backoff_seconds)

            for attempt in range(1, attempts + 1):
                try:
                    payload = path.read_bytes()
                    return manager.decrypt_bytes(payload)
                except (OSError, ValueError) as exc:
                    if attempt >= attempts:
                        raise
                    logger.warning(
                        "Read/decrypt retry for %s (attempt %s/%s): %s; retrying in %.2fs",
                        path,
                        attempt,
                        attempts,
                        exc,
                        delay,
                    )
                    time.sleep(delay)
                    delay = min(delay * 2, max_delay)

            raise RuntimeError(f"Failed to read/decrypt file after retries: {path}")

        return self._retry_io_value(lambda: path.read_bytes(), op=f"read bytes {path}")

    def _write_plain_bytes_to_side(self, content: bytes, destination: Path, *, side: str) -> None:
        if side == "target" and self.config.sync.vault_encryption_enabled:
            manager = self._encryption or self._load_encryption_manager(required=True)
            self._encryption = manager
            payload = manager.encrypt_bytes(content)
            atomic_write_bytes(payload, destination)
            return
        atomic_write_bytes(content, destination)

    def _write_artifact_copy(
        self,
        src: Path,
        artifact_dst: Path,
        *,
        src_side: str,
        op: str,
    ) -> None:
        def write_plain_artifact() -> None:
            plaintext = self._read_plain_bytes_for_side(src, side=src_side)
            atomic_write_bytes(plaintext, artifact_dst)

        if not self.config.sync.encryption_enabled:
            self._retry_io(write_plain_artifact, op=op)
            return

        manager = self._encryption or self._load_encryption_manager(required=True)
        self._encryption = manager

        def write_encrypted_artifact() -> None:
            plaintext = self._read_plain_bytes_for_side(src, side=src_side)
            payload = manager.encrypt_bytes(plaintext)
            atomic_write_bytes(payload, artifact_dst)

        self._retry_io(write_encrypted_artifact, op=op)

    def _restore_artifact_to_destination(
        self,
        artifact_path: Path,
        destination: Path,
        *,
        artifact_side: str,
        destination_side: str,
        op: str,
    ) -> None:
        def restore() -> None:
            raw = artifact_path.read_bytes()
            plaintext: bytes

            if is_encrypted_payload(raw):
                manager = self._encryption or self._load_encryption_manager(required=True)
                self._encryption = manager
                plaintext = manager.decrypt_bytes(raw)
            else:
                plaintext = raw

            self._write_plain_bytes_to_side(plaintext, destination, side=destination_side)

        self._retry_io(restore, op=op)

    def _load_encryption_manager(self, *, required: bool) -> EncryptionManager:
        env_name = self.config.sync.encryption_passphrase_env
        iterations = self.config.sync.encryption_kdf_iterations
        try:
            manager = EncryptionManager.from_env(
                env_var=env_name,
                kdf_iterations=iterations,
                required=required,
            )
        except (EncryptionConfigError, CryptoUnavailableError) as exc:
            raise RuntimeError(str(exc)) from exc

        if manager is None:
            raise RuntimeError(
                f"Encryption passphrase env var {env_name!r} is required to decrypt encrypted data."
            )
        return manager

    def _retry_io(self, action: Callable[[], None], *, op: str) -> None:
        attempts = max(1, self.config.sync.io_retry_attempts)
        delay = max(0.05, self.config.sync.io_retry_backoff_seconds)
        max_delay = max(delay, self.config.sync.io_retry_max_backoff_seconds)

        for attempt in range(1, attempts + 1):
            try:
                action()
                return
            except OSError as exc:
                if attempt >= attempts:
                    raise
                logger.warning(
                    "I/O error during %s (attempt %s/%s): %s; retrying in %.2fs",
                    op,
                    attempt,
                    attempts,
                    exc,
                    delay,
                )
                time.sleep(delay)
                delay = min(delay * 2, max_delay)

    def _retry_io_value(self, action: Callable[[], bytes], *, op: str) -> bytes:
        attempts = max(1, self.config.sync.io_retry_attempts)
        delay = max(0.05, self.config.sync.io_retry_backoff_seconds)
        max_delay = max(delay, self.config.sync.io_retry_max_backoff_seconds)

        for attempt in range(1, attempts + 1):
            try:
                return action()
            except OSError as exc:
                if attempt >= attempts:
                    raise
                logger.warning(
                    "I/O error during %s (attempt %s/%s): %s; retrying in %.2fs",
                    op,
                    attempt,
                    attempts,
                    exc,
                    delay,
                )
                time.sleep(delay)
                delay = min(delay * 2, max_delay)

        raise RuntimeError(f"I/O retry unexpectedly exhausted without return for {op}")

    def _maybe_send_alerts(self, stats: SyncStats) -> None:
        url = self.config.sync.alerts_webhook_url.strip()
        if not url:
            return

        should_alert = False
        reason: list[str] = []
        if self.config.sync.alerts_on_error and stats.errors > 0:
            should_alert = True
            reason.append("errors")
        if self.config.sync.alerts_on_conflict and stats.conflicts > 0:
            should_alert = True
            reason.append("conflicts")
        if not should_alert:
            return

        payload = {
            "event": "obsidian_sync_alert",
            "timestamp": utc_now_iso(),
            "reasons": reason,
            "stats": stats.to_dict(),
            "source_vault": str(self.source_root),
            "synology_vault": str(self.target_root),
        }
        try:
            _send_webhook_json(url, payload)
            self._record_activity(
                action="alert_sent",
                rel_path=None,
                side=None,
                details={"reasons": reason, "webhook_url": url},
            )
        except Exception as exc:  # noqa: BLE001
            logger.warning("Failed to send alert webhook: %s", exc)
            self._record_activity(
                action="alert_failed",
                rel_path=None,
                side=None,
                details={"error": str(exc)},
            )


def _send_webhook_json(url: str, payload: dict[str, Any]) -> None:
    data = json.dumps(payload, sort_keys=True).encode("utf-8")
    request = urllib.request.Request(
        url,
        data=data,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=10) as response:
        response.read()


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 _is_transient_sync_exception(exc: Exception) -> bool:
    if isinstance(exc, OSError):
        if exc.errno in {11, 16, 35}:
            return True
        msg = str(exc).lower()
        if "resource deadlock avoided" in msg or "resource temporarily unavailable" in msg:
            return True
        return False

    msg = str(exc).lower()
    if "encrypted payload is too short" in msg:
        return True
    if "encrypted payload header is invalid" in msg:
        return True
    if "encrypted payload token is empty" in msg:
        return True

    return False


def _is_generated_conflict_copy_name(name: str) -> bool:
    return bool(CONFLICT_COPY_NAME_PATTERN.search(name))


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