"""Semantic graph for managing models and relationships."""

from collections import deque
from dataclasses import dataclass
from typing import Any

from sidemantic.core.metric import Metric
from sidemantic.core.model import Model
from sidemantic.core.parameter import Parameter
from sidemantic.core.table_calculation import TableCalculation


def _reverse_custom_join_condition(sql: str | None) -> str | None:
    if sql is None:
        return None
    return sql.replace("{from}", "__SIDEMANTIC_FROM__").replace("{to}", "{from}").replace("__SIDEMANTIC_FROM__", "{to}")


def _custom_join_condition(sql: str | None) -> str | None:
    """Return custom join SQL only for the placeholder-based native contract."""
    if not sql:
        return None
    if "{from}" in sql or "{to}" in sql:
        return sql
    return None


@dataclass
class JoinPath:
    """Represents a join between two models."""

    from_model: str
    to_model: str
    from_columns: list[str]  # Foreign key column(s) in from_model
    to_columns: list[str]  # Primary/unique key column(s) in to_model
    relationship: str  # many_to_one, one_to_many, one_to_one
    custom_condition: str | None = None

    # Backwards compatibility properties (return first column)
    @property
    def from_entity(self) -> str:
        """Get first foreign key column (backwards compatibility)."""
        return self.from_columns[0] if self.from_columns else ""

    @property
    def to_entity(self) -> str:
        """Get first primary key column (backwards compatibility)."""
        return self.to_columns[0] if self.to_columns else ""


