from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
import os
import tomllib

DEFAULT_CONFIG_FILENAME = "obsidian-sync.toml"
DEFAULT_EXCLUDES = [
    ".obsidian-sync-state",
    ".obsidian-sync-backups",
    ".DS_Store",
    "Thumbs.db",
    "*.conflict-source-*",
    "*.conflict-target-*",
]
DEFAULT_DEVICE_EXCLUDES = [
    ".obsidian/workspace.json",
    ".obsidian/workspaces.json",
    ".obsidian/cache",
    ".obsidian/cache/**",
    ".obsidian/plugins/*/cache",
    ".obsidian/plugins/*/cache/**",
    ".obsidian/plugins/*/tmp",
    ".obsidian/plugins/*/tmp/**",
]


@dataclass(slots=True)
class PathsConfig:
    source_vault: Path
    synology_vault: Path
    windows_synology_vault: str = ""


@dataclass(slots=True)
class SyncConfig:
    exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDES))
    exclude_device_state: bool = True
    exclude_device_patterns: list[str] = field(default_factory=lambda: list(DEFAULT_DEVICE_EXCLUDES))
    conflict_policy: str = "latest"
    device_profile: str = "default"
    rules_include_globs: list[str] = field(default_factory=list)
    rules_exclude_globs: list[str] = field(default_factory=list)
    rules_include_extensions: list[str] = field(default_factory=list)
    rules_exclude_extensions: list[str] = field(default_factory=list)
    rules_include_tags: list[str] = field(default_factory=list)
    rules_exclude_tags: list[str] = field(default_factory=list)
    sync_main_settings: bool = True
    sync_appearance_settings: bool = True
    sync_themes_and_snippets: bool = True
    sync_enabled_plugins: bool = True
    sync_hotkeys: bool = True
    sync_images: bool = True
    sync_audio: bool = True
    sync_videos: bool = True
    sync_pdfs: bool = True
    sync_other_types: bool = True
    delta_sync_enabled: bool = True
    delta_min_file_size_bytes: int = 8 * 1024 * 1024
    delta_chunk_size_bytes: int = 1024 * 1024
    delta_max_diff_ratio: float = 0.5
    vault_encryption_enabled: bool = False
    encryption_enabled: bool = False
    encryption_passphrase_env: str = "OBSIDIAN_SYNC_PASSPHRASE"
    encryption_kdf_iterations: int = 600_000
    integrity_audit_interval_seconds: float = 3600.0
    integrity_auto_repair: bool = False
    alerts_webhook_url: str = ""
    alerts_on_conflict: bool = True
    alerts_on_error: bool = True
    health_stale_after_seconds: float = 300.0
    debounce_seconds: float = 1.0
    poll_interval_seconds: float = 30.0
    backup_dir: str = ".obsidian-sync-backups"
    io_retry_attempts: int = 5
    io_retry_backoff_seconds: float = 0.2
    io_retry_max_backoff_seconds: float = 2.0


