"""Tree-sitter parser for Carve.

Provides language-agnostic parsing of source code into a tree-sitter CST,
with auto-detection of language from file extension.
"""

from __future__ import annotations

import importlib
from pathlib import Path
from typing import TYPE_CHECKING

import pathspec
from tree_sitter import Language, Parser, Range

from carve.models import NodeMetadata, ProjectStore, TreeNode, TreeStore
from carve.node_id import BODY_WRAPPER_TYPES, extract_node_name

# Language injection: map HTML element types to the language of their content.
INJECTION_MAP: dict[str, str] = {
    "script_element": "javascript",
    "style_element": "css",
}

if TYPE_CHECKING:
    from tree_sitter import Node


class LanguageNotSupportedError(Exception):
    """Raised when a file's language is not supported."""

    pass


# Extension to language mapping
EXTENSION_MAP: dict[str, str] = {
    ".py": "python",
    ".pyw": "python",
    ".pyi": "python",
    ".js": "javascript",
    ".mjs": "javascript",
    ".cjs": "javascript",
    ".jsx": "javascript",
    ".html": "html",
    ".htm": "html",
    ".css": "css",
    ".json": "json",
    ".crv": "json",
    ".md": "markdown",
    ".toml": "toml",
    ".yaml": "yaml",
    ".yml": "yaml",
    ".sh": "bash",
    ".jinja": "jinja",
    ".jinja2": "jinja",
    ".j2": "jinja",
}


def detect_language(file_path: str | Path) -> str:
    """Detect language from file extension.

    Args:
        file_path: Path to the file

    Returns:
        Language name (e.g., 'python', 'javascript').
        Returns 'text' for unrecognized extensions.
    """
    path = Path(file_path)
    ext = path.suffix.lower()

    return EXTENSION_MAP.get(ext, "text")


# Default mapping from language name to Python module providing the grammar.
DEFAULT_IMPORT_MAP: dict[str, str] = {
    "python": "tree_sitter_python",
    "javascript": "tree_sitter_javascript",
    "html": "tree_sitter_html",
    "css": "tree_sitter_css",
    "json": "tree_sitter_json",
    "markdown": "tree_sitter_markdown",
    "toml": "tree_sitter_toml",
    "yaml": "tree_sitter_yaml",
    "bash": "tree_sitter_bash",
    "jinja": "tree_sitter_jinja",
}


def _load_language(language: str, import_map: dict[str, str]) -> Language:
    """Load a tree-sitter Language from an import map.

    If the language is not in the import map, auto-derives a module name
    using the convention ``tree_sitter_{name}`` (with dots and hyphens
    replaced by underscores) before giving up.
    """
    if language in import_map:
        module_name = import_map[language]
    else:
        # Auto-derive: "htmldjango" -> "tree_sitter_htmldjango",
        #              "html-django" -> "tree_sitter_html_django"
        module_name = "tree_sitter_" + language.replace("-", "_").replace(".", "_")

    try:
        mod = importlib.import_module(module_name)
    except ImportError:
        raise LanguageNotSupportedError(
            f"Grammar for '{language}' not installed. "
            f"Tried module '{module_name}'. "
            f"Install it with: pip install {module_name}"
        ) from None

    return Language(mod.language())


def get_language(language: str) -> Language:
    """Get the tree-sitter Language object for a language name.

    Uses the default import map. For custom mappings, use
    ``TreeSitterParser`` with ``import_overrides``.

    Args:
        language: Language name (e.g., 'python', 'javascript')

    Returns:
        tree-sitter Language object

    Raises:
        LanguageNotSupportedError: If the language is not supported
    """
    return _load_language(language, DEFAULT_IMPORT_MAP)


def _rewrite_node_ids(node: TreeNode, old_prefix: str, new_prefix: str) -> None:
    """Rewrite node IDs, replacing old_prefix with new_prefix.

    Used when building project trees to transform single-file IDs
    (e.g., 'module::User') into project-scoped IDs
    (e.g., 'myproject/models.py::User').
    """
    if node.node_id == old_prefix:
        node.node_id = new_prefix
    elif node.node_id.startswith(old_prefix + "::"):
        node.node_id = new_prefix + node.node_id[len(old_prefix) :]
    for child in node.children:
        _rewrite_node_ids(child, old_prefix, new_prefix)


