"""
Repository Scanner — Phase 1 of Kodara pipeline.

Recursively scans a codebase, detects languages, extracts structural
information (classes, functions, imports, exports), and computes file
hashes for incremental change detection.
"""

import ast
import re
import hashlib
from pathlib import Path
from dataclasses import dataclass, field


# Supported language extensions
LANGUAGE_EXTENSIONS: dict[str, str] = {
    ".py": "python",
    ".js": "javascript",
    ".jsx": "javascript",
    ".ts": "typescript",
    ".tsx": "typescript",
    ".go": "go",
    ".rs": "rust",
    ".java": "java",
    ".cs": "csharp",
    ".php": "php",
}

# Directories to skip during scanning
SKIP_DIRS: set[str] = {
    "node_modules", "vendor", ".git", "build", "dist",
    "__pycache__", ".pytest_cache", "venv", ".venv", "env",
    "coverage", ".coverage", "htmlcov", ".next", ".nuxt",
    "target", "out", "bin", "obj", ".cache", "tmp",
}

# Files to skip
SKIP_FILES: set[str] = {
    "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
    "uv.lock", "poetry.lock", "Cargo.lock",
}

MAX_FILE_SIZE   = 500 * 1024  # 500 KB
FREE_PLAN_LIMIT = 9999        # Max files on Free plan


class FreePlanLimitError(Exception):
    """Raised when a project exceeds the Free plan file limit."""
    def __init__(self, file_count: int):
        self.file_count = file_count
        super().__init__(
            f"Project has {file_count}+ files. "
            f"Free plan supports up to {FREE_PLAN_LIMIT} files."
        )


@dataclass
class FileInfo:
    """Structural metadata extracted from a single source file."""
    path: str           # Relative path from repo root
    language: str
    hash: str           # SHA-256 of file content
    imports: list[str] = field(default_factory=list)
    exports: list[str] = field(default_factory=list)
    classes: list[str] = field(default_factory=list)
    functions: list[str] = field(default_factory=list)
    size_bytes: int = 0


@dataclass
class ScanResult:
    """Complete scan output for a repository."""
    root: str
    files: dict[str, FileInfo]   # relative_path → FileInfo
    languages: dict[str, int]    # language → file count


