"""Validation and error handling for semantic layer."""

from functools import lru_cache
from typing import TYPE_CHECKING, Literal, get_args, get_origin

from sidemantic.sql.aggregation_detection import sql_has_aggregate

if TYPE_CHECKING:
    from sidemantic.core.metric import Metric
    from sidemantic.core.model import Model
    from sidemantic.core.semantic_graph import SemanticGraph


class ValidationError(Exception):
    """Raised when semantic layer validation fails."""

    pass


class QueryValidationError(ValidationError):
    """Raised when query validation fails."""

    pass


class MetricValidationError(ValidationError):
    """Raised when metric validation fails."""

    pass


class ModelValidationError(ValidationError):
    """Raised when model validation fails."""

    pass


def _extract_literal_strings(annotation) -> set[str]:
    if get_origin(annotation) is Literal:
        return {value for value in get_args(annotation) if isinstance(value, str)}

    values = set()
    for arg in get_args(annotation):
        values.update(_extract_literal_strings(arg))
    return values


@lru_cache(maxsize=1)
def _valid_measure_aggs() -> set[str]:
    from sidemantic.core.metric import Metric

    annotation = Metric.model_fields["agg"].annotation
    return _extract_literal_strings(annotation)


def validate_model(model: "Model") -> list[str]:
    """Validate a model definition.

    Args:
        model: Model to validate

    Returns:
        List of validation errors (empty if valid)
    """
    errors = []

    # Check for primary_key
    if not model.primary_key:
        errors.append(f"Model '{model.name}' must have a primary_key defined")

    # Check for a physical, SQL, or externally sourced model definition
    if not model.table and not model.sql and not getattr(model, "source_uri", None):
        errors.append(f"Model '{model.name}' must have one of 'table', 'sql', or 'source_uri' defined")

    for label, items in [
        ("dimension", model.dimensions),
        ("metric", model.metrics),
        ("segment", model.segments),
        ("pre-aggregation", model.pre_aggregations),
    ]:
        seen_names = set()
        for item in items:
            if item.name in seen_names:
                errors.append(f"Model '{model.name}' has duplicate {label} '{item.name}'")
            seen_names.add(item.name)

    if model.default_time_dimension:
        default_time_dimension = model.get_dimension(model.default_time_dimension)
        if default_time_dimension is None:
            errors.append(
                f"Model '{model.name}' default_time_dimension "
                f"'{model.default_time_dimension}' does not reference a dimension"
            )
        elif default_time_dimension.type != "time":
            errors.append(
                f"Model '{model.name}' default_time_dimension "
                f"'{model.default_time_dimension}' must reference a time dimension"
            )

    # Check that dimensions have valid types
    for dim in model.dimensions:
        if dim.type not in ["categorical", "time", "boolean", "numeric"]:
            errors.append(
                f"Model '{model.name}': dimension '{dim.name}' has invalid type '{dim.type}'. "
                f"Must be one of: categorical, time, boolean, numeric"
            )

        # Time dimensions should have granularity
        if dim.type == "time" and not dim.granularity:
            errors.append(f"Model '{model.name}': time dimension '{dim.name}' should have a granularity defined")

    # Check that measures have valid aggregation types
    # Derived, ratio, cumulative, time_comparison, and conversion metrics don't need agg
    for measure in model.metrics:
        # Skip validation for complex metric types that don't use agg
        if measure.type in ["derived", "ratio", "cumulative", "time_comparison", "conversion", "retention", "cohort"]:
            continue

        valid_aggs = _valid_measure_aggs()

        if measure.agg in valid_aggs:
            continue
        if measure.agg is None and measure.sql and sql_has_aggregate(measure.sql):
            continue
        if measure.agg not in valid_aggs:
            valid_aggs_str = ", ".join(sorted(valid_aggs))
            errors.append(
                f"Model '{model.name}': measure '{measure.name}' has invalid aggregation '{measure.agg}'. "
                f"Must be one of: {valid_aggs_str}"
            )

    for preagg in model.pre_aggregations:
        for measure_name in preagg.measures or []:
            if model.get_metric(measure_name) is None:
                errors.append(
                    f"Pre-aggregation '{model.name}.{preagg.name}' references unknown measure '{measure_name}'"
                )
        for dimension_name in preagg.dimensions or []:
            if model.get_dimension(dimension_name) is None:
                errors.append(
                    f"Pre-aggregation '{model.name}.{preagg.name}' references unknown dimension '{dimension_name}'"
                )
        if preagg.time_dimension:
            time_dimension = model.get_dimension(preagg.time_dimension)
            if time_dimension is None:
                errors.append(
                    f"Pre-aggregation '{model.name}.{preagg.name}' references unknown time_dimension "
                    f"'{preagg.time_dimension}'"
                )
            elif time_dimension.type != "time":
                errors.append(
                    f"Pre-aggregation '{model.name}.{preagg.name}' time_dimension "
                    f"'{preagg.time_dimension}' must reference a time dimension"
                )

    return errors


