"""4-layer language discovery for LSP server matching.

Determines which programming languages a resource (file, directory,
or project) contains, so the LSP runtime can start the right language
servers.

## Discovery Layers (priority order)

1. **File extension analysis** -- Map file extensions to language IDs
   using a built-in extension table.  Fastest and most common.
2. **Content analysis** -- Inspect shebang lines, magic comments, and
   structural patterns for files without/ambiguous extensions.
3. **UKO classification** -- Use Universal Knowledge Ontology layer-3
   classification when available (persisted across sessions).
4. **Explicit project declaration** -- Project-level language config
   overrides or supplements automatic discovery.

Results are cached per file path and invalidated on content change.

Based on ``docs/specification.md`` Resource Language Discovery
(lines 20557-20566) and issue #826.
"""

from __future__ import annotations

import os
from typing import Any

import structlog

logger = structlog.get_logger(__name__)

# ---------------------------------------------------------------------------
# Layer 1: Extension-to-language mapping
# ---------------------------------------------------------------------------

_EXTENSION_MAP: dict[str, str] = {
    ".py": "python",
    ".pyi": "python",
    ".pyw": "python",
    ".ts": "typescript",
    ".tsx": "typescriptreact",
    ".js": "javascript",
    ".jsx": "javascriptreact",
    ".mjs": "javascript",
    ".cjs": "javascript",
    ".rs": "rust",
    ".go": "go",
    ".java": "java",
    ".kt": "kotlin",
    ".kts": "kotlin",
    ".scala": "scala",
    ".c": "c",
    ".h": "c",
    ".cpp": "cpp",
    ".cxx": "cpp",
    ".cc": "cpp",
    ".hpp": "cpp",
    ".hxx": "cpp",
    ".cs": "csharp",
    ".fs": "fsharp",
    ".rb": "ruby",
    ".php": "php",
    ".swift": "swift",
    ".lua": "lua",
    ".r": "r",
    ".R": "r",
    ".jl": "julia",
    ".zig": "zig",
    ".nim": "nim",
    ".ex": "elixir",
    ".exs": "elixir",
    ".erl": "erlang",
    ".hrl": "erlang",
    ".hs": "haskell",
    ".ml": "ocaml",
    ".mli": "ocaml",
    ".pl": "perl",
    ".pm": "perl",
    ".dart": "dart",
    ".v": "v",
    ".sh": "shellscript",
    ".bash": "shellscript",
    ".zsh": "shellscript",
    ".fish": "shellscript",
    ".ps1": "powershell",
    ".psm1": "powershell",
    ".json": "json",
    ".jsonc": "jsonc",
    ".yaml": "yaml",
    ".yml": "yaml",
    ".toml": "toml",
    ".xml": "xml",
    ".html": "html",
    ".htm": "html",
    ".css": "css",
    ".scss": "scss",
    ".less": "less",
    ".sql": "sql",
    ".md": "markdown",
    ".rst": "restructuredtext",
    ".tex": "latex",
    ".dockerfile": "dockerfile",
    ".proto": "proto3",
    ".graphql": "graphql",
    ".gql": "graphql",
    ".vue": "vue",
    ".svelte": "svelte",
}

# ---------------------------------------------------------------------------
# Layer 2: Shebang / content analysis
# ---------------------------------------------------------------------------

_SHEBANG_MAP: dict[str, str] = {
    "python": "python",
    "python3": "python",
    "node": "javascript",
    "ruby": "ruby",
    "perl": "perl",
    "bash": "shellscript",
    "sh": "shellscript",
    "zsh": "shellscript",
    "fish": "shellscript",
    "lua": "lua",
    "php": "php",
}


def _detect_from_shebang(first_line: str) -> str | None:
    """Parse a shebang line to determine language.

    Handles both ``#!/usr/bin/env python3`` and ``#!/usr/bin/python3``.

    Args:
        first_line: The first line of the file.

    Returns:
        Language ID or ``None`` if not detectable.
    """
    if not first_line.startswith("#!"):
        return None

    parts = first_line[2:].strip().split()
    if not parts:
        return None

    # Handle /usr/bin/env <interpreter>
    executable = parts[-1] if parts[0].endswith("/env") else parts[0]
    basename = os.path.basename(executable)

    # Strip version suffixes (python3.11 -> python3 -> python)
    for key, lang in _SHEBANG_MAP.items():
        if basename == key or basename.startswith(key):
            return lang

    return None


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