class RepositoryScanner:
    """
    Scans a local repository and extracts structural metadata.

    Design decision: We parse imports/classes/functions using language-specific
    strategies (stdlib ast for Python, regex for others) rather than tree-sitter
    to keep the scanner dependency-light. jcodemunch handles deep AST queries;
    Kodara handles architectural relationships at a higher level.
    """

    def __init__(self, root: str | Path):
        self.root = Path(root).resolve()

    def scan(self, free_plan: bool = True) -> ScanResult:
        """
        Scan the repository and return structured metadata.

        free_plan: if True, raises FreePlanLimitError when project exceeds 400 files.
        """
        files: dict[str, FileInfo] = {}
        languages: dict[str, int] = {}

        for path in self._walk():
            ext = path.suffix.lower()
            language = LANGUAGE_EXTENSIONS.get(ext)
            if not language:
                continue

            # Size guard
            try:
                if path.stat().st_size > MAX_FILE_SIZE:
                    continue
            except OSError:
                continue

            rel_path = path.relative_to(self.root).as_posix()

            try:
                content = path.read_text(encoding="utf-8", errors="replace")
                file_hash = hashlib.sha256(
                    content.encode("utf-8", errors="replace")
                ).hexdigest()
                info = self._parse_file(rel_path, language, content, file_hash, path.stat().st_size)
                files[rel_path] = info
                languages[language] = languages.get(language, 0) + 1

                # Free plan limit check
                if free_plan and len(files) > FREE_PLAN_LIMIT:
                    raise FreePlanLimitError(len(files))

            except FreePlanLimitError:
                raise
            except Exception:
                continue

        return ScanResult(root=str(self.root), files=files, languages=languages)

    def _walk(self):
        """Walk the repo, skipping excluded directories."""
        for path in self.root.rglob("*"):
            if not path.is_file():
                continue
            if path.name in SKIP_FILES:
                continue
            # Skip if any path part is in SKIP_DIRS or starts with '.'
            rel_parts = path.relative_to(self.root).parts
            if any(
                part in SKIP_DIRS or (part.startswith(".") and part != ".")
                for part in rel_parts[:-1]
            ):
                continue
            yield path

    def _parse_file(
        self, path: str, language: str, content: str, file_hash: str, size: int
    ) -> FileInfo:
        """Route to the appropriate language parser."""
        info = FileInfo(path=path, language=language, hash=file_hash, size_bytes=size)

        parsers = {
            "python": self._parse_python,
            "javascript": self._parse_js_ts,
            "typescript": self._parse_js_ts,
            "go": self._parse_go,
            "rust": self._parse_rust,
            "java": self._parse_java,
            "csharp": self._parse_csharp,
            "php": self._parse_php,
        }
        parser = parsers.get(language)
        if parser:
            try:
                parser(content, info)
            except Exception:
                pass  # Partial data is fine; don't crash the scan

        # Deduplicate
        info.imports = list(dict.fromkeys(info.imports))
        info.exports = list(dict.fromkeys(info.exports))
        info.classes = list(dict.fromkeys(info.classes))
        info.functions = list(dict.fromkeys(info.functions))

        return info

    # ── Python ──────────────────────────────────────────────────────────────

    def _parse_python(self, content: str, info: FileInfo) -> None:
        try:
            tree = ast.parse(content)
        except SyntaxError:
            return

        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    info.imports.append(alias.name.split(".")[0])
            elif isinstance(node, ast.ImportFrom):
                if node.module:
                    if node.level > 0:
                        # Relative import (from .models import X) — stem only
                        info.imports.append(node.module.split(".")[0])
                    else:
                        # Absolute import — record full path for package resolution
                        info.imports.append(node.module)
            elif isinstance(node, ast.ClassDef):
                info.classes.append(node.name)
            elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                # Only top-level (depth 1) functions
                info.functions.append(node.name)

        # Public names are exports
        info.exports = [
            n for n in info.classes + info.functions if not n.startswith("_")
        ]

    # ── JavaScript / TypeScript ─────────────────────────────────────────────

    def _parse_js_ts(self, content: str, info: FileInfo) -> None:
        # ES6: import X from 'y'  |  import { X } from 'y'
        for m in re.finditer(r"""import\s+.*?\s+from\s+['"]([^'"]+)['"]""", content, re.MULTILINE):
            info.imports.append(self._normalize_js_import(m.group(1)))

        # CommonJS: require('y')
        for m in re.finditer(r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)""", content):
            info.imports.append(self._normalize_js_import(m.group(1)))

        # export function/class/const/let/var Name
        for m in re.finditer(
            r"""export\s+(?:default\s+)?(?:async\s+)?(?:function\*?\s+|class\s+|const\s+|let\s+|var\s+)(\w+)""",
            content
        ):
            info.exports.append(m.group(1))

        # class Name
        for m in re.finditer(r"""(?:^|\s)class\s+(\w+)""", content, re.MULTILINE):
            info.classes.append(m.group(1))

        # function Name(  |  const Name = (  |  const Name = async (
        for m in re.finditer(
            r"""(?:function\s+(\w+)\s*[(<]|(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\(|function))""",
            content
        ):
            name = m.group(1) or m.group(2)
            if name:
                info.functions.append(name)

    def _normalize_js_import(self, mod: str) -> str:
        """Normalize JS/TS import path to package name."""
        if mod.startswith("."):
            return mod  # Relative import — keep as-is for graph resolution
        # Scoped packages: @scope/package → @scope/package
        if mod.startswith("@"):
            parts = mod.split("/")
            return "/".join(parts[:2]) if len(parts) >= 2 else mod
        # Regular package: strip subpath
        return mod.split("/")[0]

    # ── Go ──────────────────────────────────────────────────────────────────

    def _parse_go(self, content: str, info: FileInfo) -> None:
        # import ( "pkg1"\n "pkg2" )
        for m in re.finditer(r'import\s*\((.*?)\)', content, re.DOTALL):
            for line in m.group(1).splitlines():
                line = line.strip().strip('"')
                if line and not line.startswith("//"):
                    pkg = line.split('"')[0].strip() if '"' in line else line
                    if pkg:
                        info.imports.append(pkg.rstrip("/").split("/")[-1])

        # import "single/pkg"
        for m in re.finditer(r'import\s+"([^"]+)"', content):
            info.imports.append(m.group(1).split("/")[-1])

        # func [(*Receiver)] Name(
        for m in re.finditer(r'func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)\s*[(<\[]', content):
            name = m.group(1)
            info.functions.append(name)
            if name[0].isupper():
                info.exports.append(name)

        # type Name struct
        for m in re.finditer(r'type\s+(\w+)\s+struct', content):
            info.classes.append(m.group(1))

    # ── Rust ────────────────────────────────────────────────────────────────

    def _parse_rust(self, content: str, info: FileInfo) -> None:
        # use crate::path  |  use std::path
        for m in re.finditer(r'use\s+([\w:]+)', content):
            root = m.group(1).split("::")[0]
            if root not in ("self", "super", "crate"):
                info.imports.append(root)

        # pub fn / fn
        for m in re.finditer(r'(pub\s+)?fn\s+(\w+)\s*[<(]', content):
            name = m.group(2)
            info.functions.append(name)
            if m.group(1):  # pub
                info.exports.append(name)

        # pub struct / struct
        for m in re.finditer(r'(pub\s+)?struct\s+(\w+)', content):
            info.classes.append(m.group(2))

        # impl Trait for Type / impl Type
        for m in re.finditer(r'impl\s+(?:\w+\s+for\s+)?(\w+)', content):
            name = m.group(1)
            if name not in info.classes:
                info.classes.append(name)

    # ── Java ────────────────────────────────────────────────────────────────

    def _parse_java(self, content: str, info: FileInfo) -> None:
        for m in re.finditer(r'^import\s+([\w.]+);', content, re.MULTILINE):
            info.imports.append(m.group(1).split(".")[0])

        for m in re.finditer(r'(?:public\s+)?(?:abstract\s+)?class\s+(\w+)', content):
            info.classes.append(m.group(1))

        for m in re.finditer(r'(?:public|protected)\s+\w+\s+(\w+)\s*\(', content):
            info.functions.append(m.group(1))
            info.exports.append(m.group(1))

    # ── C# ──────────────────────────────────────────────────────────────────

    def _parse_csharp(self, content: str, info: FileInfo) -> None:
        for m in re.finditer(r'^using\s+([\w.]+);', content, re.MULTILINE):
            info.imports.append(m.group(1).split(".")[0])

        for m in re.finditer(r'(?:public\s+)?(?:partial\s+)?class\s+(\w+)', content):
            info.classes.append(m.group(1))

        for m in re.finditer(r'public\s+\w+\s+(\w+)\s*\(', content):
            info.functions.append(m.group(1))
            info.exports.append(m.group(1))

    # ── PHP ─────────────────────────────────────────────────────────────────

    def _parse_php(self, content: str, info: FileInfo) -> None:
        for m in re.finditer(r'(?:use|require|include)\s+[\'"]?([\w/\\]+)[\'"]?', content):
            info.imports.append(m.group(1).split("\\")[-1].split("/")[-1])

        for m in re.finditer(r'class\s+(\w+)', content):
            info.classes.append(m.group(1))

        for m in re.finditer(r'(?:public\s+)?function\s+(\w+)\s*\(', content):
            info.functions.append(m.group(1))
