"""Context service for managing file context in plans.

This service handles adding, removing, and managing files that the AI
will use as context when generating code changes. Uses repository pattern
and Unit of Work for persistence (ADR-007).

Includes LangGraph-based context analysis for intelligent context processing.
"""

from __future__ import annotations

import hashlib
import uuid
from collections.abc import AsyncIterator, Iterator
from datetime import datetime
from fnmatch import fnmatch
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol

import structlog

from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import ConfigurationError, FileSystemError, PlanError
from cleveragents.domain.models.core import Context, ContextType, Plan, Project
from cleveragents.infrastructure.database.unit_of_work import (
    UnitOfWork,
    UnitOfWorkContext,
)
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType

DEFAULT_IGNORE_PATTERNS = [
    "__pycache__",
    ".git",
    ".pytest_cache",
    ".coverage",
    ".mypy_cache",
    ".ruff_cache",
    "node_modules",
    ".venv",
    "venv",
    ".env",
    ".agentsignore",
    "config.local.toml",
    "*.pyc",
    "*.pyo",
    "*.pyd",
    "*.so",
    "*.dll",
    "*.exe",
    "*.db",
    "*.sqlite",
]

if TYPE_CHECKING:
    from langchain_core.language_models import BaseLanguageModel

    from cleveragents.agents.context_analysis import (
        ContextAnalysisAgent,
        ContextAnalysisState,
    )
    from cleveragents.infrastructure.events.protocol import EventBus


class VectorStoreProtocol(Protocol):
    def is_enabled(self) -> bool: ...

    def refresh_for_plan(self, plan_id: int) -> int: ...

    def search(
        self,
        plan_id: int,
        query: str,
        *,
        top_k: int = 5,
        refresh_if_missing: bool = True,
    ) -> list[dict[str, Any]]: ...


logger = structlog.get_logger(__name__)