class LanguageDiscovery:
    """4-layer language discovery engine.

    Detects programming languages for files and directories using
    extension mapping, content analysis, UKO classification, and
    explicit project declarations.

    Args:
        project_languages: Explicit language declarations from the
            project configuration (layer 4).
        uko_classifications: Pre-loaded UKO language classifications
            keyed by file path (layer 3).
    """

    def __init__(
        self,
        project_languages: list[str] | None = None,
        uko_classifications: dict[str, str] | None = None,
    ) -> None:
        self._project_languages = [lang.lower() for lang in (project_languages or [])]
        self._uko: dict[str, str] = uko_classifications or {}
        self._cache: dict[str, str] = {}

    def detect_file_language(self, file_path: str) -> str:
        """Detect the language of a single file.

        Applies the 4-layer process in priority order:
        extension -> content -> UKO -> project default -> ``plaintext``.

        Args:
            file_path: Path to the file.

        Returns:
            Language identifier (e.g., ``"python"``).
        """
        if file_path in self._cache:
            return self._cache[file_path]

        lang = self._detect_uncached(file_path)
        self._cache[file_path] = lang
        return lang

    def detect_directory_languages(self, directory: str) -> list[str]:
        """Discover all languages present in a directory tree.

        Walks the directory, detects each file's language, and returns
        a deduplicated sorted list.

        Args:
            directory: Root directory to scan.

        Returns:
            Sorted list of unique language identifiers.
        """
        languages: set[str] = set()
        try:
            for root, _dirs, files in os.walk(directory):
                for fname in files:
                    fpath = os.path.join(root, fname)
                    lang = self.detect_file_language(fpath)
                    if lang != "plaintext":
                        languages.add(lang)
        except OSError:
            logger.warning(
                "lsp.discovery.walk_error",
                directory=directory,
                exc_info=True,
            )

        return sorted(languages)

    def invalidate(self, file_path: str) -> None:
        """Remove a file from the cache (e.g., after content change).

        Args:
            file_path: Path to invalidate.
        """
        self._cache.pop(file_path, None)

    def invalidate_all(self) -> None:
        """Clear the entire cache."""
        self._cache.clear()

    def _detect_uncached(self, file_path: str) -> str:
        """Run the 4-layer detection without cache."""
        # Layer 1: Extension
        ext = os.path.splitext(file_path)[1].lower()
        # Handle Dockerfile (no extension but specific name)
        basename = os.path.basename(file_path).lower()
        if basename == "dockerfile" or basename.startswith("dockerfile."):
            return "dockerfile"
        if basename == "makefile":
            return "makefile"
        if basename == "cmakelists.txt":
            return "cmake"

        if ext in _EXTENSION_MAP:
            return _EXTENSION_MAP[ext]

        # Layer 2: Content analysis (shebang)
        try:
            with open(file_path, encoding="utf-8", errors="replace") as f:
                first_line = f.readline(512)
            lang = _detect_from_shebang(first_line)
            if lang is not None:
                return lang
        except OSError:
            pass

        # Layer 3: UKO classification
        if file_path in self._uko:
            return self._uko[file_path]

        # Layer 4: Project-level declaration (return first match if any)
        if self._project_languages:
            return self._project_languages[0]

        return "plaintext"

    def get_servers_for_language(
        self,
        language: str,
        available_configs: list[Any],
    ) -> list[Any]:
        """Find LSP server configs that serve a given language.

        Args:
            language: Language identifier.
            available_configs: List of ``LspServerConfig`` objects.

        Returns:
            Configs whose ``languages`` list includes *language*.
        """
        return [
            cfg
            for cfg in available_configs
            if language in getattr(cfg, "languages", [])
        ]


__all__ = [
    "LanguageDiscovery",
]
