from __future__ import annotations

from dataclasses import asdict
from dataclasses import dataclass, field
from pathlib import Path

from auraindex.analyzers import (
    analyze_query_advisories,
    analyze_sql_candidates,
    apply_schema_checks,
    build_schema_drift_report,
    build_workload_stats,
    extract_existing_indexes,
    introspect_existing_indexes_from_live_db,
    suggest_indexes,
    validate_suggestions_with_explain,
)
from auraindex.extractors import WasmTreeSitterExtractor, extract_python_file, extract_sql_file, extract_text_fallback_file
from auraindex.models import QueryCandidate, ScanResult
from auraindex.utils.files import discover_files
from auraindex.utils.text import normalize_whitespace

SUPPORTED_EXTENSIONS = {".py", ".sql", ".js", ".jsx", ".ts", ".tsx", ".php"}
WASM_EXTENSIONS = {".py", ".php", ".js", ".jsx", ".ts", ".tsx"}
TEXT_FALLBACK_EXTENSIONS = {".php", ".js", ".jsx", ".ts", ".tsx"}
TEXT_FALLBACK_LANGUAGE = {
    ".php": "php",
    ".js": "javascript",
    ".jsx": "javascript",
    ".ts": "typescript",
    ".tsx": "tsx",
}


@dataclass
class ScanConfig:
    target_path: Path
    dialect: str = "postgres"
    extensions: set[str] = field(default_factory=lambda: {".py", ".sql", ".js", ".jsx", ".ts", ".tsx", ".php"})
    exclude_dirs: set[str] = field(
        default_factory=lambda: {
            ".git",
            "node_modules",
            "__pycache__",
            ".venv",
            "venv",
            "build",
            "dist",
            "staticfiles",
            ".next",
        }
    )
    enable_wasm: bool = True
    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
    enable_schema_checks: bool = True
    enable_drift_watch: bool = True
    enable_query_advisories: bool = True
    max_query_advisories: int = 120
    enable_db_introspection: bool = False
    db_dialect: str | None = None
    db_host: str | None = None
    db_port: int | None = None
    db_user: str | None = None
    db_password: str | None = None
    db_name: str | None = None
    db_path: Path | None = None
    db_schema: str | None = None
    db_timeout_seconds: int = 8
    explain_validate: bool = False
    explain_db_path: Path | None = None
    explain_command: str | None = None
    explain_timeout_seconds: int = 15
    explain_max_queries: int = 1


