from __future__ import annotations

import hashlib

from auraindex.models import IndexSuggestion, WorkloadStats
from auraindex.utils.text import dedupe_preserve_order, sanitize_identifier

SYSTEM_TABLE_PREFIXES = ("pg_", "sqlite_", "information_schema")
SYSTEM_TABLE_NAMES = {"_unknown"}


def suggest_indexes(
    workload: WorkloadStats,
    dialect: str = "postgres",
    min_support: int = 2,
    max_indexes_per_table: int = 4,
    cardinality_hints: dict[str, str] | None = None,
    enable_impact_scoring: bool = True,
    enable_index_type_hints: bool = True,
) -> list[IndexSuggestion]:
    candidate_map: dict[tuple[str, tuple[str, ...]], IndexSuggestion] = {}
    hints = cardinality_hints or {}

    for table, columns in workload.column_usage.items():
        for column, usage in columns.items():
            hint = _lookup_hint(hints, table, column)

            if usage.join_count >= min_support:
                score = usage.join_count * 4.0 + usage.where_count * 1.5 + usage.order_count
                confidence = _clamp(0.80 + min(0.15, usage.join_count * 0.03), 0.55, 0.98)
                _add_candidate(
                    candidate_map,
                    table=table,
                    columns=[column],
                    kind="join_key",
                    score=score,
                    confidence=confidence,
                    reason=f"Column `{column}` appears in JOIN predicates ({usage.join_count} occurrences).",
                    evidence={
                        "support_count": usage.join_count,
                        "where_count": usage.where_count,
                        "join_count": usage.join_count,
                        "order_count": usage.order_count,
                        "group_count": usage.group_count,
                    },
                    dialect=dialect,
                )

            if usage.where_count >= min_support:
                if hint == "low" and usage.join_count < min_support:
                    continue
                if hint == "unknown" and _is_low_selectivity_column(column) and usage.where_count < max(min_support + 2, 4):
                    continue
                score = usage.where_count * 3.0 + usage.join_count * 1.5 + usage.order_count
                confidence = _clamp(0.74 + min(0.18, usage.where_count * 0.03), 0.50, 0.98)
                if hint == "high":
                    confidence = _clamp(confidence + 0.05, 0.50, 0.98)
                if hint == "low":
                    confidence = _clamp(confidence - 0.10, 0.50, 0.98)
                _add_candidate(
                    candidate_map,
                    table=table,
                    columns=[column],
                    kind="filter_key",
                    score=score,
                    confidence=confidence,
                    reason=f"Column `{column}` is used in WHERE predicates ({usage.where_count} occurrences).",
                    evidence={
                        "support_count": usage.where_count,
                        "where_count": usage.where_count,
                        "join_count": usage.join_count,
                        "order_count": usage.order_count,
                        "group_count": usage.group_count,
                    },
                    dialect=dialect,
                )

    for table, combos in workload.composite_filters.items():
        for combo, count in combos.items():
            if count < min_support:
                continue
            score = count * 5.0 + len(combo)
            confidence = _clamp(0.84 + min(0.12, count * 0.03), 0.60, 0.98)
            _add_candidate(
                candidate_map,
                table=table,
                columns=list(combo),
                kind="composite_filter",
                score=score,
                confidence=confidence,
                reason=(
                    f"Composite predicate {list(combo)} appears in WHERE clauses "
                    f"{count} times."
                ),
                evidence={"support_count": count, "composite_count": count},
                dialect=dialect,
            )

    for table, patterns in workload.where_order_patterns.items():
        for (where_cols, order_cols), count in patterns.items():
            if count < min_support:
                continue
            extended = list(where_cols)
            for order_col in order_cols:
                if order_col not in extended:
                    extended.append(order_col)
                    break
            if len(extended) == len(where_cols):
                continue
            score = count * 4.5 + len(extended)
            confidence = _clamp(0.82 + min(0.12, count * 0.02), 0.60, 0.98)
            _add_candidate(
                candidate_map,
                table=table,
                columns=extended[:3],
                kind="composite_order",
                score=score,
                confidence=confidence,
                reason=(
                    "WHERE + ORDER BY pattern suggests left-prefix composite index: "
                    f"{list(where_cols)} + {list(order_cols)} (support={count})."
                ),
                evidence={"support_count": count, "pattern_count": count},
                dialect=dialect,
            )

    if enable_impact_scoring:
        _apply_impact_scoring(candidate_map, workload)
    if enable_index_type_hints:
        _apply_index_type_hints(candidate_map, workload, dialect=dialect)

    return _prune_and_cap(candidate_map, max_indexes_per_table=max_indexes_per_table)