@dataclass(slots=True)
class AppConfig:
    paths: PathsConfig
    sync: SyncConfig
    config_path: Path
    state_db_path: Path

    @property
    def state_dir(self) -> Path:
        return self.state_db_path.parent

    @classmethod
    def load(cls, config_path: Path | str = DEFAULT_CONFIG_FILENAME) -> "AppConfig":
        path = Path(config_path).expanduser().resolve()
        with path.open("rb") as handle:
            raw = tomllib.load(handle)

        paths = raw.get("paths", {})
        sync = raw.get("sync", {})
        profiles = raw.get("profiles", {})

        source_vault = _expand_to_abs(paths["source_vault"])
        synology_vault = _expand_to_abs(paths["synology_vault"])
        windows_synology_vault = str(paths.get("windows_synology_vault", ""))
        device_profile = str(sync.get("device_profile", "default"))
        profile_sync: dict[str, object] = {}
        if isinstance(profiles, dict):
            selected = profiles.get(device_profile, {})
            if isinstance(selected, dict):
                profile_sync = selected
        raw_excludes = list(sync.get("exclude", DEFAULT_EXCLUDES))
        exclude_device_state = bool(sync.get("exclude_device_state", True))
        exclude_device_patterns = list(sync.get("exclude_device_patterns", DEFAULT_DEVICE_EXCLUDES))
        rules_include_globs = _merge_lists(
            sync.get("rules_include_globs", []),
            profile_sync.get("rules_include_globs", []),
        )
        rules_exclude_globs = _merge_lists(
            sync.get("rules_exclude_globs", []),
            profile_sync.get("rules_exclude_globs", []),
        )
        rules_include_extensions = _normalize_extensions(
            _merge_lists(
                sync.get("rules_include_extensions", []),
                profile_sync.get("rules_include_extensions", []),
            )
        )
        rules_exclude_extensions = _normalize_extensions(
            _merge_lists(
                sync.get("rules_exclude_extensions", []),
                profile_sync.get("rules_exclude_extensions", []),
            )
        )
        rules_include_tags = _normalize_tags(
            _merge_lists(
                sync.get("rules_include_tags", []),
                profile_sync.get("rules_include_tags", []),
            )
        )
        rules_exclude_tags = _normalize_tags(
            _merge_lists(
                sync.get("rules_exclude_tags", []),
                profile_sync.get("rules_exclude_tags", []),
            )
        )
        combined_excludes = _merged_excludes(
            raw_excludes,
            exclude_device_state=exclude_device_state,
            device_patterns=exclude_device_patterns,
        )
        conflict_policy = str(sync.get("conflict_policy", "latest")).strip().lower()
        if conflict_policy not in {"latest", "source", "target", "manual"}:
            conflict_policy = "latest"

        sync_cfg = SyncConfig(
            exclude=combined_excludes,
            exclude_device_state=exclude_device_state,
            exclude_device_patterns=exclude_device_patterns,
            conflict_policy=conflict_policy,
            device_profile=device_profile,
            rules_include_globs=rules_include_globs,
            rules_exclude_globs=rules_exclude_globs,
            rules_include_extensions=rules_include_extensions,
            rules_exclude_extensions=rules_exclude_extensions,
            rules_include_tags=rules_include_tags,
            rules_exclude_tags=rules_exclude_tags,
            sync_main_settings=bool(sync.get("sync_main_settings", True)),
            sync_appearance_settings=bool(sync.get("sync_appearance_settings", True)),
            sync_themes_and_snippets=bool(sync.get("sync_themes_and_snippets", True)),
            sync_enabled_plugins=bool(sync.get("sync_enabled_plugins", True)),
            sync_hotkeys=bool(sync.get("sync_hotkeys", True)),
            sync_images=bool(sync.get("sync_images", True)),
            sync_audio=bool(sync.get("sync_audio", True)),
            sync_videos=bool(sync.get("sync_videos", True)),
            sync_pdfs=bool(sync.get("sync_pdfs", True)),
            sync_other_types=bool(sync.get("sync_other_types", True)),
            delta_sync_enabled=bool(sync.get("delta_sync_enabled", True)),
            delta_min_file_size_bytes=max(1024, int(sync.get("delta_min_file_size_bytes", 8 * 1024 * 1024))),
            delta_chunk_size_bytes=max(4096, int(sync.get("delta_chunk_size_bytes", 1024 * 1024))),
            delta_max_diff_ratio=min(1.0, max(0.0, float(sync.get("delta_max_diff_ratio", 0.5)))),
            vault_encryption_enabled=bool(sync.get("vault_encryption_enabled", False)),
            encryption_enabled=bool(sync.get("encryption_enabled", False)),
            encryption_passphrase_env=str(sync.get("encryption_passphrase_env", "OBSIDIAN_SYNC_PASSPHRASE")).strip()
            or "OBSIDIAN_SYNC_PASSPHRASE",
            encryption_kdf_iterations=max(100_000, int(sync.get("encryption_kdf_iterations", 600_000))),
            integrity_audit_interval_seconds=max(30.0, float(sync.get("integrity_audit_interval_seconds", 3600.0))),
            integrity_auto_repair=bool(sync.get("integrity_auto_repair", False)),
            alerts_webhook_url=str(sync.get("alerts_webhook_url", "")),
            alerts_on_conflict=bool(sync.get("alerts_on_conflict", True)),
            alerts_on_error=bool(sync.get("alerts_on_error", True)),
            health_stale_after_seconds=max(30.0, float(sync.get("health_stale_after_seconds", 300.0))),
            debounce_seconds=float(sync.get("debounce_seconds", 1.0)),
            poll_interval_seconds=float(sync.get("poll_interval_seconds", 30.0)),
            backup_dir=str(sync.get("backup_dir", ".obsidian-sync-backups")),
            io_retry_attempts=max(1, int(sync.get("io_retry_attempts", 5))),
            io_retry_backoff_seconds=max(0.05, float(sync.get("io_retry_backoff_seconds", 0.2))),
            io_retry_max_backoff_seconds=max(0.1, float(sync.get("io_retry_max_backoff_seconds", 2.0))),
        )

        state_db_raw = raw.get("state", {}).get("db_path")
        if state_db_raw:
            state_db = _expand_to_abs(str(state_db_raw))
        else:
            state_db = path.parent / ".obsidian-sync-state" / "state.db"

        return cls(
            paths=PathsConfig(
                source_vault=source_vault,
                synology_vault=synology_vault,
                windows_synology_vault=windows_synology_vault,
            ),
            sync=sync_cfg,
            config_path=path,
            state_db_path=state_db,
        )

    def ensure_directories(self) -> None:
        self.paths.source_vault.mkdir(parents=True, exist_ok=True)
        self.paths.synology_vault.mkdir(parents=True, exist_ok=True)
        self.state_dir.mkdir(parents=True, exist_ok=True)