class TreeSitterParser:
    """Parser for source code using tree-sitter.

    Provides auto-detection of language and parsing to CST, with methods
    to walk and convert nodes to Carve's TreeNode model.

    Args:
        extension_overrides: Extra or replacement extension-to-language entries
            merged on top of ``EXTENSION_MAP``.
        import_overrides: Extra or replacement language-to-module entries
            merged on top of ``DEFAULT_IMPORT_MAP``.
    """

    def __init__(
        self,
        extension_overrides: dict[str, str] | None = None,
        import_overrides: dict[str, str] | None = None,
    ) -> None:
        """Initialize the parser."""
        self._parsers: dict[str, Parser] = {}
        self._extension_map: dict[str, str] = {**EXTENSION_MAP}
        self._import_map: dict[str, str] = {**DEFAULT_IMPORT_MAP}
        if extension_overrides:
            self._extension_map.update(extension_overrides)
        if import_overrides:
            self._import_map.update(import_overrides)

    def detect_language(self, file_path: str | Path) -> str:
        """Detect language from file extension using instance maps.

        Returns 'text' for unrecognized extensions.
        """
        ext = Path(file_path).suffix.lower()
        return self._extension_map.get(ext, "text")

    def get_language(self, language: str) -> Language:
        """Get the tree-sitter Language object using instance import map."""
        return _load_language(language, self._import_map)

    def _get_parser(self, language: str) -> Parser:
        """Get or create a parser for a language."""
        if language not in self._parsers:
            parser = Parser(self.get_language(language))
            self._parsers[language] = parser
        return self._parsers[language]

    def parse(self, source: str | bytes, language: str) -> Node:
        """Parse source code and return the tree-sitter CST root.

        Args:
            source: Source code as string or bytes
            language: Language name (e.g., 'python')

        Returns:
            The root Node of the CST
        """
        parser = self._get_parser(language)

        if isinstance(source, str):
            source = source.encode("utf-8")

        tree = parser.parse(source)
        return tree.root_node

    def parse_file(self, file_path: str | Path) -> tuple[Node, str]:
        """Parse a file and return the CST root and detected language.

        Args:
            file_path: Path to the source file

        Returns:
            Tuple of (root Node, language name)
        """
        path = Path(file_path)
        language = self.detect_language(path)
        source = path.read_bytes()

        return self.parse(source, language), language

    def to_tree_store(
        self,
        source: str | bytes,
        language: str,
        file_path: str | None = None,
    ) -> TreeStore:
        """Parse source code and build a TreeStore.

        Args:
            source: Source code
            language: Language name
            file_path: Optional path for context

        Returns:
            A TreeStore containing the parsed tree
        """
        if isinstance(source, bytes):
            source_str = source.decode("utf-8")
            source_bytes = source
        else:
            source_str = source
            source_bytes = source.encode("utf-8")

        if language == "text":
            tree_root = TreeNode(
                node_id="document",
                node_type="document",
                source=source_str,
                start_byte=0,
                end_byte=len(source_bytes),
                start_point=(0, 0),
                end_point=(source_str.count("\n"), 0),
                children=[],
                metadata=NodeMetadata(),
                language="text",
            )
            return TreeStore(
                file_path=file_path,
                language="text",
                source=source_str,
                root=tree_root,
            )

        root_node = self.parse(source_bytes, language)
        tree_root = self._convert_node(root_node, language, source_bytes)

        return TreeStore(
            file_path=file_path,
            language=language,
            source=source_str,
            root=tree_root,
        )

    def to_tree_store_from_file(self, file_path: str | Path) -> TreeStore:
        """Parse a file and build a TreeStore.

        Args:
            file_path: Path to the source file

        Returns:
            A TreeStore containing the parsed tree
        """
        path = Path(file_path)
        language = self.detect_language(path)
        source = path.read_text(encoding="utf-8")

        return self.to_tree_store(source, language, str(path))

    def to_project_store(
        self,
        project_path: str | Path,
    ) -> ProjectStore:
        """Parse a directory tree into a ProjectStore.

        Walks the filesystem, creates directory and file nodes,
        parses supported files with tree-sitter into one unified tree.

        If a ``.carveignore`` file exists in the project root, its patterns
        are applied using gitignore-style matching (via ``pathspec``).
        Without a ``.carveignore``, all supported files are included.

        Args:
            project_path: Path to the project root directory

        Returns:
            A ProjectStore containing the unified project tree
        """
        path = Path(project_path).resolve()
        root_name = path.name

        # Load .carveignore if present
        carveignore = path / ".carveignore"
        if carveignore.is_file():
            spec = pathspec.PathSpec.from_lines(
                "gitignore", carveignore.read_text(encoding="utf-8").splitlines()
            )
        else:
            spec = None

        file_sources: dict[str, str] = {}
        file_languages: dict[str, str] = {}

        root_node = self._build_directory_node(
            path,
            root_name,
            file_sources,
            file_languages,
            ignore_spec=spec,
            project_root=path,
        )

        return ProjectStore(
            project_path=str(path),
            root=root_node,
            file_sources=file_sources,
            file_languages=file_languages,
        )

    def _build_directory_node(
        self,
        dir_path: Path,
        node_id: str,
        file_sources: dict[str, str],
        file_languages: dict[str, str],
        *,
        ignore_spec: pathspec.PathSpec | None = None,
        project_root: Path | None = None,
    ) -> TreeNode:
        """Recursively build a directory TreeNode with file and subdir children."""
        children: list[TreeNode] = []

        for item in sorted(dir_path.iterdir()):
            # Check .carveignore patterns against project-relative path
            if ignore_spec is not None and project_root is not None:
                rel = str(item.relative_to(project_root))
                # pathspec expects dirs to end with / for dir-only patterns
                check_path = rel + "/" if item.is_dir() else rel
                if ignore_spec.match_file(check_path):
                    continue

            if item.is_dir():
                child_id = f"{node_id}/{item.name}"
                child = self._build_directory_node(
                    item,
                    child_id,
                    file_sources,
                    file_languages,
                    ignore_spec=ignore_spec,
                    project_root=project_root,
                )
                # Only include directories that contain supported files
                if child.children:
                    children.append(child)

            elif item.is_file():
                try:
                    language = self.detect_language(item)
                except LanguageNotSupportedError:
                    continue

                file_id = f"{node_id}/{item.name}"
                source = item.read_text(encoding="utf-8")

                store = self.to_tree_store(source, language, str(item))

                if store.root is not None:
                    # Transform the tree-sitter root (module/program) into a file node
                    file_node = store.root
                    old_root_id = file_node.node_id
                    _rewrite_node_ids(file_node, old_root_id, file_id)
                    file_node.node_type = "file"
                    children.append(file_node)

                file_sources[file_id] = source
                file_languages[file_id] = language

        return TreeNode(
            node_id=node_id,
            node_type="directory",
            source="",
            children=children,
            language="",
        )

    def _convert_node(
        self,
        node: Node,
        language: str,
        source: bytes,
        *,
        naming_parent_id: str = "",
        sibling_index: int = 0,
    ) -> TreeNode:
        """Convert a tree-sitter Node to a Carve TreeNode.

        Recursively converts all ``named_children`` into the Carve tree.
        Body wrapper nodes (``block``, ``class_body``, ``statement_block``)
        are included in the tree but are *transparent* in the ID hierarchy:
        their children's IDs skip them, giving clean paths like
        ``module::User::__init__`` instead of ``module::User::block[1]::__init__``.

        Args:
            node: tree-sitter Node
            language: Language name
            source: Full source bytes for extracting text
            naming_parent_id: The ID used as prefix for this node's ID.
                For body-wrapper children, this is the grandparent's ID.
            sibling_index: Index among the parent's ``named_children``.
        """
        # Determine this node's name component
        name = extract_node_name(node)
        if name:
            part = name
        elif node.parent is None:
            # Root node — use type as name (module, program, document, etc.)
            part = node.type
        else:
            part = f"{node.type}[{sibling_index}]"

        node_id = f"{naming_parent_id}::{part}" if naming_parent_id else part

        # Body wrappers are transparent: pass the *current* naming context
        # through to children so their IDs skip the wrapper.
        is_body_wrapper = node.type in BODY_WRAPPER_TYPES
        child_naming_id = naming_parent_id if is_body_wrapper else node_id

        children = [
            self._convert_node(
                child,
                language,
                source,
                naming_parent_id=child_naming_id,
                sibling_index=i,
            )
            for i, child in enumerate(node.named_children)
        ]

        # Language injection: parse raw_text inside script/style with the right grammar
        if language == "html":
            children = self._apply_injections(children, source, node_id)

        node_source = source[node.start_byte : node.end_byte].decode("utf-8")

        return TreeNode(
            node_id=node_id,
            node_type=node.type,
            source=node_source,
            start_byte=node.start_byte,
            end_byte=node.end_byte,
            start_point=(node.start_point[0], node.start_point[1]),
            end_point=(node.end_point[0], node.end_point[1]),
            children=children,
            metadata=NodeMetadata(),
            language=language,
        )

    def _apply_injections(
        self,
        children: list[TreeNode],
        source: bytes,
        parent_id: str,
    ) -> list[TreeNode]:
        """Apply language injections to script/style element children.

        For each child whose node_type is in INJECTION_MAP (script_element,
        style_element), sub-parse the raw_text region with the appropriate
        language grammar and replace the element's children with the parsed
        JS/CSS children.
        """
        for child in children:
            injected_lang = INJECTION_MAP.get(child.node_type)
            if injected_lang is None:
                continue

            # Find the raw_text child
            raw_text = None
            for gc in child.children:
                if gc.node_type == "raw_text":
                    raw_text = gc
                    break

            if raw_text is None or raw_text.start_byte >= raw_text.end_byte:
                continue

            # Sub-parse the raw_text region with the injected language grammar
            sub_parser = Parser(self.get_language(injected_lang))
            sub_parser.included_ranges = [
                Range(
                    start_point=raw_text.start_point,
                    end_point=raw_text.end_point,
                    start_byte=raw_text.start_byte,
                    end_byte=raw_text.end_byte,
                )
            ]
            sub_tree = sub_parser.parse(source)
            sub_root = sub_tree.root_node

            # Convert the sub-tree's children with the injected language
            injected_children = [
                self._convert_node(
                    sub_child,
                    injected_lang,
                    source,
                    naming_parent_id=child.node_id,
                    sibling_index=i,
                )
                for i, sub_child in enumerate(sub_root.named_children)
            ]

            # Replace the element's children and set its language
            child.children = injected_children
            child.language = injected_lang

        return children

    def walk_nodes(self, node: Node, language: str) -> list[tuple[Node, str]]:
        """Walk all named nodes in a tree, yielding (node, node_id) pairs.

        Args:
            node: Starting tree-sitter Node
            language: Language name

        Returns:
            List of (Node, node_id) tuples for all named nodes
        """
        result: list[tuple[Node, str]] = []
        self._walk_nodes_recursive(node, result)
        return result

    def _walk_nodes_recursive(self, node: Node, result: list[tuple[Node, str]]) -> None:
        """Recursively walk nodes."""
        name = extract_node_name(node)

        # Only include if this node has a name
        if name is not None:
            result.append((node, name))

        for child in node.named_children:
            self._walk_nodes_recursive(child, result)

    def get_node_at_position(self, root: Node, row: int, column: int) -> Node | None:
        """Find the deepest node at a given position.

        Args:
            root: Root node of the tree
            row: Row (0-indexed)
            column: Column (0-indexed)

        Returns:
            The deepest node containing the position, or None
        """
        return self._find_node_at_position(root, row, column)

    def _find_node_at_position(self, node: Node, row: int, column: int) -> Node | None:
        """Recursively find node at position."""
        # Check if position is within this node
        start_row, start_col = node.start_point
        end_row, end_col = node.end_point

        # Position before this node
        if row < start_row or (row == start_row and column < start_col):
            return None

        # Position after this node
        if row > end_row or (row == end_row and column >= end_col):
            return None

        # Try to find a more specific child
        for child in node.children:
            found = self._find_node_at_position(child, row, column)
            if found is not None:
                return found

        return node
