"""CLI memory/skills discovery and backend helpers."""

from __future__ import annotations

import fnmatch
import subprocess
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path, PurePosixPath

from adk_deepagents.backends.filesystem import FilesystemBackend
from adk_deepagents.backends.protocol import FileDownloadResponse, FileInfo, GrepMatch
from adk_deepagents.cli.config import PROFILE_MEMORY_FILENAME, CliPaths, ensure_profile_memory

PROFILE_SKILLS_DIRNAME = "skills"
PROJECT_MEMORY_FILENAME = "AGENTS.md"
PROJECT_SKILLS_DIRNAME = "skills"

_CLI_DEFAULT_EXCLUDED_DIR_NAMES = frozenset(
    {
        ".git",
        ".hg",
        ".svn",
        ".venv",
        "venv",
        "node_modules",
        "__pycache__",
        ".pytest_cache",
        ".mypy_cache",
        ".ruff_cache",
    }
)

_CLI_DEFAULT_EXCLUDE_PATTERNS = (
    "**/*.pyc",
    "**/*.pyo",
)


@dataclass(frozen=True)
class CliAgentResources:
    """Resolved memory/skills resources for a CLI agent run."""

    memory_sources: tuple[str, ...]
    memory_source_paths: Mapping[str, Path]
    skills_dirs: tuple[str, ...]


def discover_cli_agent_resources(
    *, paths: CliPaths, agent_name: str, cwd: Path
) -> CliAgentResources:
    """Resolve memory and skills discovery paths with deterministic precedence.

    Precedence order:
    1. Global profile resources under ``~/.adk-deepagents/profiles/<agent>/``
    2. Project resources under the current working directory

    Project entries are appended after global entries to give project context
    the final precedence in the system prompt / skills discovery order.
    """
    normalized_cwd = cwd.resolve()

    profile_memory_path = ensure_profile_memory(paths, agent_name).resolve()
    project_memory_path = (normalized_cwd / PROJECT_MEMORY_FILENAME).resolve()

    memory_sources: list[str] = []
    memory_source_paths: dict[str, Path] = {}
    seen_memory_paths: set[Path] = set()

    global_memory_source = f"global://profiles/{agent_name}/{PROFILE_MEMORY_FILENAME}"
    if profile_memory_path not in seen_memory_paths:
        seen_memory_paths.add(profile_memory_path)
        memory_sources.append(global_memory_source)
        memory_source_paths[global_memory_source] = profile_memory_path

    project_memory_source = f"project://{PROJECT_MEMORY_FILENAME}"
    if project_memory_path.is_file() and project_memory_path not in seen_memory_paths:
        seen_memory_paths.add(project_memory_path)
        memory_sources.append(project_memory_source)
        memory_source_paths[project_memory_source] = project_memory_path

    skills_dirs: list[str] = []
    seen_skills_dirs: set[str] = set()
    skills_candidates = [
        (paths.profiles_dir / agent_name / PROFILE_SKILLS_DIRNAME).resolve(),
        (normalized_cwd / PROJECT_SKILLS_DIRNAME).resolve(),
    ]

    for skills_dir in skills_candidates:
        if not skills_dir.is_dir():
            continue

        key = str(skills_dir)
        if key in seen_skills_dirs:
            continue

        seen_skills_dirs.add(key)
        skills_dirs.append(key)

    return CliAgentResources(
        memory_sources=tuple(memory_sources),
        memory_source_paths=memory_source_paths,
        skills_dirs=tuple(skills_dirs),
    )


def build_missing_skills_dependency_error(skills_dirs: Sequence[str]) -> RuntimeError:
    """Create an actionable error for missing optional skills dependencies."""
    requested = ", ".join(skills_dirs) if skills_dirs else "(none)"
    return RuntimeError(
        "Skills discovery was requested but optional dependency 'adk-skills-agent' is missing. "
        f"Requested skills directories: {requested}. "
        'Install optional support with: pip install "adk-deepagents[skills]"'
    )