class ContextService:
    """Service for managing context files in plans.

    Context files are the source files that the AI reads to understand
    the codebase when generating changes.
    """

    def __init__(
        self,
        settings: Settings,
        unit_of_work: UnitOfWork,
        *,
        vector_store_service: VectorStoreProtocol | None = None,
        event_bus: EventBus | None = None,
    ) -> None:
        """Initialize the context service.

        Args:
            settings: Application settings
            unit_of_work: Unit of Work for database transactions
            vector_store_service: Optional semantic search helper
            event_bus: Optional EventBus for domain event emission.
        """
        self.settings = settings
        self.unit_of_work = unit_of_work
        self._vector_store_service = vector_store_service
        self._event_bus = event_bus
        self.max_file_size = 10 * 1024 * 1024  # 10MB per file
        # Use getattr with default value
        self.max_context_size = getattr(
            settings, "cleveragents_max_context_size", 50 * 1024 * 1024
        )  # 50MB default
        self.extra_ignore_patterns: list[str] = []
        self._agentsignore_cache: dict[Path, list[str]] = {}

    def add_to_context(
        self, project: Project, path: Path, recursive: bool = True
    ) -> tuple[list[Path], list[Path]]:
        """Add files to the current plan's context.

        Args:
            project: The project containing the plan
            path: Path to add (can be file or directory)
            recursive: Whether to add directory contents recursively

        Returns:
            Tuple of (added_files, already_in_context)

        Raises:
            FileSystemError: If path does not exist
            PlanError: If no current plan exists
        """
        if not path.exists():
            raise FileSystemError(
                message=f"Path does not exist: {path}",
                path=path,
            )

        plan_id: int | None = None
        with self.unit_of_work.transaction() as ctx:
            # Get current plan
            current_plan = (
                ctx.plans.get_current_for_project(project.id) if project.id else None
            )

            if not current_plan:
                raise PlanError(
                    message="No current plan to add context to",
                    details={
                        "hint": "Create a plan first with 'agents new' or 'agents tell'"
                    },
                )

            plan_id = current_plan.id
            added_files: list[Path] = []
            already_in_context: list[Path] = []

            if path.is_file():
                if self._should_ignore(project, path):
                    return [], []
                # Add single file
                result = self._add_file_to_context(ctx, current_plan, path)
                if result == "added":
                    added_files.append(path)
                elif result == "already_exists":
                    already_in_context.append(path)
            elif path.is_dir():
                # Add directory contents
                if recursive:
                    for file_path in path.rglob("*"):
                        if file_path.is_file() and not self._should_ignore(
                            project, file_path
                        ):
                            result = self._add_file_to_context(
                                ctx, current_plan, file_path
                            )
                            if result == "added":
                                added_files.append(file_path)
                            elif result == "already_exists":
                                already_in_context.append(file_path)
                else:
                    for file_path in path.iterdir():
                        if file_path.is_file() and not self._should_ignore(
                            project, file_path
                        ):
                            result = self._add_file_to_context(
                                ctx, current_plan, file_path
                            )
                            if result == "added":
                                added_files.append(file_path)
                            elif result == "already_exists":
                                already_in_context.append(file_path)

        if plan_id and added_files:
            self._refresh_vector_index(plan_id)

        return added_files, already_in_context

    def _add_file_to_context(
        self, ctx: UnitOfWorkContext, plan: Plan, file_path: Path
    ) -> str:
        """Add a single file to the context.

        Args:
            ctx: Database transaction context
            plan: Plan to add context to
            file_path: File to add

        Returns:
            Status: "added", "already_exists", "skipped"
        """
        # Check file size
        file_size = file_path.stat().st_size
        if file_size > self.max_file_size:
            return "skipped"

        # Check if already in context
        if not plan.id:
            return "skipped"
        existing_contexts = ctx.contexts.get_for_plan(plan.id)
        if any(c.path == str(file_path) for c in existing_contexts):
            return "already_exists"

        # Check total context size
        total_size = sum(c.size for c in existing_contexts) + file_size
        if total_size > self.max_context_size:
            return "skipped"

        try:
            # Read file content
            content = file_path.read_text(encoding="utf-8", errors="ignore")

            # Calculate hash
            file_hash = hashlib.sha256(content.encode()).hexdigest()

            # Create context entry
            context = Context(
                id=None,
                plan_id=plan.id,
                path=str(file_path),
                type=ContextType.FILE,
                content=content,
                file_hash=file_hash,
                size=file_size,
                added_at=datetime.now(),
            )

            ctx.contexts.add(context)
            return "added"

        except Exception:
            # Skip files that can't be read
            return "skipped"

    def _load_agentsignore(self, directory: Path) -> list[str]:
        """Load .agentsignore patterns for a directory (cached)."""

        cached = self._agentsignore_cache.get(directory)
        if cached is not None:
            return cached

        patterns: list[str] = []
        ignore_file = directory / ".agentsignore"
        if ignore_file.exists():
            try:
                for line in ignore_file.read_text(
                    encoding="utf-8", errors="ignore"
                ).splitlines():
                    stripped = line.strip()
                    if not stripped or stripped.startswith("#"):
                        continue
                    patterns.append(stripped)
            except Exception:
                patterns = []

        self._agentsignore_cache[directory] = patterns
        return patterns

    def _collect_ignore_rules(self, path: Path) -> list[tuple[Path, str]]:
        """Collect ignore rules from .agentsignore files up the directory tree."""

        rules: list[tuple[Path, str]] = []
        for directory in path.parents:
            patterns = self._load_agentsignore(directory)
            rules.extend((directory, pattern) for pattern in patterns)

        if self.extra_ignore_patterns:
            rules.extend((Path("/"), pattern) for pattern in self.extra_ignore_patterns)

        return rules

    def _matches_default_ignore(self, path: Path) -> bool:
        """Check built-in ignore patterns against the given path."""

        name = path.name
        parts = set(path.parts)
        for pattern in DEFAULT_IGNORE_PATTERNS:
            if any(char in pattern for char in "*?["):
                if fnmatch(name, pattern):
                    return True
                continue

            if name == pattern or pattern in parts:
                return True

        return False

    def _matches_ignore(self, base: Path, pattern: str, path: Path) -> bool:
        """Apply gitignore-style matching for a single pattern."""

        try:
            rel_path = path.relative_to(base)
        except ValueError:
            return False

        rel_str = rel_path.as_posix()
        name = path.name

        anchored = pattern.startswith("/")
        cleaned = pattern.lstrip("/")

        # Directory-only patterns
        if cleaned.endswith("/"):
            dir_pat = cleaned.rstrip("/")
            return rel_str == dir_pat or rel_str.startswith(f"{dir_pat}/")

        # Anchored pattern applies to relative path
        if anchored:
            return fnmatch(rel_str, cleaned) or rel_str.startswith(f"{cleaned}/")

        # Unanchored: match relative path, basename, or descendant
        if fnmatch(rel_str, cleaned) or fnmatch(name, cleaned):
            return True
        return rel_str.startswith(f"{cleaned}/")

    def _project_path_matches(self, project: Project, path: Path, pattern: str) -> bool:
        """Match a path against a project-level include/exclude glob."""

        try:
            rel = path.relative_to(project.path)
        except ValueError:
            return False
        rel_str = rel.as_posix()
        name = path.name
        return fnmatch(rel_str, pattern) or fnmatch(name, pattern)

    def _should_ignore(self, project: Project, path: Path) -> bool:
        """Check whether to ignore a path via settings and .agentsignore."""

        if self._matches_default_ignore(path):
            return True

        for pattern in project.settings.exclude_paths:
            if self._project_path_matches(project, path, pattern):
                return True

        if project.settings.include_paths and not any(
            self._project_path_matches(project, path, pattern)
            for pattern in project.settings.include_paths
        ):
            return True

        rules = self._collect_ignore_rules(path)
        return any(self._matches_ignore(base, pattern, path) for base, pattern in rules)

    def remove_from_context(self, project: Project, path: Path) -> int:
        """Remove files from the context.

        Args:
            project: The project containing the plan
            path: Path to remove (can be file or directory)

        Returns:
            Number of files removed
        """
        plan_id: int | None = None
        with self.unit_of_work.transaction() as ctx:
            current_plan = (
                ctx.plans.get_current_for_project(project.id) if project.id else None
            )

            if not current_plan or not current_plan.id:
                return 0

            plan_id = current_plan.id
            contexts = ctx.contexts.get_for_plan(plan_id)
            removed_count = 0

            for context in contexts:
                context_path = Path(context.path)
                # Check if this context matches the path or is under the path
                if (
                    context_path == path or path in context_path.parents
                ) and context.id:
                    ctx.contexts.remove(context.id)
                    removed_count += 1

        if plan_id and removed_count:
            self._refresh_vector_index(plan_id)

        return removed_count

    def clear_context(self, project: Project) -> int:
        """Clear all context for the current plan.

        Args:
            project: The project containing the plan

        Returns:
            Number of files removed
        """
        plan_id: int | None = None
        with self.unit_of_work.transaction() as ctx:
            current_plan = (
                ctx.plans.get_current_for_project(project.id) if project.id else None
            )

            if not current_plan or not current_plan.id:
                return 0

            plan_id = current_plan.id
            contexts = ctx.contexts.get_for_plan(plan_id)
            count = len(contexts)

            ctx.contexts.clear_for_plan(plan_id)

        if plan_id and count:
            self._refresh_vector_index(plan_id)

        return count

    def list_context(self, project: Project) -> list[Context]:
        """List all files in the current plan's context.

        Args:
            project: The project containing the plan

        Returns:
            List of context entries
        """
        if not project.id:
            return []

        with self.unit_of_work.transaction() as ctx:
            current_plan = ctx.plans.get_current_for_project(project.id)

            if not current_plan or not current_plan.id:
                return []

            return ctx.contexts.get_for_plan(current_plan.id)

    def get_context_size(self, project: Project) -> int:
        """Get the total size of all context files.

        Args:
            project: The project containing the plan

        Returns:
            Total size in bytes
        """
        contexts = self.list_context(project)
        return sum(c.size for c in contexts)

    def show_context_content(self, project: Project) -> dict[str, str]:
        """Get the content of all context files.

        Args:
            project: The project containing the plan

        Returns:
            Dict mapping file paths to their content
        """
        contexts = self.list_context(project)
        return {c.path: c.content or "" for c in contexts}

    def get_context_content(self, project: Project, path: Path) -> str | None:
        """Get the content of a specific context file.

        Args:
            project: The project containing the plan
            path: Path to the file to retrieve

        Returns:
            File content if found, None otherwise
        """
        # Resolve path to absolute to match stored paths
        resolved_path = path.resolve()
        contexts = self.list_context(project)
        for context in contexts:
            if context.path == str(resolved_path):
                return context.content
        return None

    def list_files(self, project: Project | None = None) -> list[str]:
        """List all file paths in the current plan's context.

        This is a convenience method that returns just the file paths
        as strings, rather than full Context objects.

        Args:
            project: The project containing the plan. If None, attempts
                    to use the current project from the ProjectService.

        Returns:
            List of file paths as strings
        """
        # If no project provided, try to get the current one
        if project is None:
            # Import here to avoid circular dependency
            from cleveragents.application.container import get_container

            try:
                container = get_container()
                project_service = container.project_service()
                project = project_service.get_current_project()

                # If still no project, return empty list
                if project is None:
                    return []
            except Exception:
                # In case of any errors getting project, return empty list
                return []

        contexts = self.list_context(project)
        return [context.path for context in contexts]

    def _get_current_plan(self, project: Project) -> Plan | None:
        """Fetch the current plan for metadata enrichment."""

        if not project.id:
            return None
        with self.unit_of_work.transaction() as ctx:
            return ctx.plans.get_current_for_project(project.id)

    def _build_langsmith_config(
        self,
        project: Project,
        *,
        run_name: str,
        file_paths: list[str],
        mode: str,
    ) -> dict[str, Any]:
        """Construct metadata for LangSmith tracing if enabled."""

        if not getattr(self.settings, "is_langsmith_enabled", False):
            return {}

        plan = self._get_current_plan(project)
        metadata = {
            "project_id": project.id,
            "project_name": project.name,
            "plan_id": getattr(plan, "id", None),
            "plan_name": getattr(plan, "name", None),
            "context_file_count": len(file_paths),
            "service": "ContextService",
            "mode": mode,
        }
        tags = [
            "context-analysis",
            "service:context",
            f"mode:{mode}",
        ]
        if project.id is not None:
            tags.append(f"project:{project.id}")
        return (
            self.settings.build_langsmith_config(
                tags=tags,
                metadata=metadata,
                run_name=run_name,
            )
            or {}
        )

    def _prepare_analysis_config(
        self,
        project: Project,
        *,
        run_name: str,
        file_paths: list[str],
        mode: str,
    ) -> dict[str, Any]:
        config = self._build_langsmith_config(
            project,
            run_name=run_name,
            file_paths=file_paths,
            mode=mode,
        )
        config.setdefault("configurable", {})
        config["configurable"]["thread_id"] = f"context-analysis-{uuid.uuid4()}"
        return config

    def _vector_store_available(self) -> bool:
        service = self._vector_store_service
        return bool(service and service.is_enabled())

    def _refresh_vector_index(self, plan_id: int) -> None:
        service = self._vector_store_service
        if not plan_id or not self._vector_store_available() or not service:
            return
        try:
            service.refresh_for_plan(plan_id)
        except ConfigurationError as exc:
            logger.warning(
                "vector-store-refresh-failed",
                plan_id=plan_id,
                error=str(exc),
            )
        except Exception as exc:  # pragma: no cover - defensive
            logger.warning(
                "vector-store-refresh-error",
                plan_id=plan_id,
                error=str(exc),
            )

    # --- LangGraph-based Context Analysis Methods ---

    def _get_context_agent(
        self, llm: BaseLanguageModel | None = None
    ) -> ContextAnalysisAgent:
        """Get or create a ContextAnalysisAgent instance.

        Args:
            llm: Optional language model to use. If None, uses default mock.

        Returns:
            ContextAnalysisAgent instance for analyzing context.
        """
        # Import here to avoid circular dependency and allow lazy loading
        from cleveragents.agents.context_analysis import ContextAnalysisAgent

        return ContextAnalysisAgent(llm=llm)

    def analyze_context(
        self,
        project: Project,
        llm: BaseLanguageModel | None = None,
    ) -> ContextAnalysisState:
        """Analyze the current plan's context using LangGraph workflow.

        Uses the ContextAnalysisAgent to perform intelligent analysis of the
        context files including dependency extraction, relevance scoring,
        and summarization.

        Args:
            project: The project containing the plan
            llm: Optional language model to use for analysis

        Returns:
            ContextAnalysisState containing analysis results including:
            - documents: Loaded documents
            - dependencies: Extracted dependencies per file
            - relevance_scores: Relevance scores for each file
            - chunks: Chunked documents for large files
            - summary: High-level summary of the context
            - error: Error message if any

        Raises:
            PlanError: If no current plan exists
        """
        # Get file paths from current context
        file_paths = self.list_files(project)

        if not file_paths:
            # Return empty analysis for empty context
            from cleveragents.agents.context_analysis import ContextAnalysisState

            return ContextAnalysisState(
                file_paths=[],
                documents=[],
                dependencies={},
                summary="No context files to analyze",
                relevance_scores={},
                chunks=[],
                error=None,
            )

        # Create agent and run analysis
        agent = self._get_context_agent(llm)
        config = self._prepare_analysis_config(
            project,
            run_name="ContextService.analyze_context",
            file_paths=file_paths,
            mode="sync",
        )

        initial_state: ContextAnalysisState = {
            "file_paths": file_paths,
            "documents": [],
            "dependencies": {},
            "summary": "",
            "relevance_scores": {},
            "chunks": [],
            "error": None,
        }

        return agent.invoke(initial_state, config)

    async def analyze_context_async(
        self,
        project: Project,
        llm: BaseLanguageModel | None = None,
    ) -> ContextAnalysisState:
        """Asynchronously analyze the current plan's context.

        Async version of analyze_context for non-blocking execution.

        Args:
            project: The project containing the plan
            llm: Optional language model to use for analysis

        Returns:
            ContextAnalysisState containing analysis results
        """
        from cleveragents.agents.context_analysis import ContextAnalysisState

        file_paths = self.list_files(project)

        if not file_paths:
            return ContextAnalysisState(
                file_paths=[],
                documents=[],
                dependencies={},
                summary="No context files to analyze",
                relevance_scores={},
                chunks=[],
                error=None,
            )

        agent = self._get_context_agent(llm)
        config = self._prepare_analysis_config(
            project,
            run_name="ContextService.analyze_context_async",
            file_paths=file_paths,
            mode="async",
        )

        initial_state: ContextAnalysisState = {
            "file_paths": file_paths,
            "documents": [],
            "dependencies": {},
            "summary": "",
            "relevance_scores": {},
            "chunks": [],
            "error": None,
        }

        return await agent.ainvoke(initial_state, config)

    def analyze_context_streaming(
        self,
        project: Project,
        llm: BaseLanguageModel | None = None,
    ) -> Iterator[dict[str, Any]]:
        """Stream the context analysis workflow execution.

        Yields events as each workflow node completes, allowing for
        real-time progress tracking.

        Args:
            project: The project containing the plan
            llm: Optional language model to use for analysis

        Yields:
            Dictionary containing node execution events
        """
        file_paths = self.list_files(project)

        if not file_paths:
            yield {"type": "complete", "summary": "No context files to analyze"}
            return

        agent = self._get_context_agent(llm)
        config = self._prepare_analysis_config(
            project,
            run_name="ContextService.analyze_context_streaming",
            file_paths=file_paths,
            mode="stream",
        )

        from cleveragents.agents.graphs.context_analysis import ContextAnalysisState

        initial_state = ContextAnalysisState(
            file_paths=file_paths,
            documents=[],
            dependencies={},
            summary="",
            relevance_scores={},
            chunks=[],
            error=None,
        )

        yield from agent.stream(initial_state, config)

    async def analyze_context_streaming_async(
        self,
        project: Project,
        llm: BaseLanguageModel | None = None,
    ) -> AsyncIterator[dict[str, Any]]:
        """Asynchronously stream the context analysis workflow execution.

        Async version of analyze_context_streaming for non-blocking streaming.

        Args:
            project: The project containing the plan
            llm: Optional language model to use for analysis

        Yields:
            Dictionary containing node execution events
        """
        file_paths = self.list_files(project)

        if not file_paths:
            yield {"type": "complete", "summary": "No context files to analyze"}
            return

        agent = self._get_context_agent(llm)
        config = self._prepare_analysis_config(
            project,
            run_name="ContextService.analyze_context_streaming_async",
            file_paths=file_paths,
            mode="stream",
        )

        from cleveragents.agents.graphs.context_analysis import ContextAnalysisState

        initial_state = ContextAnalysisState(
            file_paths=file_paths,
            documents=[],
            dependencies={},
            summary="",
            relevance_scores={},
            chunks=[],
            error=None,
        )

        async for event in agent.astream(initial_state, config):
            yield event

    def get_context_summary(
        self,
        project: Project,
        llm: BaseLanguageModel | None = None,
    ) -> str:
        """Get a high-level summary of the current context.

        Convenience method that runs the full analysis and returns
        just the summary.

        Args:
            project: The project containing the plan
            llm: Optional language model to use for analysis

        Returns:
            Summary string describing the context
        """
        result = self.analyze_context(project, llm)
        return result["summary"] if result["summary"] else "No summary available"

    def get_context_dependencies(
        self,
        project: Project,
        llm: BaseLanguageModel | None = None,
    ) -> dict[str, list[str]]:
        """Get extracted dependencies for all context files.

        Convenience method that runs the full analysis and returns
        just the dependency information.

        Args:
            project: The project containing the plan
            llm: Optional language model to use for analysis

        Returns:
            Dictionary mapping file paths to their dependencies
        """
        result = self.analyze_context(project, llm)
        return result["dependencies"]

    def get_relevant_files(
        self,
        project: Project,
        threshold: float = 0.5,
        llm: BaseLanguageModel | None = None,
    ) -> list[tuple[str, float]]:
        """Get context files sorted by relevance score.

        Filters and sorts context files by their relevance scores.

        Args:
            project: The project containing the plan
            threshold: Minimum relevance score (0.0 to 1.0)
            llm: Optional language model to use for analysis

        Returns:
            List of (file_path, score) tuples sorted by score descending
        """
        result = self.analyze_context(project, llm)
        scores = result["relevance_scores"]

        filtered = [
            (path, score) for path, score in scores.items() if score >= threshold
        ]
        return sorted(filtered, key=lambda x: x[1], reverse=True)

    def search_context(
        self,
        project: Project,
        query: str,
        *,
        limit: int = 5,
        refresh_if_missing: bool = True,
    ) -> list[dict[str, Any]]:
        """Run a semantic search across indexed context documents."""

        trimmed = query.strip()
        if not trimmed:
            return []

        plan = self._get_current_plan(project)
        if not plan or not plan.id:
            return []

        if not self._vector_store_available():
            return []

        service = self._vector_store_service
        if service is None:
            return []

        try:
            results = service.search(
                plan.id,
                trimmed,
                top_k=max(1, limit),
                refresh_if_missing=refresh_if_missing,
            )
            # Emit CONTEXT_QUERY_EXECUTED event
            if self._event_bus is not None:
                try:
                    self._event_bus.emit(
                        DomainEvent(
                            event_type=EventType.CONTEXT_QUERY_EXECUTED,
                            details={
                                "query": trimmed[:200],
                                "results_count": len(results),
                                "plan_id": plan.id,
                            },
                        )
                    )
                except Exception:
                    logger.warning(
                        "event_bus_emit_failed",
                        event_type="CONTEXT_QUERY_EXECUTED",
                    )
            return results
        except ConfigurationError as exc:
            logger.warning(
                "vector-store-search-failed",
                plan_id=plan.id,
                error=str(exc),
            )
        except Exception as exc:  # pragma: no cover - defensive
            logger.warning(
                "vector-store-search-error",
                plan_id=plan.id,
                error=str(exc),
            )
        return []
