import json
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from agentmux.adapters.base import AdapterRegistry, ConvertedFile, ToolAdapter
from agentmux.core.component import (
    ComponentType,
)
from agentmux.core.setup import InstallStrategy, Setup, ToolConfig
from agentmux.utils.symlink import SyncActionLabel, backup, create_symlink, write_file

__all__ = [
    "SyncAction",
    "SyncActionLabel",
    "sync_setup",
]

# Internal label used in SyncAction for files emitted by adapter.post_sync().
# Not part of the public ComponentType enum because it represents a sync-engine
# implementation detail, not a user-facing component type.
_SETTINGS_LABEL = "settings"


@dataclass
class SyncAction:
    action: SyncActionLabel
    target: Path
    component_type: str  # ComponentType.value or _SETTINGS_LABEL for post_sync results
    component_name: str
    detail: str = ""
    backup_path: Path | None = None


def _deep_merge(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
    """Recursively merge *update* into *base*, returning a new dict.

    Nested dicts are merged recursively.  All other types — including lists —
    are replaced wholesale: the value from *update* always wins.  This is the
    correct behaviour for MCP server configs (where each server entry should be
    overwritten, not appended), but callers that expect list concatenation must
    handle it themselves before calling this function.
    """
    result = dict(base)
    for key, value in update.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = _deep_merge(result[key], value)
        else:
            result[key] = value
    return result


def _deploy_merge(converted: ConvertedFile, target: Path) -> tuple[SyncActionLabel, Path | None]:
    """Deploy via deep-merge into an existing JSON file.

    If *target* is a symlink, its content is read through the link before the
    symlink is replaced with a regular file containing the merged result.

    Dangling symlink handling: if *target* is a symlink whose destination has
    been deleted (i.e. ``target.is_symlink()`` is True but ``resolved.exists()``
    is False), the dangling symlink is silently removed and the merge proceeds
    as if no prior content existed (``existing = {}``).  This is intentional —
    there is no recoverable content — but it means any data the symlink *used to*
    point to is permanently lost.  Callers that need to preserve such data must
    restore the symlink target before calling this function.
    """
    existing: dict[str, Any] = {}
    if target.exists() or target.is_symlink():
        # Resolve symlinks so we read the actual content before replacing
        resolved = target.resolve() if target.is_symlink() else target
        if resolved.exists():
            raw_text = resolved.read_text()
            try:
                existing = json.loads(raw_text)
            except json.JSONDecodeError as exc:
                raise ValueError(f"Existing file is not valid JSON ({target}): {exc}") from exc
        # Remove the symlink so write_file creates a regular file
        if target.is_symlink():
            target.unlink()
    new_data: dict[str, Any] = json.loads(converted.content or "{}")
    merged = _deep_merge(existing, new_data)
    return write_file(json.dumps(merged, indent=2), target)


def _deploy_symlink(converted: ConvertedFile, target: Path) -> tuple[SyncActionLabel, Path | None]:
    """Deploy by creating a symlink from target to source."""
    assert converted.source is not None, (
        f"_deploy_symlink called with source=None for target '{target}'"
    )
    return create_symlink(converted.source, target, converted.is_directory)


def _deploy_copy(converted: ConvertedFile, target: Path) -> tuple[SyncActionLabel, Path | None]:
    """Deploy by copying source to target.

    Returns ``("skipped", None)`` when the target already exists and its content
    is byte-for-byte identical to the source.  Returns ``(action, backup_path)``
    otherwise, where *backup_path* is set when a pre-existing file was backed up.
    """
    if converted.source is None:
        raise ValueError(
            f"_deploy_copy called with source=None for target '{target}'. "
            "A ConvertedFile must have a non-None source to use the copy strategy."
        )
    target.parent.mkdir(parents=True, exist_ok=True)

    backup_path: Path | None = None
    action: SyncActionLabel
    if target.is_symlink():
        # Symlink in place of a copy target — replace it unconditionally.
        target.unlink()
        action = "created"
    elif target.exists():
        # Compare content before committing to an overwrite.
        if converted.is_directory:
            content_unchanged = _dirs_equal(converted.source, target)
        else:
            content_unchanged = target.read_bytes() == converted.source.read_bytes()

        if content_unchanged:
            return "skipped", None

        backup_path = backup(target)
        action = "updated"
    else:
        action = "created"

    if converted.is_directory:
        shutil.copytree(str(converted.source), str(target), dirs_exist_ok=True)
    else:
        shutil.copy2(str(converted.source), str(target))
    return action, backup_path


def _dirs_equal(source: Path, target: Path) -> bool:
    """Return True when *source* and *target* directories have identical content.

    Performs a recursive byte-level comparison of all files.  Missing files or
    extra files in *target* are treated as differences.
    """
    source_files = {p.relative_to(source) for p in source.rglob("*") if p.is_file()}
    target_files = {p.relative_to(target) for p in target.rglob("*") if p.is_file()}
    if source_files != target_files:
        return False
    return all((target / rel).read_bytes() == (source / rel).read_bytes() for rel in source_files)


def _deploy_write(converted: ConvertedFile, target: Path) -> tuple[SyncActionLabel, Path | None]:
    """Deploy by writing generated content to target."""
    assert converted.content is not None, (
        f"_deploy_write called with content=None for target '{target}'"
    )
    return write_file(converted.content, target)


def _predict_action(
    converted: ConvertedFile, target: Path, strategy: InstallStrategy
) -> SyncActionLabel:
    """Predict the action that *would* be taken without mutating the filesystem.

    Returns the same action strings as the real deploy functions ("created",
    "updated", "skipped") so that dry-run results are directly comparable to
    real sync results.
    """
    if not target.exists() and not target.is_symlink():
        return "created"

    # Merge: target exists — predict whether the merged result would differ.
    if converted.merge:
        # Conservatively predict "updated" because computing the deep-merged
        # result without mutating the filesystem requires loading both sides and
        # diffing the output — expensive and not worth the complexity here.
        return "updated"

    # Generated content or copy — compare content to predict create/update/skip.
    if converted.content is not None:
        # write_file skips when the existing content is identical.
        if target.is_symlink():
            # A symlink where we'd normally write a regular file → replace it.
            return "updated"
        existing_content = target.read_text() if target.is_file() else None
        if existing_content == converted.content:
            return "skipped"
        return "updated"

    if converted.source is not None:
        if strategy == InstallStrategy.COPY:
            if target.is_symlink():
                # Symlink in place of a copy target is always replaced.
                return "updated"
            if converted.is_directory:
                return "skipped" if _dirs_equal(converted.source, target) else "updated"
            return "skipped" if target.read_bytes() == converted.source.read_bytes() else "updated"
        else:
            # SYMLINK / GENERATE: symlink already pointing at the right source → skip.
            if target.is_symlink() and target.resolve() == converted.source.resolve():
                return "skipped"
            return "updated"

    return "created"


def _deploy(
    converted: ConvertedFile,
    base_dir: Path,
    comp_type: str,
    comp_name: str,
    strategy: InstallStrategy = InstallStrategy.GENERATE,
    *,
    dry_run: bool = False,
) -> SyncAction:
    target = base_dir / converted.path
    # Guard against path traversal: the resolved target must stay within
    # base_dir's parent (one level up is intentionally allowed for the
    # Claude Code adapter which writes ../.mcp.json relative to .claude/).
    try:
        allowed_root = base_dir.resolve().parent
        if not target.resolve().is_relative_to(allowed_root):
            return SyncAction(
                "error",
                target,
                comp_type,
                comp_name,
                f"Path traversal rejected: '{target}' is outside allowed root '{allowed_root}'",
            )
    except Exception as exc:
        return SyncAction("error", target, comp_type, comp_name, f"Path validation failed: {exc}")

    if dry_run:
        try:
            action = _predict_action(converted, target, strategy)
        except Exception as exc:
            return SyncAction(
                "error", target, comp_type, comp_name, f"Dry-run prediction failed: {exc}"
            )
        return SyncAction(action, target, comp_type, comp_name)

    try:
        backup_path: Path | None = None
        # 1. Merge always wins — independent of strategy
        if converted.merge:
            action, backup_path = _deploy_merge(converted, target)
        # 2. Has generated content — write it (covers agents, commands, rules, etc.)
        elif converted.content is not None:
            action, backup_path = _deploy_write(converted, target)
        # 3. Has source file/dir — deploy according to strategy
        elif converted.source is not None:
            if strategy == InstallStrategy.COPY:
                action, backup_path = _deploy_copy(converted, target)
            else:
                # SYMLINK and GENERATE both fall back to symlink when only a source exists
                action, backup_path = _deploy_symlink(converted, target)
        else:
            return SyncAction("error", target, comp_type, comp_name, "No content or source")
        return SyncAction(action, target, comp_type, comp_name, backup_path=backup_path)
    except Exception as exc:
        return SyncAction("error", target, comp_type, comp_name, str(exc))


def _deploy_converted_files(
    converted_list: list[ConvertedFile],
    comp_type: ComponentType,
    comp_name: str,
    *,
    global_base: Path,
    project_base: Path | None,
    strategy: InstallStrategy,
    actions: list[SyncAction],
    synced_types: set[ComponentType],
    dry_run: bool = False,
) -> None:
    """Deploy a list of converted files, appending results to *actions*."""
    for converted in converted_list:
        base = global_base if converted.scope == "global" else project_base
        if base is None:
            continue  # skip project-scoped components when no project dir is set
        sync_action = _deploy(
            converted, base, comp_type.value, comp_name, strategy, dry_run=dry_run
        )
        actions.append(sync_action)
        if sync_action.action != "error":
            synced_types.add(comp_type)


def sync_setup(
    setup: Setup,
    tools: list[str] | None = None,
    project_dir: Path | None = None,
    dry_run: bool = False,
) -> list[SyncAction]:
    """Sync all components in *setup* to the enabled tool adapters.

    Args:
        setup: Parsed setup bundle.
        tools: Restrict sync to these adapter names. None means all enabled.
            **Note:** the ``tools`` filter is applied *in addition to* the
            ``[tools]`` section in ``agentmux.toml``; it does not override it.
            Passing ``tools=["cursor"]`` syncs cursor only if cursor is also
            enabled in the TOML.  To force a sync regardless of TOML config,
            the caller must modify the ``Setup`` object directly.
        project_dir: When set, also deploy project-scoped components (rules,
            instructions) into this directory.
        dry_run: When ``True``, compute and return the actions that *would* be
            taken without writing, symlinking, or copying any files.

    Returns:
        List of SyncAction describing every file operation attempted (or that
        would be attempted when *dry_run* is ``True``).
    """
    actions: list[SyncAction] = []
    all_adapters = AdapterRegistry.get_adapters()
    active_adapters: list[ToolAdapter] = (
        [a for a in all_adapters if a.name in tools] if tools else list(all_adapters)
    )

    components = setup.load_components()

    for adapter in active_adapters:
        if setup.tools:
            # Only sync tools explicitly listed in [tools]
            tool_config = setup.tools.get(adapter.name)
            if tool_config is None or not tool_config.enabled:
                continue
        else:
            # No [tools] configured: sync to all adapters that detect an install
            if not adapter.detect():
                continue
            tool_config = ToolConfig()

        strategy = tool_config.strategy
        global_base = adapter.get_global_base_dir()
        project_base = (project_dir / adapter.get_project_subdir()) if project_dir else None
        synced_types: set[ComponentType] = set()

        # Sync all regular component types using the adapter's dispatch method.
        # MCP is excluded here because it uses per-tool lookup (get_mcp_for_tool),
        # not the components dict.
        regular_types = (
            ComponentType.AGENT,
            ComponentType.COMMAND,
            ComponentType.SKILL,
            ComponentType.RULE,
            ComponentType.INSTRUCTIONS,
        )
        for comp_type in regular_types:
            for component in components.get(comp_type, []):
                comp_name = getattr(component, "name", comp_type.value)
                _deploy_converted_files(
                    adapter.convert(component, comp_type),
                    comp_type,
                    comp_name,
                    global_base=global_base,
                    project_base=project_base,
                    strategy=strategy,
                    actions=actions,
                    synced_types=synced_types,
                    dry_run=dry_run,
                )

        mcp = setup.get_mcp_for_tool(adapter.name)
        if mcp is not None:
            _deploy_converted_files(
                adapter.convert_mcp(mcp),
                ComponentType.MCP,
                "mcp",
                global_base=global_base,
                project_base=project_base,
                strategy=strategy,
                actions=actions,
                synced_types=synced_types,
                dry_run=dry_run,
            )

        for converted in adapter.post_sync(global_base, synced_types):
            actions.append(
                _deploy(
                    converted, global_base, _SETTINGS_LABEL, "settings", strategy, dry_run=dry_run
                )
            )

    return actions