class MemoryMappedFilesystemBackend(FilesystemBackend):
    """Workspace filesystem backend with explicit memory-source path mappings.

    CLI memory may include files outside the workspace (for example global
    profile memory under ``~/.adk-deepagents``). This backend keeps normal tool
    operations sandboxed to the workspace while allowing memory downloads from
    explicitly mapped source keys.
    """

    def __init__(
        self,
        *,
        root_dir: Path,
        memory_source_paths: Mapping[str, Path] | None = None,
        respect_gitignore: bool = True,
        exclude_patterns: Sequence[str] | None = None,
    ) -> None:
        super().__init__(root_dir=root_dir, virtual_mode=True)
        self._memory_source_paths = {
            source: path.expanduser().resolve()
            for source, path in (memory_source_paths or {}).items()
        }
        self._respect_gitignore = respect_gitignore
        self._exclude_patterns = tuple(exclude_patterns or ())
        self._is_git_repo: bool | None = None

    def ls_info(self, path: str) -> list[FileInfo]:
        entries = super().ls_info(path)
        return self._filter_file_info_entries(entries)

    def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
        entries = super().glob_info(pattern, path)
        return self._filter_file_info_entries(entries)

    def grep_raw(
        self,
        pattern: str,
        path: str | None = None,
        glob: str | None = None,
    ) -> list[GrepMatch] | str:
        result = super().grep_raw(pattern, path=path, glob=glob)
        if isinstance(result, str):
            return result

        return self._filter_grep_matches(result)

    def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
        results: list[FileDownloadResponse] = []
        for source in paths:
            mapped_path = self._memory_source_paths.get(source)
            if mapped_path is None:
                results.extend(super().download_files([source]))
                continue

            results.append(self._download_mapped_source(source=source, path=mapped_path))

        return results

    def _filter_file_info_entries(self, entries: list[FileInfo]) -> list[FileInfo]:
        hidden = self._hidden_original_paths([entry.get("path") for entry in entries])
        return [entry for entry in entries if entry.get("path") not in hidden]

    def _filter_grep_matches(self, matches: list[GrepMatch]) -> list[GrepMatch]:
        hidden = self._hidden_original_paths([match.get("path") for match in matches])
        return [match for match in matches if match.get("path") not in hidden]

    def _hidden_original_paths(self, raw_paths: Sequence[object]) -> set[str]:
        rel_by_original: dict[str, str] = {}
        for raw in raw_paths:
            if not isinstance(raw, str):
                continue
            rel = self._to_workspace_relative_path(raw)
            if rel is None:
                continue
            rel_by_original[raw] = rel

        if not rel_by_original:
            return set()

        hidden_relative = {
            rel for rel in set(rel_by_original.values()) if self._is_default_hidden(rel)
        }

        remaining = [rel for rel in set(rel_by_original.values()) if rel not in hidden_relative]
        hidden_relative.update(self._git_ignored_paths(remaining))

        return {original for original, rel in rel_by_original.items() if rel in hidden_relative}

    def _to_workspace_relative_path(self, path: str) -> str | None:
        normalized = path.strip()
        if not normalized:
            return None

        if normalized.startswith("/"):
            return PurePosixPath(normalized.lstrip("/")).as_posix()

        parsed = Path(normalized)
        if parsed.is_absolute():
            try:
                return parsed.resolve().relative_to(self._root).as_posix()
            except ValueError:
                return None

        return PurePosixPath(normalized).as_posix()

    def _is_default_hidden(self, relative_path: str) -> bool:
        if not relative_path:
            return False

        parts = PurePosixPath(relative_path).parts
        if any(part in _CLI_DEFAULT_EXCLUDED_DIR_NAMES for part in parts):
            return True

        if any(
            fnmatch.fnmatch(relative_path, pattern) for pattern in _CLI_DEFAULT_EXCLUDE_PATTERNS
        ):
            return True

        return any(fnmatch.fnmatch(relative_path, pattern) for pattern in self._exclude_patterns)

    def _git_ignored_paths(self, relative_paths: Sequence[str]) -> set[str]:
        if not self._respect_gitignore:
            return set()
        if not relative_paths:
            return set()
        if not self._is_within_git_repo():
            return set()

        payload = "\n".join(relative_paths) + "\n"
        try:
            result = subprocess.run(
                ["git", "-C", str(self._root), "check-ignore", "--stdin"],
                input=payload,
                text=True,
                capture_output=True,
                check=False,
                timeout=5,
            )
        except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
            return set()

        if result.returncode not in {0, 1}:
            return set()

        ignored: set[str] = set()
        for line in result.stdout.splitlines():
            normalized = line.strip().lstrip("./").rstrip("/")
            if normalized:
                ignored.add(PurePosixPath(normalized).as_posix())
        return ignored

    def _is_within_git_repo(self) -> bool:
        if self._is_git_repo is not None:
            return self._is_git_repo

        try:
            result = subprocess.run(
                ["git", "-C", str(self._root), "rev-parse", "--is-inside-work-tree"],
                capture_output=True,
                text=True,
                check=False,
                timeout=5,
            )
        except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
            self._is_git_repo = False
            return False

        self._is_git_repo = result.returncode == 0 and result.stdout.strip() == "true"
        return self._is_git_repo

    @staticmethod
    def _download_mapped_source(*, source: str, path: Path) -> FileDownloadResponse:
        if not path.exists():
            return FileDownloadResponse(path=source, error="file_not_found")
        if path.is_dir():
            return FileDownloadResponse(path=source, error="is_directory")

        try:
            content = path.read_bytes()
        except PermissionError:
            return FileDownloadResponse(path=source, error="permission_denied")
        except OSError:
            return FileDownloadResponse(path=source, error="file_not_found")

        return FileDownloadResponse(path=source, content=content)