def _add_candidate(
    candidate_map: dict[tuple[str, tuple[str, ...]], IndexSuggestion],
    *,
    table: str,
    columns: list[str],
    kind: str,
    score: float,
    confidence: float,
    reason: str,
    evidence: dict[str, int],
    dialect: str,
) -> None:
    clean_table = sanitize_identifier(table)
    clean_columns = tuple(
        dedupe_preserve_order([sanitize_identifier(col) for col in columns if sanitize_identifier(col)])
    )

    if not clean_table or not clean_columns:
        return
    if _is_excluded_table(clean_table):
        return
    if len(clean_columns) == 1 and clean_columns[0] in {"id", "pk"}:
        return

    key = (clean_table, clean_columns)
    index_name = _build_index_name(clean_table, list(clean_columns), dialect)
    ddl = _build_ddl(clean_table, list(clean_columns), index_name, dialect)
    orm_snippets = _build_orm_snippets(clean_table, list(clean_columns), index_name)
    needs_manual_review = confidence < 0.90 or len(clean_columns) > 2

    suggestion = IndexSuggestion(
        table=clean_table,
        columns=list(clean_columns),
        index_name=index_name,
        kind=kind,
        score=score,
        confidence=round(confidence, 3),
        reason=reason,
        evidence=evidence,
        ddl=ddl,
        needs_manual_review=needs_manual_review,
        orm_snippets=orm_snippets,
    )

    existing = candidate_map.get(key)
    if existing is None:
        candidate_map[key] = suggestion
        return

    if suggestion.score > existing.score:
        merged_reason = f"{existing.reason} | {suggestion.reason}"
        suggestion.reason = merged_reason
        suggestion.evidence = _merge_evidence(existing.evidence, suggestion.evidence)
        candidate_map[key] = suggestion
    else:
        existing.reason = f"{existing.reason} | {suggestion.reason}"
        existing.confidence = max(existing.confidence, suggestion.confidence)
        existing.needs_manual_review = existing.needs_manual_review or suggestion.needs_manual_review
        existing.score = max(existing.score, suggestion.score)
        existing.evidence = _merge_evidence(existing.evidence, suggestion.evidence)


def _prune_and_cap(
    candidate_map: dict[tuple[str, tuple[str, ...]], IndexSuggestion],
    *,
    max_indexes_per_table: int,
) -> list[IndexSuggestion]:
    grouped: dict[str, list[IndexSuggestion]] = {}
    for suggestion in candidate_map.values():
        grouped.setdefault(suggestion.table, []).append(suggestion)

    final: list[IndexSuggestion] = []
    for table, suggestions in grouped.items():
        accepted: list[IndexSuggestion] = []
        ranked = sorted(suggestions, key=lambda item: (item.score, item.confidence), reverse=True)

        for suggestion in ranked:
            if _is_redundant_by_existing(suggestion, accepted):
                continue
            accepted.append(suggestion)
            if len(accepted) >= max_indexes_per_table:
                break

        accepted.sort(key=lambda item: item.score, reverse=True)
        final.extend(accepted)

    final.sort(key=lambda item: (item.table, -item.score, item.index_name))
    return final


def _is_redundant_by_existing(candidate: IndexSuggestion, accepted: list[IndexSuggestion]) -> bool:
    for existing in accepted:
        if candidate.columns == existing.columns:
            return True

        if _is_prefix(candidate.columns, existing.columns):
            if existing.score >= candidate.score * 0.95:
                return True

        if _is_prefix(existing.columns, candidate.columns) and len(existing.columns) > 1:
            if existing.score >= candidate.score:
                return True

    if len(candidate.columns) != 1:
        return False
    column = candidate.columns[0]
    for existing in accepted:
        if len(existing.columns) <= 1:
            continue
        if existing.columns[0] == column and existing.score >= candidate.score:
            return True
    return False