class SemanticGraph:
    """Semantic graph managing models, metrics, and join relationships.

    The graph uses entities as edges to automatically discover join paths
    between models.
    """

    def __init__(self):
        self.models: dict[str, Model] = {}
        self.metrics: dict[str, Metric] = {}
        self.table_calculations: dict[str, TableCalculation] = {}
        self.parameters: dict[str, Parameter] = {}
        self.metadata: dict[str, Any] = {}
        self._version = 0
        self._adjacency_dirty = True
        self._adjacency: dict[str, list[tuple[str, list[str], list[str], str, str | None]]] = {}

    def _mark_dirty(self) -> None:
        self._version += 1
        self._adjacency_dirty = True

    def add_model(self, model: Model) -> None:
        """Add a model to the graph.

        Args:
            model: Model to add
        """
        if model.name in self.models:
            raise ValueError(f"Model {model.name} already exists")

        self.models[model.name] = model

        # Auto-register graph-level metrics from model
        # Graph-level metric types: time_comparison, conversion
        # These need to be accessible without model prefix
        if model.metrics:
            for metric in model.metrics:
                if metric.type in ("time_comparison", "conversion"):
                    # Register at graph level if not already there
                    if metric.name not in self.metrics:
                        self.metrics[metric.name] = metric

        self._mark_dirty()

    def add_metric(self, measure: Metric) -> None:
        """Add a measure to the graph.

        Args:
            measure: Metric to add
        """
        if measure.name in self.metrics:
            raise ValueError(f"Measure {measure.name} already exists")

        self.metrics[measure.name] = measure
        self._mark_dirty()

    def add_table_calculation(self, calc: TableCalculation) -> None:
        """Add a table calculation to the graph.

        Args:
            calc: Table calculation to add
        """
        if calc.name in self.table_calculations:
            raise ValueError(f"Table calculation {calc.name} already exists")

        self.table_calculations[calc.name] = calc
        self._mark_dirty()

    def get_table_calculation(self, name: str) -> TableCalculation:
        """Get a table calculation by name.

        Args:
            name: Table calculation name

        Returns:
            Table calculation object

        Raises:
            KeyError: If table calculation doesn't exist
        """
        if name not in self.table_calculations:
            raise KeyError(f"Table calculation {name} not found")

        return self.table_calculations[name]

    def add_parameter(self, param: Parameter) -> None:
        """Add a parameter to the graph.

        Args:
            param: Parameter to add

        Raises:
            ValueError: If parameter already exists
        """
        if param.name in self.parameters:
            raise ValueError(f"Parameter {param.name} already exists")

        self.parameters[param.name] = param
        self._mark_dirty()

    def get_parameter(self, name: str) -> Parameter:
        """Get a parameter by name.

        Args:
            name: Parameter name

        Returns:
            Parameter object

        Raises:
            KeyError: If parameter doesn't exist
        """
        if name not in self.parameters:
            raise KeyError(f"Parameter {name} not found")

        return self.parameters[name]

    def _add_metric_impl(self, measure: Metric) -> None:
        """Internal method to add a measure without checks (for legacy compatibility).

        Args:
            measure: Metric to add
        """
        if measure.name in self.metrics:
            raise ValueError(f"Measure {measure.name} already exists")

        self.metrics[measure.name] = measure

    def get_model(self, name: str) -> Model:
        """Get model by name.

        Args:
            name: Model name

        Returns:
            Model instance

        Raises:
            KeyError: If model not found
        """
        if name not in self.models:
            raise KeyError(f"Model {name} not found")
        return self.models[name]

    def get_metric(self, name: str) -> Metric:
        """Get measure by name.

        Args:
            name: Metric name

        Returns:
            Measure instance

        Raises:
            KeyError: If measure not found
        """
        if name not in self.metrics:
            raise KeyError(f"Measure {name} not found")
        return self.metrics[name]

    def resolve_metric_reference(self, reference: str) -> tuple[str | None, Metric]:
        """Resolve a query metric reference using exact graph metric names first.

        Returns:
            Tuple of (model_name, metric). model_name is None for graph-level metrics.

        Raises:
            KeyError: If the reference does not resolve to a graph or model metric.
        """
        if reference in self.metrics:
            return None, self.metrics[reference]

        if "." in reference:
            model_name, metric_name = reference.split(".", 1)
            model = self.models.get(model_name)
            if model:
                metric = model.get_metric(metric_name)
                if metric:
                    return model_name, metric

        raise KeyError(f"Metric reference {reference} not found")

    def build_adjacency(self) -> None:
        """Build adjacency list for join path discovery.

        Creates edges between models using join relationships.

        This is automatically called when models are added, but can be called
        manually if relationships are modified after models are registered.
        """
        if not hasattr(self, "_adjacency"):
            self._adjacency = {}
        self._adjacency.clear()

        def add_edge(
            from_model: str,
            to_model: str,
            from_keys: list[str],
            to_keys: list[str],
            relationship_type: str,
            custom_condition: str | None = None,
        ) -> None:
            if from_model not in self._adjacency:
                self._adjacency[from_model] = []
            self._adjacency[from_model].append((to_model, from_keys, to_keys, relationship_type, custom_condition))

        def invert_relationship(relationship_type: str) -> str:
            if relationship_type == "many_to_one":
                return "one_to_many"
            if relationship_type == "one_to_many":
                return "many_to_one"
            return relationship_type

        # Build adjacency from join relationships
        for model_name, model in self.models.items():
            for relationship in model.relationships:
                related_model = relationship.name
                if related_model not in self.models:
                    continue  # Skip if related model doesn't exist yet

                if relationship.type == "cross":
                    add_edge(model_name, related_model, [], [], "cross")
                    add_edge(related_model, model_name, [], [], "cross")
                    continue

                if relationship.type == "many_to_many":
                    junction_model = relationship.through
                    if not junction_model or junction_model not in self.models:
                        if not relationship.foreign_key:
                            continue
                        local_keys = model.primary_key_columns
                        remote_keys = relationship.foreign_key_columns
                        custom_condition = _custom_join_condition(relationship.sql)
                        add_edge(model_name, related_model, local_keys, remote_keys, "one_to_many", custom_condition)
                        add_edge(
                            related_model,
                            model_name,
                            remote_keys,
                            local_keys,
                            "many_to_one",
                            _reverse_custom_join_condition(custom_condition),
                        )
                        continue

                    junction_self_fks, junction_related_fks = relationship.junction_key_columns()
                    if not junction_self_fks or not junction_related_fks:
                        continue

                    base_pk = model.primary_key_columns
                    related_pk = (
                        relationship.primary_key_columns
                        if relationship.primary_key
                        else self.models[related_model].primary_key_columns
                    )

                    add_edge(model_name, junction_model, base_pk, junction_self_fks, "one_to_many")
                    add_edge(junction_model, model_name, junction_self_fks, base_pk, "many_to_one")

                    add_edge(junction_model, related_model, junction_related_fks, related_pk, "many_to_one")
                    add_edge(related_model, junction_model, related_pk, junction_related_fks, "one_to_many")
                    continue

                # Get the join key names
                if relationship.type == "many_to_one":
                    # This model has foreign key pointing to related model
                    # Example: orders many_to_one customers (orders.customer_id -> customers.id)
                    local_keys = relationship.foreign_key_columns  # [customer_id] (in orders)
                    remote_keys = (
                        relationship.primary_key_columns
                        if relationship.primary_key
                        else self.models[related_model].primary_key_columns
                    )  # Use related model's primary key
                else:
                    # one_to_one or one_to_many: related model has foreign key pointing here
                    # Example: customers one_to_many orders (customers.id <- orders.customer_id)
                    local_keys = (
                        relationship.primary_key_columns if relationship.primary_key else model.primary_key_columns
                    )
                    remote_keys = relationship.foreign_key_columns  # [customer_id] (in orders)

                custom_condition = _custom_join_condition(relationship.sql)
                add_edge(model_name, related_model, local_keys, remote_keys, relationship.type, custom_condition)
                add_edge(
                    related_model,
                    model_name,
                    remote_keys,
                    local_keys,
                    invert_relationship(relationship.type),
                    _reverse_custom_join_condition(custom_condition),
                )

    def find_relationship_path(self, from_model: str, to_model: str) -> list[JoinPath]:
        """Find join path between two models using BFS.

        Args:
            from_model: Source model name
            to_model: Target model name

        Returns:
            List of JoinPath objects representing the join sequence

        Raises:
            ValueError: If no join path exists
        """
        if from_model == to_model:
            return []

        if getattr(self, "_adjacency_dirty", True):
            self.build_adjacency()
            self._adjacency_dirty = False

        if from_model not in self.models:
            raise KeyError(f"Model {from_model} not found")
        if to_model not in self.models:
            raise KeyError(f"Model {to_model} not found")

        # BFS to find shortest path
        queue = deque([(from_model, [])])
        visited = {from_model}

        while queue:
            current, path = queue.popleft()

            if current not in self._adjacency:
                continue

            for next_model, from_keys, to_keys, relationship_type, custom_condition in self._adjacency[current]:
                if next_model in visited:
                    continue

                visited.add(next_model)

                new_path = path + [
                    JoinPath(
                        from_model=current,
                        to_model=next_model,
                        from_columns=from_keys,
                        to_columns=to_keys,
                        relationship=relationship_type,
                        custom_condition=custom_condition,
                    )
                ]

                if next_model == to_model:
                    return new_path

                queue.append((next_model, new_path))

        raise ValueError(f"No join path found between {from_model} and {to_model}")

    def find_all_models_for_query(self, dimensions: list[str], measures: list[str]) -> set[str]:
        """Find all models needed for a query.

        Args:
            dimensions: List of dimension references (model.dimension)
            measures: List of measure references (model.measure)

        Returns:
            Set of model names needed for the query
        """
        models = set()

        for dim in dimensions:
            if "." in dim:
                model_name = dim.split(".")[0]
                models.add(model_name)

        for measure in measures:
            if "." in measure:
                model_name = measure.split(".")[0]
                models.add(model_name)

        return models