def validate_metric(measure: "Metric", graph: "SemanticGraph") -> list[str]:
    """Validate a measure definition.

    Args:
        measure: Metric to validate
        graph: Semantic graph containing models

    Returns:
        List of validation errors (empty if valid)
    """
    errors = []

    # Check measure type
    if measure.type and measure.type not in [
        "ratio",
        "derived",
        "cumulative",
        "time_comparison",
        "conversion",
        "retention",
        "cohort",
    ]:
        errors.append(
            f"Metric '{measure.name}' has invalid type '{measure.type}'. "
            f"Must be one of: ratio, derived, cumulative, time_comparison, conversion, retention, cohort"
        )
        return errors  # Can't continue validation with invalid type

    # Validate untyped metrics with sql (measure references)
    if not measure.type and not measure.agg and measure.sql:
        # Only validate direct model.measure references here.
        # Complex SQL expressions (e.g., COUNT(model.col) / COUNT(other.col))
        # are valid untyped metrics and should not be split as plain refs.
        sql_ref = measure.sql.strip()
        is_direct_ref = (
            "." in sql_ref and " " not in sql_ref and not any(op in sql_ref for op in ["+", "-", "*", "/", "(", ")"])
        )
        if is_direct_ref:
            model_name, measure_name = sql_ref.split(".", 1)
            model = graph.models.get(model_name)
            if not model:
                errors.append(f"Metric '{measure.name}': model '{model_name}' not found")
            elif not model.get_metric(measure_name):
                errors.append(f"Metric '{measure.name}': measure '{measure_name}' not found in model '{model_name}'")

    # Validate based on type
    if measure.type == "ratio":
        if not measure.numerator:
            errors.append(f"Ratio measure '{measure.name}' must have 'numerator' defined")
        if not measure.denominator:
            errors.append(f"Ratio measure '{measure.name}' must have 'denominator' defined")

        # Validate references
        for ref_type, ref in [
            ("numerator", measure.numerator),
            ("denominator", measure.denominator),
        ]:
            if ref and "." in ref and ref not in graph.metrics:
                model_name, measure_name = ref.split(".", 1)
                model = graph.models.get(model_name)
                if not model:
                    errors.append(f"Ratio measure '{measure.name}': {ref_type} model '{model_name}' not found")
                elif not model.get_metric(measure_name):
                    errors.append(
                        f"Ratio measure '{measure.name}': {ref_type} measure '{measure_name}' not found in model '{model_name}'"
                    )

    elif measure.type == "derived":
        if not measure.sql:
            errors.append(f"Derived measure '{measure.name}' must have 'expr' defined")

        # Auto-detect dependencies and check for circular references
        dependencies = measure.get_dependencies(graph)

        # Check for self-reference first
        if measure.name in dependencies:
            errors.append(f"Derived measure '{measure.name}' cannot reference itself")
        else:
            circular_deps = _check_circular_dependencies(measure, graph, set())
            if circular_deps:
                errors.append(f"Derived measure '{measure.name}' has circular dependency: {' -> '.join(circular_deps)}")

    elif measure.type == "cumulative":
        if not measure.sql and not measure.window_expression:
            errors.append(f"Cumulative measure '{measure.name}' must have 'sql' or 'window_expression' defined")

    elif measure.type == "retention":
        if not measure.entity:
            errors.append(f"Retention measure '{measure.name}' must have 'entity' defined")
        if not measure.cohort_event:
            errors.append(f"Retention measure '{measure.name}' must have 'cohort_event' defined")

    return errors