def run_scan(config: ScanConfig) -> ScanResult:
    target = config.target_path.resolve()
    if not target.exists():
        raise FileNotFoundError(f"Target path does not exist: {target}")

    extensions = {ext.lower() for ext in config.extensions if ext.lower() in SUPPORTED_EXTENSIONS}
    if not extensions:
        extensions = {".py", ".sql"}

    files = discover_files(target, extensions=extensions, exclude_dirs=config.exclude_dirs)
    wasm_extractor = WasmTreeSitterExtractor() if config.enable_wasm else None
    wasm_unavailable_reason = wasm_extractor.unavailable_reason if wasm_extractor else None
    use_python_ast_fallback = bool(config.enable_wasm and wasm_extractor and wasm_unavailable_reason)
    use_text_fallback = bool(config.enable_wasm and wasm_extractor and wasm_unavailable_reason)
    warnings: list[str] = []
    candidates: list[QueryCandidate] = []
    skipped_non_python_sources = 0
    used_python_fallback_files = 0
    used_text_fallback_files = 0

    for file_path in files:
        suffix = file_path.suffix.lower()
        if suffix == ".sql":
            candidates.extend(extract_sql_file(file_path))
            continue

        if suffix in WASM_EXTENSIONS:
            if suffix == ".py" and use_python_ast_fallback:
                python_candidates, python_warnings = extract_python_file(file_path)
                candidates.extend(python_candidates)
                warnings.extend(python_warnings)
                used_python_fallback_files += 1
                continue
            if suffix in TEXT_FALLBACK_EXTENSIONS and use_text_fallback:
                language = TEXT_FALLBACK_LANGUAGE.get(suffix, "javascript")
                text_candidates, text_warnings = extract_text_fallback_file(file_path, language=language)
                candidates.extend(text_candidates)
                warnings.extend(text_warnings)
                used_text_fallback_files += 1
                continue

            if wasm_extractor is None:
                skipped_non_python_sources += 1
                continue
            if wasm_unavailable_reason:
                skipped_non_python_sources += 1
                continue

            wasm_candidates, wasm_warnings = wasm_extractor.extract_file(file_path)
            candidates.extend(wasm_candidates)
            warnings.extend(wasm_warnings)

    if skipped_non_python_sources:
        if config.enable_wasm:
            warnings.append(
                f"Skipped {skipped_non_python_sources} non-Python non-SQL file(s) because WASM extraction was unavailable."
            )
        else:
            warnings.append(
                f"Skipped {skipped_non_python_sources} non-SQL source file(s) because WASM extraction is disabled."
            )

    candidates = _dedupe_candidates(candidates)
    parsed_queries, parse_failures = analyze_sql_candidates(candidates, dialect=config.dialect)
    workload = build_workload_stats(parsed_queries)
    suggestions = suggest_indexes(
        workload,
        dialect=config.dialect,
        min_support=max(1, config.min_support),
        max_indexes_per_table=max(1, config.max_indexes_per_table),
        cardinality_hints=config.cardinality_hints,
        enable_impact_scoring=config.enable_impact_scoring,
        enable_index_type_hints=config.enable_index_type_hints,
    )
    metadata: dict[str, object] = {}
    metadata["extraction"] = {
        "wasm_enabled": config.enable_wasm,
        "wasm_available": not bool(wasm_unavailable_reason),
        "wasm_unavailable_reason": wasm_unavailable_reason,
        "python_ast_fallback_files": used_python_fallback_files,
        "text_fallback_files": used_text_fallback_files,
        "skipped_non_sql_source_files": skipped_non_python_sources,
    }
    existing_indexes = []
    code_existing_indexes = []
    live_existing_indexes = []
    if config.enable_schema_checks or config.enable_drift_watch:
        code_existing_indexes, schema_warnings = extract_existing_indexes(candidates, dialect=config.dialect)
        warnings.extend(schema_warnings)
        existing_indexes.extend(code_existing_indexes)

    if config.enable_db_introspection:
        db_dialect = config.db_dialect or config.dialect
        live_existing_indexes, live_warnings, live_summary = introspect_existing_indexes_from_live_db(
            dialect=db_dialect,
            host=config.db_host,
            port=config.db_port,
            user=config.db_user,
            password=config.db_password,
            database=config.db_name,
            db_path=str(config.db_path) if config.db_path else None,
            schema=config.db_schema,
            timeout_seconds=max(1, config.db_timeout_seconds),
        )
        warnings.extend(live_warnings)
        metadata["live_db_introspection"] = live_summary
        existing_indexes.extend(live_existing_indexes)

    existing_indexes = _dedupe_existing_indexes(existing_indexes)

    if config.enable_schema_checks:
        suggestions, schema_summary = apply_schema_checks(suggestions, existing_indexes)
        metadata["schema_checks"] = {
            "summary": schema_summary,
            "existing_indexes": [asdict(index) for index in existing_indexes[:200]],
            "sources": {
                "code_discovered": len(code_existing_indexes),
                "live_discovered": len(live_existing_indexes),
            },
        }
        skipped = int(schema_summary.get("skipped_suggestions", 0))
        if skipped:
            warnings.append(
                f"Schema checks skipped {skipped} suggestion(s) due to existing index coverage."
            )

    if config.enable_drift_watch:
        metadata["schema_drift"] = build_schema_drift_report(workload, suggestions, existing_indexes)

    if config.enable_query_advisories:
        advisories = analyze_query_advisories(parsed_queries, max_items=max(1, config.max_query_advisories))
        metadata["query_advisories"] = {
            "count": len(advisories),
            "items": advisories,
        }

    explain_summary, explain_warnings = validate_suggestions_with_explain(
        suggestions,
        parsed_queries,
        dialect=config.dialect,
        explain_validate=config.explain_validate,
        explain_db_path=str(config.explain_db_path) if config.explain_db_path else None,
        explain_command=config.explain_command,
        explain_timeout_seconds=max(1, config.explain_timeout_seconds),
        explain_max_queries=max(1, config.explain_max_queries),
    )
    if explain_summary:
        metadata["explain_validation"] = explain_summary
    warnings.extend(explain_warnings)

    return ScanResult(
        scanned_files=[str(path) for path in files],
        candidates=candidates,
        parsed_queries=parsed_queries,
        parse_failures=parse_failures,
        workload=workload,
        suggestions=suggestions,
        warnings=warnings,
        metadata=metadata,
    )


def _dedupe_candidates(candidates: list[QueryCandidate]) -> list[QueryCandidate]:
    seen: set[tuple[str, int, str, str]] = set()
    deduped: list[QueryCandidate] = []
    for candidate in candidates:
        key = (
            candidate.context.file_path,
            candidate.context.line,
            candidate.context.source_kind,
            normalize_whitespace(candidate.sql).lower(),
        )
        if key in seen:
            continue
        seen.add(key)
        deduped.append(candidate)
    return deduped


def _dedupe_existing_indexes(indexes: list[object]) -> list[object]:
    seen: set[tuple[str, tuple[str, ...], bool, str | None]] = set()
    deduped: list[object] = []
    for index in indexes:
        key = (index.table, tuple(index.columns), index.unique, index.partial_where)
        if key in seen:
            continue
        seen.add(key)
        deduped.append(index)
    return deduped
