from __future__ import annotations

import ast
import re
from pathlib import Path

from ai_orchestrator.config.settings import Settings


class RepoAnalyzer:
    def __init__(self, settings: Settings) -> None:
        self.settings = settings

    def scan_repository(self, repo_path: Path, goal: str | None = None, client_context: dict | None = None) -> dict:
        relevant_files = sorted(path for path in repo_path.rglob("*") if path.is_file() and self._is_relevant(path, repo_path))
        python_files = [path for path in relevant_files if path.suffix == ".py"]
        directory_map = self._directory_map(repo_path)
        important_files = self._important_files(repo_path)
        mentioned_files = self._mentioned_files(repo_path, relevant_files, goal)
        context_focus_files = self._client_context_focus_files(repo_path, relevant_files, client_context)
        mentioned_files = self._merge_paths(mentioned_files, context_focus_files)
        if mentioned_files:
            important_files = [path.relative_to(repo_path) for path in mentioned_files]
        dependency_graph = self.build_dependency_graph(repo_path, mentioned_files=mentioned_files)
        uses_pytest = self._repo_uses_pytest(repo_path)
        related_test_files = self._focused_pytest_targets(repo_path, mentioned_files) if uses_pytest else []
        test_commands = self.identify_test_commands(repo_path, uses_pytest=uses_pytest, mentioned_files=mentioned_files)
        build_commands = self.identify_build_commands(repo_path)
        entry_points = [] if mentioned_files else [path.as_posix() for path in important_files if path.name in {"main.py", "app.py"}]
        key_modules = self._key_modules(repo_path, python_files, mentioned_files)
        file_previews = self._file_previews(repo_path, python_files, important_files, mentioned_files)
        syntax_findings = self._syntax_findings(repo_path, python_files, mentioned_files)

        architecture_notes = []
        if (repo_path / "pyproject.toml").exists():
            architecture_notes.append("Python project with pyproject.toml configuration.")
        if (repo_path / "package.json").exists():
            architecture_notes.append("Node-based project with package.json.")
        if (repo_path / "tests").exists():
            architecture_notes.append("Repository has a dedicated tests directory.")

        return {
            "repo_name": repo_path.name,
            "entry_points": entry_points,
            "test_framework": "pytest" if uses_pytest else None,
            "key_modules": key_modules,
            "important_files": [path.as_posix() for path in important_files],
            "mentioned_files": [path.relative_to(repo_path).as_posix() for path in mentioned_files],
            "file_previews": file_previews,
            "syntax_findings": syntax_findings,
            "contains_python": bool(python_files),
            "directory_map": directory_map,
            "dependency_graph": dependency_graph,
            "test_commands": test_commands,
            "related_test_files": related_test_files,
            "build_commands": build_commands,
            "architecture_notes": architecture_notes,
            "client_context": self._client_context_summary(client_context),
        }

    def build_dependency_graph(self, repo_path: Path, mentioned_files: list[Path] | None = None) -> dict[str, list[str]]:
        graph: dict[str, list[str]] = {}
        focus = {path.resolve() for path in mentioned_files or []}
        for file_path in repo_path.rglob("*.py"):
            if not self._is_relevant(file_path, repo_path):
                continue
            if focus and file_path.resolve() not in focus:
                continue
            relative = file_path.relative_to(repo_path).as_posix()
            try:
                tree = ast.parse(file_path.read_text(encoding="utf-8"))
            except (OSError, SyntaxError, UnicodeDecodeError):
                continue
            imports: list[str] = []
            for node in ast.walk(tree):
                if isinstance(node, ast.Import):
                    imports.extend(alias.name for alias in node.names)
                elif isinstance(node, ast.ImportFrom) and node.module:
                    imports.append(node.module)
            graph[relative] = sorted(set(imports))
        return graph

    def identify_test_commands(
        self,
        repo_path: Path,
        *,
        uses_pytest: bool | None = None,
        mentioned_files: list[Path] | None = None,
    ) -> list[str]:
        commands: list[str] = []
        if uses_pytest is None:
            uses_pytest = self._repo_uses_pytest(repo_path)
        if uses_pytest:
            for target in self._focused_pytest_targets(repo_path, mentioned_files or [])[:3]:
                commands.append(f"pytest -q {target}")
            commands.append("pytest -q")
        if (repo_path / "package.json").exists():
            commands.append("npm test")
        deduped: list[str] = []
        seen: set[str] = set()
        for command in commands:
            if command in seen:
                continue
            seen.add(command)
            deduped.append(command)
        return deduped

    def _repo_uses_pytest(self, repo_path: Path) -> bool:
        if (repo_path / "tests").exists():
            return True
        if any(self._is_relevant(path, repo_path) and path.name == "conftest.py" for path in repo_path.rglob("conftest.py")):
            return True
        if self._has_pytest_config(repo_path):
            return True
        return self._has_pytest_test_files(repo_path)

    @staticmethod
    def _has_pytest_config(repo_path: Path) -> bool:
        config_candidates = {
            repo_path / "pytest.ini": None,
            repo_path / "pyproject.toml": "[tool.pytest.ini_options]",
            repo_path / "setup.cfg": "[tool:pytest]",
            repo_path / "tox.ini": "[pytest]",
        }
        for config_path, marker in config_candidates.items():
            if not config_path.exists():
                continue
            if marker is None:
                return True
            try:
                content = config_path.read_text(encoding="utf-8")
            except (OSError, UnicodeDecodeError):
                continue
            if marker in content:
                return True
        return False

    def _has_pytest_test_files(self, repo_path: Path) -> bool:
        for file_path in repo_path.rglob("*.py"):
            if not self._is_relevant(file_path, repo_path):
                continue
            if not self._looks_like_test_module(file_path):
                continue
            if self._python_file_contains_pytest_tests(file_path):
                return True
        return False

    @staticmethod
    def _looks_like_test_module(file_path: Path) -> bool:
        return (
            file_path.name.startswith("test_")
            or file_path.name.endswith("_test.py")
            or file_path.name == "conftest.py"
        )

    @staticmethod
    def _python_file_contains_pytest_tests(file_path: Path) -> bool:
        try:
            tree = ast.parse(file_path.read_text(encoding="utf-8"))
        except (OSError, SyntaxError, UnicodeDecodeError):
            return False

        for node in tree.body:
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
                return True
            if isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
                if any(
                    isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test_")
                    for child in node.body
                ):
                    return True
        return False

    def identify_build_commands(self, repo_path: Path) -> list[str]:
        commands: list[str] = []
        if (repo_path / "pyproject.toml").exists():
            commands.append("python -m compileall .")
        if (repo_path / "package.json").exists():
            commands.append("npm run build")
        return commands

    def _important_files(self, repo_path: Path) -> list[Path]:
        priority = [
            "README.md",
            "pyproject.toml",
            "package.json",
            "main.py",
            "app.py",
            "setup.py",
        ]
        found: list[Path] = []
        for name in priority:
            for file_path in repo_path.rglob(name):
                if self._is_relevant(file_path, repo_path):
                    found.append(file_path.relative_to(repo_path))
        return found

    def _mentioned_files(self, repo_path: Path, relevant_files: list[Path], goal: str | None) -> list[Path]:
        if not goal:
            return []

        raw_matches = [raw_match.strip("`'\"") for raw_match in re.findall(r"[\w./\\-]+\.[A-Za-z0-9]+", goal)]
        return self._resolve_file_candidates(repo_path, relevant_files, raw_matches)

    def _client_context_focus_files(
        self,
        repo_path: Path,
        relevant_files: list[Path],
        client_context: dict | None,
    ) -> list[Path]:
        if not client_context:
            return []
        raw_candidates: list[str] = []
        for key in ("selected_files",):
            values = client_context.get(key)
            if isinstance(values, list):
                raw_candidates.extend(str(value) for value in values if isinstance(value, str))
        active_file = client_context.get("active_file")
        if isinstance(active_file, str) and active_file.strip():
            raw_candidates.append(active_file)
        if not raw_candidates:
            open_tabs = client_context.get("open_tabs")
            if isinstance(open_tabs, list):
                raw_candidates.extend(str(value) for value in open_tabs[:2] if isinstance(value, str))
        return self._resolve_file_candidates(repo_path, relevant_files, raw_candidates)

    @staticmethod
    def _merge_paths(primary: list[Path], secondary: list[Path]) -> list[Path]:
        result: list[Path] = []
        seen: set[Path] = set()
        for path in primary + secondary:
            if path in seen:
                continue
            seen.add(path)
            result.append(path)
        return result

    def _resolve_file_candidates(self, repo_path: Path, relevant_files: list[Path], raw_candidates: list[str]) -> list[Path]:
        indexed_paths = {
            file_path.relative_to(repo_path).as_posix().lower(): file_path
            for file_path in relevant_files
        }
        by_name: dict[str, list[Path]] = {}
        for file_path in relevant_files:
            by_name.setdefault(file_path.name.lower(), []).append(file_path)

        matches: list[Path] = []
        seen: set[Path] = set()
        for raw_match in raw_candidates:
            normalized = raw_match.strip("`'\"").replace("\\", "/").lstrip("./").lower()
            direct = indexed_paths.get(normalized)
            if direct is not None and direct not in seen:
                matches.append(direct)
                seen.add(direct)
                continue

            file_name = Path(normalized).name
            for candidate in by_name.get(file_name, []):
                if candidate not in seen:
                    matches.append(candidate)
                    seen.add(candidate)
        return matches

    def _focused_pytest_targets(self, repo_path: Path, mentioned_files: list[Path]) -> list[str]:
        if not mentioned_files:
            return []

        focused_targets: list[str] = []
        seen: set[str] = set()

        def add_target(candidate: Path) -> None:
            try:
                relative = candidate.relative_to(repo_path).as_posix()
            except ValueError:
                return
            if relative in seen or not candidate.exists() or not candidate.is_file():
                return
            if not self._is_relevant(candidate, repo_path):
                return
            if not self._looks_like_test_module(candidate):
                return
            if not self._python_file_contains_pytest_tests(candidate):
                return
            seen.add(relative)
            focused_targets.append(relative)

        for file_path in mentioned_files:
            if self._looks_like_test_module(file_path) and self._python_file_contains_pytest_tests(file_path):
                add_target(file_path)
                continue

            stem = file_path.stem
            relative_parent = file_path.relative_to(repo_path).parent
            test_roots = [repo_path / "tests", repo_path / relative_parent]
            for root in test_roots:
                add_target(root / f"test_{stem}.py")
                add_target(root / f"{stem}_test.py")

            mirrored_tests_root = repo_path / "tests" / relative_parent
            add_target(mirrored_tests_root / f"test_{stem}.py")
            add_target(mirrored_tests_root / f"{stem}_test.py")

        return focused_targets

    @staticmethod
    def _client_context_summary(client_context: dict | None) -> dict:
        if not isinstance(client_context, dict):
            return {}
        summary: dict[str, object] = {}
        for key in ("client", "response_protocol", "workspace_root", "active_file", "shell", "os", "approval_mode"):
            value = client_context.get(key)
            if isinstance(value, str) and value:
                summary[key] = value
        for key in ("selected_files", "open_tabs"):
            value = client_context.get(key)
            if isinstance(value, list) and value:
                summary[key] = [str(item) for item in value[:5]]
        return summary

    def _key_modules(self, repo_path: Path, python_files: list[Path], mentioned_files: list[Path]) -> list[str]:
        if mentioned_files:
            # For mentioned files, also include files that import them or that they import
            return self._prioritize_files_by_import_centrality(repo_path, mentioned_files, python_files)

        # For general analysis, prioritize by centrality in the import graph
        if not python_files:
            return []

        return self._prioritize_files_by_import_centrality(repo_path, [], python_files)

    def _prioritize_files_by_import_centrality(self, repo_path: Path, focus_files: list[Path], all_python_files: list[Path]) -> list[str]:
        """Prioritize files based on their centrality in the import graph."""
        if focus_files:
            return [path.relative_to(repo_path).as_posix() for path in focus_files]

        # Build import graph
        import_graph = self.build_dependency_graph(repo_path, mentioned_files=focus_files)

        # Calculate centrality score for each file
        centrality = {}
        for file_path in all_python_files:
            relative = file_path.relative_to(repo_path).as_posix()
            # Centrality = number of files that import this file + number of files it imports
            inbound = sum(1 for deps in import_graph.values() if relative in deps)
            outbound = len(import_graph.get(relative, []))
            centrality[relative] = inbound + outbound

        # Sort by centrality (most central files first)
        sorted_files = sorted(centrality.items(), key=lambda x: x[1], reverse=True)

        # Always include focus files at the beginning
        focus_paths = {f.relative_to(repo_path).as_posix() for f in focus_files}

        result = []
        seen = set(focus_paths)
        result.extend(focus_paths)

        for file_path, score in sorted_files:
            if file_path not in seen:
                result.append(file_path)
                seen.add(file_path)
                if len(result) >= self.settings.repo_preview_files:
                    break

        return result

    def _calculate_file_complexity(self, repo_path: Path, file_path: Path) -> dict:
        """Calculate complexity metrics for a file to help prioritize analysis."""
        try:
            content = file_path.read_text(encoding="utf-8")
            tree = ast.parse(content)
        except (OSError, UnicodeDecodeError, SyntaxError):
            return {"complexity": 0, "lines": 0, "functions": 0}

        lines = len(content.splitlines())
        functions = sum(1 for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)))

        # Calculate cyclomatic complexity approximation
        complexity = 0
        for node in ast.walk(tree):
            if isinstance(node, (ast.If, ast.For, ast.While, ast.ExceptHandler)):
                complexity += 1
            elif isinstance(node, (ast.BoolOp, ast.BinOp)):
                complexity += 1

        return {"complexity": complexity, "lines": lines, "functions": functions}

    def _file_previews(
        self,
        repo_path: Path,
        python_files: list[Path],
        important_files: list[Path],
        mentioned_files: list[Path],
    ) -> list[dict[str, str]]:
        ordered: list[Path] = []
        seen: set[Path] = set()

        def queue(file_path: Path) -> None:
            absolute = file_path if file_path.is_absolute() else repo_path / file_path
            if absolute in seen or not absolute.exists() or not absolute.is_file():
                return
            if not self._is_relevant(absolute, repo_path):
                return
            ordered.append(absolute)
            seen.add(absolute)

        for file_path in mentioned_files:
            queue(file_path)
        if not mentioned_files:
            for file_path in important_files:
                queue(repo_path / file_path)
        for file_path in python_files:
            if mentioned_files and file_path not in mentioned_files:
                continue
            queue(file_path)
            if len(ordered) >= self.settings.repo_preview_files:
                break

        previews: list[dict[str, str]] = []
        for file_path in ordered[: self.settings.repo_preview_files]:
            try:
                content = file_path.read_text(encoding="utf-8")
            except (UnicodeDecodeError, OSError):
                continue
            preview_limit = self.settings.repo_focus_file_bytes if mentioned_files else self.settings.repo_preview_bytes
            snippet = content[: preview_limit]
            truncated = len(content) > preview_limit
            if truncated:
                snippet = f"{snippet}\n...<truncated>"
            previews.append(
                {
                    "path": file_path.relative_to(repo_path).as_posix(),
                    "content": snippet,
                    "truncated": truncated,
                }
            )
        return previews

    def _syntax_findings(self, repo_path: Path, python_files: list[Path], mentioned_files: list[Path]) -> list[dict[str, str | int]]:
        findings: list[dict[str, str | int]] = []
        if mentioned_files:
            ordered = [file_path for file_path in mentioned_files if file_path.suffix == ".py"]
        else:
            ordered = []
            seen: set[Path] = set()
            for file_path in python_files:
                if file_path in seen or file_path.suffix != ".py":
                    continue
                ordered.append(file_path)
                seen.add(file_path)
                if len(ordered) >= max(self.settings.repo_preview_files, 5):
                    break

        for file_path in ordered:
            try:
                ast.parse(file_path.read_text(encoding="utf-8"))
            except SyntaxError as exc:
                findings.append(
                    {
                        "path": file_path.relative_to(repo_path).as_posix(),
                        "line": exc.lineno or 1,
                        "column": exc.offset or 1,
                        "message": exc.msg,
                    }
                )
            except (OSError, UnicodeDecodeError):
                continue
        return findings

    def _directory_map(self, repo_path: Path) -> dict[str, int]:
        counts: dict[str, int] = {}
        for file_path in repo_path.rglob("*"):
            if not file_path.is_file() or not self._is_relevant(file_path, repo_path):
                continue
            top_level = file_path.relative_to(repo_path).parts[0]
            counts[top_level] = counts.get(top_level, 0) + 1
        return counts

    @staticmethod
    def _is_relevant(file_path: Path, repo_path: Path) -> bool:
        ignored_parts = {".git", ".venv", "node_modules", "__pycache__", "artifacts", "data"}
        relative_parts = file_path.relative_to(repo_path).parts
        return not any(part in ignored_parts for part in relative_parts)
