"""Base config target class."""

from __future__ import annotations

import copy
import json

from abc import ABC, abstractmethod
from functools import cached_property
from pathlib import Path
from typing import Any, NamedTuple

from ai_rules.config import Config


class _MergedSettings(NamedTuple):
    """Result of computing the settings ai-rules would write for a target.

    ``merged`` is what gets written/diffed; the remaining fields are only
    needed by the write path to persist managed-fields tracker state.
    """

    merged: dict[str, Any]
    tracker: Any | None
    source_preserved: dict[str, Any]
    airules_keys: set[str]


class ConfigTarget(ABC):
    """Base class for config pipeline targets."""

    def __init__(self, config_dir: Path, config: Config):
        self.config_dir = config_dir
        self.config = config

    @property
    @abstractmethod
    def name(self) -> str:
        """Human-readable name of the target."""
        pass

    @property
    @abstractmethod
    def target_id(self) -> str:
        """Short identifier for the target (e.g., 'claude', 'goose')."""
        pass

    @property
    @abstractmethod
    def config_file_name(self) -> str:
        """Config file name for the target (e.g., 'settings.json', 'config.yaml')."""
        pass

    @property
    @abstractmethod
    def config_file_format(self) -> str:
        """Config file format ('json', 'yaml', or 'toml')."""
        pass

    @classmethod
    def is_supported_on_current_platform(cls) -> bool:
        return True

    @property
    def preserved_fields(self) -> list[str]:
        """Fields in the target's config file managed by the tool itself.

        These are preserved across ai-rules installs and excluded from
        staleness diffs. Override in subclasses for target-specific fields.
        """
        return []

    @property
    def _effective_preserved_fields(self) -> list[str]:
        """All fields that should be preserved across cache rebuilds.

        Includes static preserved_fields plus any dynamically derived fields.
        Override in subclasses (e.g. Agent) to extend with additional keys.
        """
        return self.preserved_fields

    @property
    def settings_symlink_target(self) -> Path | None:
        """Deterministic path where the settings symlink would be created.

        Agents override this with their concrete settings file location.
        Returns None for targets with no settings file symlink.
        """
        return None

    @property
    def is_settings_file_excluded(self) -> bool:
        """Whether the settings symlink target is excluded by config."""
        target = self.settings_symlink_target
        if target is None:
            return False
        return self.config.is_excluded(str(target))

    @property
    def needs_cache(self) -> bool:
        """Whether this target needs a cache file (has overrides or preserved fields)."""
        if self.is_settings_file_excluded:
            return False
        return self.target_id in self.config.settings_overrides or bool(
            self._effective_preserved_fields
        )

    @property
    def copy_mode_targets(self) -> set[Path]:
        """Set of expanded target paths to manage as file copies instead of symlinks.

        Override in agents where the agent destroys symlinks (e.g., Gemini on Windows).
        Default: empty set (all files use symlinks).
        """
        return set()

    @cached_property
    @abstractmethod
    def symlinks(self) -> list[tuple[Path, Path]]:
        """Cached list of (target_path, source_path) tuples for symlinks.

        Returns:
            List of tuples where:
            - target_path: Where symlink should be created (e.g., ~/.CLAUDE.md)
            - source_path: What symlink should point to (e.g., repo/config/AGENTS.md)
        """
        pass

    def _merge_managed_mcps(self, merged: dict[str, Any]) -> None:  # noqa: B027
        """Hook for subclasses to merge managed MCPs into the settings cache.

        Called during build_merged_settings() after preserved_fields are applied.
        Override in Agent to reconcile managed MCP entries.
        """

    def _reconcile_cache(
        self,
        merged: dict[str, Any],
        existing: dict[str, Any] | None,
        tracker: Any | None,
    ) -> tuple[dict[str, Any], set[str]]:
        """Merge existing cache state into merged settings, preserving user keys.

        Mutates merged in place. Returns (merged, airules_keys) where airules_keys
        is the set of top-level keys contributed by ai-rules (not by the user).

        Phases:
          1. Preserved fields — copy from existing, tracker cleanup first
          2. Snapshot airules_keys BEFORE user pass-through
          3. User pass-through — copy unknown keys the user owns
          4. MCP merge hook
          5. Finalize — capture any keys added by MCP merge
        """
        from ai_rules.utils import deep_merge

        preserved = self._effective_preserved_fields
        user_keys: set[str] = set()

        if existing is not None and preserved:
            if tracker:
                existing = tracker.cleanup_stale_entries(existing, merged, preserved)
            for field in preserved:
                if field in existing:
                    if isinstance(merged.get(field), dict) and isinstance(
                        existing[field], dict
                    ):
                        merged[field] = deep_merge(merged[field], existing[field])
                    else:
                        merged[field] = existing[field]

        airules_keys = set(merged.keys())

        if existing is not None and tracker is not None:
            previously_contributed = set(
                tracker.get_field_contributions(f"_contributed_keys_{self.target_id}")
                or []
            )
            for key in existing:
                if key in merged:
                    continue
                if key in preserved:
                    continue
                if key in previously_contributed:
                    continue
                merged[key] = existing[key]
                user_keys.add(key)

        self._merge_managed_mcps(merged)

        airules_keys |= set(merged.keys()) - user_keys

        return merged, airules_keys

    @property
    def _base_settings_path(self) -> Path:
        """Path to the base settings file in the config directory."""
        return self.config_dir / self.target_id / self.config_file_name

    def build_merged_settings(
        self,
        force_rebuild: bool = False,
    ) -> Path | None:
        """Build merged settings file in cache if overrides exist.

        Only rebuilds cache if:
        - force_rebuild is True, OR
        - Cache doesn't exist or is stale

        Returns:
            Path to merged settings file, or None if no overrides exist
        """
        if not self.needs_cache:
            return None

        cache_path = self.config.get_merged_settings_path(
            self.target_id, self.config_file_name, force=True
        )

        if not force_rebuild and cache_path and cache_path.exists():
            if not self.is_cache_stale():
                return cache_path

        config_format = self.config_file_format

        base_settings = self._load_base_settings(
            self._base_settings_path, config_format
        )
        if base_settings is None:
            return None

        if cache_path:
            cache_path.parent.mkdir(parents=True, exist_ok=True)

            existing = self._read_existing_cache(cache_path, config_format)
            result = self._expected_merged(base_settings, existing)

            if result.tracker:
                self._persist_tracker(
                    result.tracker, result.source_preserved, result.airules_keys
                )

            self._write_merged_cache(cache_path, result.merged, config_format)

        return cache_path

    def _expected_merged(
        self,
        base_settings: dict[str, Any],
        existing: dict[str, Any] | None,
    ) -> _MergedSettings:
        """Compute the settings ai-rules would write for the given inputs.

        Shared by build_merged_settings (which persists the tracker and writes
        the result) and get_cache_diff (which only previews it), so the diff
        predicts exactly what an install materializes.

        The source-preserved snapshot is taken from the freshly merged settings
        BEFORE _reconcile_cache mutates them in place.
        """
        from ai_rules.config import ManagedFieldsTracker

        merged = self.config.merge_settings(self.target_id, base_settings)
        tracker = ManagedFieldsTracker() if self.config_file_format == "json" else None
        # Preserved fields are containers (hooks, extensions, projects, ...); a
        # falsy/empty value means ai-rules contributes nothing, so skipping it
        # here correctly records "no contribution" in the tracker.
        source_preserved = {
            f: merged.get(f) for f in self._effective_preserved_fields if merged.get(f)
        }
        merged, airules_keys = self._reconcile_cache(merged, existing, tracker)
        return _MergedSettings(merged, tracker, source_preserved, airules_keys)

    def _safe_load_config(self, path: Path, fmt: str) -> dict[str, Any] | None:
        """Load a config file, returning None if it is absent or unparseable."""
        from ai_rules.config import CONFIG_PARSE_ERRORS, load_config_file

        if not path.exists():
            return None
        try:
            return load_config_file(path, fmt)
        except CONFIG_PARSE_ERRORS:
            return None

    def _load_base_settings(self, path: Path, fmt: str) -> dict[str, Any] | None:
        """Load base settings from ``path``.

        Returns an empty dict when the file is absent (a valid, mergeable
        result) and ``None`` on parse error so callers can short-circuit.
        Callers MUST branch on ``is None`` rather than truthiness.
        """
        if not path.exists():
            return {}
        return self._safe_load_config(path, fmt)

    def _read_existing_cache(self, cache_path: Path, fmt: str) -> dict[str, Any] | None:
        """Read existing cache state, layering in copy-mode preserved fields."""
        existing = self._safe_load_config(cache_path, fmt)

        # For copy-mode agents (e.g. Gemini on Windows), read preserved
        # fields from the live target file so edits made by the agent
        # (after it destroyed the symlink and rewrote the file) survive.
        if self.copy_mode_targets and self.settings_symlink_target:
            live_target = self.settings_symlink_target.expanduser()
            if live_target.exists() and not live_target.is_symlink():
                live_settings = self._safe_load_config(live_target, fmt)
                if live_settings is not None:
                    if existing is None:
                        existing = {}
                    for field in self._effective_preserved_fields:
                        if field in live_settings:
                            existing[field] = live_settings[field]

        return existing

    def _persist_tracker(
        self,
        tracker: Any,
        source_preserved: dict[str, Any],
        airules_keys: set[str],
    ) -> None:
        """Persist ai-rules field contributions to the managed-fields tracker."""
        for field, value in source_preserved.items():
            tracker.set_field_contributions(field, value)
        for field in self._effective_preserved_fields:
            if field not in source_preserved:
                tracker.set_field_contributions(field, None)
        tracker.set_field_contributions(
            f"_contributed_keys_{self.target_id}",
            sorted(airules_keys),
        )
        tracker.save()

    def _write_merged_cache(
        self, cache_path: Path, merged: dict[str, Any], fmt: str
    ) -> None:
        """Write merged settings to the cache file, logging and re-raising errors."""
        from ai_rules.config import dump_config_file

        try:
            dump_config_file(cache_path, merged, fmt)
        except ValueError as exc:
            import logging

            logging.getLogger(__name__).error("%s: %s", self.name, exc)
            raise

    def is_cache_stale(self) -> bool:
        """Check if cached merged settings are stale.

        Returns:
            True if cache needs rebuilding, False otherwise
        """
        if not self.needs_cache:
            return False

        cache_path = self.config.get_merged_settings_path(
            self.target_id, self.config_file_name, force=True
        )
        if not cache_path or not cache_path.exists():
            return True

        cache_mtime = cache_path.stat().st_mtime

        # User config and profile changes are always real edits — trust mtime
        from ai_rules.config import get_user_config_path

        user_config_path = get_user_config_path()
        if user_config_path.exists():
            if user_config_path.stat().st_mtime > cache_mtime:
                return True

        if self.config.profile_name and self.config.profile_name != "default":
            from ai_rules.profiles import ProfileLoader

            loader = ProfileLoader()
            profile_path = loader._profiles_dir / f"{self.config.profile_name}.yaml"
            if profile_path.exists() and profile_path.stat().st_mtime > cache_mtime:
                return True

        # Base settings mtime changes on upgrades/installs without content change;
        # fall through to content check to avoid false positives
        return self.get_cache_diff() is not None

    def get_cache_diff(self) -> str | None:
        """Get unified diff between current state and expected merged settings.

        Returns:
            Formatted diff string with Rich markup, or None if no diff
        """
        import tomli_w
        import yaml

        from ai_rules.config import CONFIG_PARSE_ERRORS, load_config_file

        if not self.needs_cache:
            return None

        config_format = self.config_file_format

        base_settings = self._load_base_settings(
            self._base_settings_path, config_format
        )
        if base_settings is None:
            return None

        cache_path = self.config.get_merged_settings_path(
            self.target_id, self.config_file_name, force=True
        )
        cache_exists = cache_path and cache_path.exists()

        if cache_exists:
            assert cache_path is not None
            try:
                current_settings = load_config_file(cache_path, config_format)
            except CONFIG_PARSE_ERRORS:
                return None
            from_label = "Cached (current)"
            to_label = "Expected (merged)"
        else:
            current_settings = base_settings
            from_label = "Base (current)"
            to_label = "Expected (with overrides)"

        expected = self._expected_merged(
            base_settings,
            copy.deepcopy(current_settings) if cache_exists else None,
        ).merged

        if current_settings == expected:
            return None

        if config_format == "json":
            current_text = json.dumps(current_settings, indent=2, sort_keys=True)
            expected_text = json.dumps(expected, indent=2, sort_keys=True)
        elif config_format == "yaml":
            current_text = yaml.dump(
                current_settings, default_flow_style=False, sort_keys=True
            )
            expected_text = yaml.dump(
                expected, default_flow_style=False, sort_keys=True
            )
        elif config_format == "toml":
            from ai_rules.config import _sort_dict, _validate_for_format

            try:
                _validate_for_format(current_settings, "toml")
                _validate_for_format(expected, "toml")
            except ValueError as exc:
                return f"[red]Config contains invalid values:[/red] {exc}"
            current_text = tomli_w.dumps(_sort_dict(current_settings))
            expected_text = tomli_w.dumps(_sort_dict(expected))
        else:
            return None

        current_lines = current_text.splitlines(keepends=True)
        expected_lines = expected_text.splitlines(keepends=True)

        from ai_rules.symlinks import format_unified_diff

        return format_unified_diff(current_lines, expected_lines, from_label, to_label)

    def get_filtered_symlinks(self) -> list[tuple[Path, Path]]:
        """Get symlinks filtered by config exclusions."""
        return [
            (target, source)
            for target, source in self.symlinks
            if not self.config.is_excluded(str(target))
        ]

    def get_deprecated_symlinks(self) -> list[Path]:
        """Get list of deprecated symlink paths that should be cleaned up.

        Returns:
            List of paths that were previously used but are now deprecated.
            These will be removed during install if they point to our config files.
        """
        return []