def write_default_config(
    config_path: Path | str,
    *,
    source_vault: Path | str,
    synology_vault: Path | str,
    windows_synology_vault: str,
    force: bool = False,
) -> Path:
    path = Path(config_path).expanduser().resolve()
    if path.exists() and not force:
        raise FileExistsError(f"Config already exists at {path}")

    source_abs = _expand_to_abs(str(source_vault))
    target_abs = _expand_to_abs(str(synology_vault))
    state_db = path.parent / ".obsidian-sync-state" / "state.db"

    content = _build_toml(
        source_vault=source_abs,
        synology_vault=target_abs,
        windows_synology_vault=windows_synology_vault,
        state_db_path=state_db,
    )

    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")
    return path


def _build_toml(
    *,
    source_vault: Path,
    synology_vault: Path,
    windows_synology_vault: str,
    state_db_path: Path,
) -> str:
    quoted_excludes = ", ".join(_toml_string(x) for x in DEFAULT_EXCLUDES)
    quoted_device_excludes = ", ".join(_toml_string(x) for x in DEFAULT_DEVICE_EXCLUDES)
    lines = [
        "[paths]",
        f"source_vault = {_toml_string(str(source_vault))}",
        f"synology_vault = {_toml_string(str(synology_vault))}",
        f"windows_synology_vault = {_toml_string(windows_synology_vault)}",
        "",
        "[sync]",
        f"exclude = [{quoted_excludes}]",
        "exclude_device_state = true",
        f"exclude_device_patterns = [{quoted_device_excludes}]",
        "conflict_policy = \"latest\"",
        "device_profile = \"default\"",
        "rules_include_globs = []",
        "rules_exclude_globs = []",
        "rules_include_extensions = []",
        "rules_exclude_extensions = []",
        "rules_include_tags = []",
        "rules_exclude_tags = []",
        "sync_main_settings = true",
        "sync_appearance_settings = true",
        "sync_themes_and_snippets = true",
        "sync_enabled_plugins = true",
        "sync_hotkeys = true",
        "sync_images = true",
        "sync_audio = true",
        "sync_videos = true",
        "sync_pdfs = true",
        "sync_other_types = true",
        "delta_sync_enabled = true",
        "delta_min_file_size_bytes = 8388608",
        "delta_chunk_size_bytes = 1048576",
        "delta_max_diff_ratio = 0.5",
        "vault_encryption_enabled = false",
        "encryption_enabled = false",
        "encryption_passphrase_env = \"OBSIDIAN_SYNC_PASSPHRASE\"",
        "encryption_kdf_iterations = 600000",
        "integrity_audit_interval_seconds = 3600.0",
        "integrity_auto_repair = false",
        "alerts_webhook_url = \"\"",
        "alerts_on_conflict = true",
        "alerts_on_error = true",
        "health_stale_after_seconds = 300.0",
        "debounce_seconds = 1.0",
        "poll_interval_seconds = 30.0",
        "backup_dir = \".obsidian-sync-backups\"",
        "io_retry_attempts = 5",
        "io_retry_backoff_seconds = 0.2",
        "io_retry_max_backoff_seconds = 2.0",
        "",
        "[state]",
        f"db_path = {_toml_string(str(state_db_path))}",
        "",
    ]
    return "\n".join(lines)


def _toml_string(value: str) -> str:
    escaped = value.replace("\\", "\\\\").replace('"', '\\"')
    return f'"{escaped}"'


def _expand_to_abs(value: str) -> Path:
    expanded = os.path.expandvars(os.path.expanduser(value))
    return Path(expanded).resolve()


def _merged_excludes(
    raw_excludes: list[str],
    *,
    exclude_device_state: bool,
    device_patterns: list[str],
) -> list[str]:
    merged: list[str] = []
    seen: set[str] = set()

    def add(patterns: list[str]) -> None:
        for item in patterns:
            normalized = str(item).strip()
            if not normalized or normalized in seen:
                continue
            seen.add(normalized)
            merged.append(normalized)

    add(raw_excludes)
    if exclude_device_state:
        add(device_patterns)

    return merged


def _merge_lists(first: object, second: object) -> list[str]:
    out: list[str] = []
    seen: set[str] = set()
    for source in (first, second):
        if not isinstance(source, list):
            continue
        for raw in source:
            item = str(raw).strip()
            if not item or item in seen:
                continue
            seen.add(item)
            out.append(item)
    return out


def _normalize_extensions(values: list[str]) -> list[str]:
    out: list[str] = []
    seen: set[str] = set()
    for raw in values:
        item = raw.strip().lower()
        if not item:
            continue
        if not item.startswith("."):
            item = "." + item
        if item in seen:
            continue
        seen.add(item)
        out.append(item)
    return out


def _normalize_tags(values: list[str]) -> list[str]:
    out: list[str] = []
    seen: set[str] = set()
    for raw in values:
        item = raw.strip()
        if item.startswith("#"):
            item = item[1:]
        item = item.lower()
        if not item or item in seen:
            continue
        seen.add(item)
        out.append(item)
    return out