def _is_prefix(prefix: list[str], full: list[str]) -> bool:
    if len(prefix) > len(full):
        return False
    return prefix == full[: len(prefix)]


def _merge_evidence(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
    keys = set(left) | set(right)
    merged: dict[str, int] = {}
    for key in keys:
        merged[key] = max(int(left.get(key, 0)), int(right.get(key, 0)))
    return merged


def _apply_impact_scoring(
    candidate_map: dict[tuple[str, tuple[str, ...]], IndexSuggestion],
    workload: WorkloadStats,
) -> None:
    for suggestion in candidate_map.values():
        table_usage = workload.column_usage.get(suggestion.table, {})
        read_queries = workload.table_read_count.get(suggestion.table, 0)
        write_queries = workload.table_write_count.get(suggestion.table, 0)
        total_queries = max(1, read_queries + write_queries)

        usage_signal = 0.0
        for column in suggestion.columns:
            usage = table_usage.get(column)
            if usage is None:
                continue
            usage_signal += usage.where_count * 3.0
            usage_signal += usage.join_count * 4.0
            usage_signal += usage.order_count * 2.0
            usage_signal += usage.group_count * 1.5

        support_hint = max((int(value) for value in suggestion.evidence.values()), default=0)
        read_benefit = usage_signal + min(8.0, support_hint * 0.9)

        write_ratio = write_queries / total_queries
        width_factor = 1.0 + max(0, len(suggestion.columns) - 1) * 0.55
        write_cost = width_factor * (1.8 + (write_ratio * 6.0) + min(4.0, write_queries * 0.05))

        net_impact = read_benefit - write_cost
        score_adjustment = _clamp(net_impact * 0.18, -3.0, 5.0)
        suggestion.score = round(suggestion.score + score_adjustment, 3)

        if net_impact >= 6.5:
            impact_level = "high"
        elif net_impact >= 2.5:
            impact_level = "medium"
        else:
            impact_level = "low"

        suggestion.metadata["impact"] = {
            "read_benefit": round(read_benefit, 3),
            "write_cost": round(write_cost, 3),
            "net_impact": round(net_impact, 3),
            "score_adjustment": round(score_adjustment, 3),
            "impact_level": impact_level,
            "read_queries": read_queries,
            "write_queries": write_queries,
            "write_ratio": round(write_ratio, 3),
        }


def _apply_index_type_hints(
    candidate_map: dict[tuple[str, tuple[str, ...]], IndexSuggestion],
    workload: WorkloadStats,
    *,
    dialect: str,
) -> None:
    for suggestion in candidate_map.values():
        table_usage = workload.column_usage.get(suggestion.table, {})
        read_queries = workload.table_read_count.get(suggestion.table, 0)
        write_queries = workload.table_write_count.get(suggestion.table, 0)
        write_ratio = write_queries / max(1, read_queries + write_queries)
        lowered_cols = [column.lower() for column in suggestion.columns]

        recommended = "btree"
        reason = "General-purpose default for equality, range, and ordering workloads."

        if dialect == "postgres":
            if any("embedding" in column or "vector" in column for column in lowered_cols):
                recommended = "ivfflat"
                reason = (
                    "Vector-like column detected; ivfflat may outperform btree for ANN similarity workloads. "
                    "Requires vector-specific operator class and tuning."
                )
            elif any(term in column for column in lowered_cols for term in ("json", "metadata", "payload", "tags")):
                recommended = "gin"
                reason = "JSON/metadata-like column detected; GIN is often better for containment/operator queries."
            elif any(
                column.endswith(("_at", "_date", "_time", "timestamp"))
                for column in lowered_cols
            ) and write_ratio < 0.35:
                recommended = "brin"
                reason = (
                    "Time-correlated column with low write pressure detected; BRIN can be compact and efficient "
                    "for append-heavy chronological scans."
                )
            elif len(suggestion.columns) == 1:
                column = suggestion.columns[0]
                usage = table_usage.get(column)
                if usage and usage.where_count >= 3 and usage.order_count == 0 and usage.join_count == 0 and write_ratio < 0.25:
                    recommended = "hash"
                    reason = "Single-column equality-heavy filter detected with low write ratio; hash index may help exact matches."

        typed_ddl = _build_typed_ddl(suggestion, recommended, dialect=dialect)
        suggestion.metadata["index_type"] = {
            "recommended": recommended,
            "reason": reason,
            "typed_ddl": typed_ddl,
        }


def _build_typed_ddl(suggestion: IndexSuggestion, index_type: str, *, dialect: str) -> str | None:
    if index_type == "btree":
        return suggestion.ddl
    if dialect != "postgres":
        return None
    if index_type == "ivfflat":
        return None
    quoted_table = _quote_identifier(suggestion.table, dialect)
    quoted_columns = ", ".join(_quote_identifier(column, dialect) for column in suggestion.columns)
    quoted_index = _quote_identifier(suggestion.index_name, dialect)
    return f"CREATE INDEX IF NOT EXISTS {quoted_index} ON {quoted_table} USING {index_type} ({quoted_columns});"


def _build_index_name(table: str, columns: list[str], dialect: str) -> str:
    base = f"ix_{table}_{'_'.join(columns)}"
    sanitized = "".join(char if char.isalnum() or char == "_" else "_" for char in base).lower()
    max_len = 63 if dialect in {"postgres", "sqlite"} else 64
    if len(sanitized) <= max_len:
        return sanitized
    digest = hashlib.md5(sanitized.encode("utf-8")).hexdigest()[:8]
    cutoff = max_len - 9
    return f"{sanitized[:cutoff]}_{digest}"


def _build_ddl(table: str, columns: list[str], index_name: str, dialect: str) -> str:
    quoted_table = _quote_identifier(table, dialect)
    quoted_columns = ", ".join(_quote_identifier(col, dialect) for col in columns)
    quoted_index = _quote_identifier(index_name, dialect)
    if dialect in {"postgres", "sqlite"}:
        return f"CREATE INDEX IF NOT EXISTS {quoted_index} ON {quoted_table} ({quoted_columns});"
    return f"CREATE INDEX {quoted_index} ON {quoted_table} ({quoted_columns});"


def _build_orm_snippets(table: str, columns: list[str], index_name: str) -> dict[str, str]:
    sqlalchemy_cols = ", ".join(f"{table}.c.{column}" for column in columns)
    django_cols = ", ".join(f'"{column}"' for column in columns)
    return {
        "sqlalchemy": f'Index("{index_name}", {sqlalchemy_cols})',
        "django": f'models.Index(fields=[{django_cols}], name="{index_name}")',
    }


def _quote_identifier(identifier: str, dialect: str) -> str:
    quote_char = "`" if dialect == "mysql" else '"'
    parts = [part for part in identifier.split(".") if part]
    return ".".join(f"{quote_char}{part}{quote_char}" for part in parts)


def _lookup_hint(hints: dict[str, str], table: str, column: str) -> str:
    value = hints.get(f"{table}.{column}", hints.get(column, "unknown"))
    normalized = str(value).strip().lower()
    if normalized in {"high", "medium", "low"}:
        return normalized
    return "unknown"


def _clamp(value: float, minimum: float, maximum: float) -> float:
    return max(minimum, min(maximum, value))


def _is_excluded_table(table: str) -> bool:
    if table in SYSTEM_TABLE_NAMES:
        return True
    for prefix in SYSTEM_TABLE_PREFIXES:
        if table.startswith(prefix):
            return True
    return False


def _is_low_selectivity_column(column: str) -> bool:
    lowered = column.lower()
    if lowered.startswith("is_") or lowered.startswith("has_"):
        return True
    return lowered in {
        "status",
        "state",
        "active",
        "enabled",
        "disabled",
        "deleted",
        "archived",
        "type",
    }