def _check_circular_dependencies(
    measure: "Metric", graph: "SemanticGraph", visited: set[str], path: list[str] | None = None
) -> list[str] | None:
    """Check for circular dependencies in derived measures.

    Args:
        measure: Metric to check
        graph: Semantic graph
        visited: Set of visited measure names
        path: Current dependency path

    Returns:
        List of measure names in circular path, or None if no cycle
    """
    if path is None:
        path = []

    if measure.name in visited:
        # Found a cycle
        cycle_start = path.index(measure.name)
        return path[cycle_start:] + [measure.name]

    if measure.type != "derived":
        return None

    visited.add(measure.name)
    path.append(measure.name)

    dependencies = measure.get_dependencies(graph)
    for dep_name in dependencies:
        try:
            dep_measure = graph.get_metric(dep_name)
            if dep_measure:
                cycle = _check_circular_dependencies(dep_measure, graph, visited.copy(), path.copy())
                if cycle:
                    return cycle
        except KeyError:
            # Dependency doesn't exist yet, skip circular check
            pass

    return None


def validate_query(metrics: list[str], dimensions: list[str], graph: "SemanticGraph") -> list[str]:
    """Validate a query before execution.

    Args:
        metrics: List of metric references
        dimensions: List of dimension references
        graph: Semantic graph

    Returns:
        List of validation errors (empty if valid)
    """
    errors = []

    # Validate metric references
    for metric_ref in metrics:
        try:
            graph.resolve_metric_reference(metric_ref)
            continue
        except KeyError:
            pass

        if "." in metric_ref:
            # Direct measure reference
            model_name, measure_name = metric_ref.split(".", 1)
            model = graph.models.get(model_name)
            if not model:
                errors.append(f"Model '{model_name}' not found (referenced in '{metric_ref}')")
            elif not model.get_metric(measure_name):
                errors.append(
                    f"Metric '{measure_name}' not found in model '{model_name}' (referenced in '{metric_ref}')"
                )
        else:
            # Metric reference
            try:
                graph.get_metric(metric_ref)
            except KeyError:
                errors.append(f"Metric '{metric_ref}' not found")

    # Validate dimension references
    for dim_ref in dimensions:
        # Handle granularity suffix
        if "__" in dim_ref:
            dim_ref_base, granularity = dim_ref.rsplit("__", 1)
            # Validate granularity
            if granularity not in ["second", "minute", "hour", "day", "week", "month", "quarter", "year"]:
                errors.append(
                    f"Invalid time granularity '{granularity}' in '{dim_ref}'. "
                    f"Must be one of: second, minute, hour, day, week, month, quarter, year"
                )
            dim_ref = dim_ref_base

        if "." in dim_ref:
            model_name, dim_name = dim_ref.split(".", 1)
            model = graph.models.get(model_name)
            if not model:
                errors.append(f"Model '{model_name}' not found (referenced in '{dim_ref}')")
            elif not model.get_dimension(dim_name):
                errors.append(f"Dimension '{dim_name}' not found in model '{model_name}' (referenced in '{dim_ref}')")
        else:
            errors.append(f"Dimension reference '{dim_ref}' must be in 'model.dimension' format")

    # Check for join paths
    model_names = set()
    for metric_ref in metrics:
        try:
            metric_model_name, measure = graph.resolve_metric_reference(metric_ref)
        except KeyError:
            continue  # Already reported as error above
        if metric_model_name:
            model_names.add(metric_model_name)
        elif measure and measure.sql and "." in measure.sql:
            model_names.add(measure.sql.split(".", 1)[0])

    for dim_ref in dimensions:
        if "__" in dim_ref:
            dim_ref = dim_ref.rsplit("__", 1)[0]
        if "." in dim_ref:
            model_names.add(dim_ref.split(".", 1)[0])

    # Check that all model pairs can be joined
    # Only check models that exist in the graph (errors for missing models already reported above)
    valid_model_names = [m for m in model_names if m in graph.models]
    model_list = list(valid_model_names)
    for i, model_a in enumerate(model_list):
        for model_b in model_list[i + 1 :]:
            try:
                graph.find_relationship_path(model_a, model_b)
            except (ValueError, KeyError):
                # Catch both ValueError (no path) and KeyError (model doesn't exist)
                errors.append(
                    f"No join path found between models '{model_a}' and '{model_b}'. "
                    f"Add relationships to enable joining these models."
                )

    return errors
