"""
Snowpark validator entry point -- runs inside Snowflake.

This module is packaged into ``snowpark_validator.zip`` and imported by the
``VALIDATION.VALIDATE_BATCH`` / ``VALIDATION.VALIDATE_SINGLE`` stored procedures.

It is intentionally self-contained (no imports from ``test_runner.*``) so it
can execute in Snowflake's Python sandbox with only ``snowflake-snowpark-python``.
"""

import json
import hashlib
import re
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from typing import Any, Optional


# ============================================================================
# Public API (called by the stored procedure handlers)
# ============================================================================

def validate_single(
    session,
    procedure_name: str,
    params: dict,
    baseline_stage: str,
    database: str,
    validation_schema: str = "VALIDATION",
    proc_prefix: str = "",
) -> dict:
    """Validate a single procedure call against its baseline.

    Args:
        session: Snowpark session.
        procedure_name: Procedure name as stored in the baseline.
        params: Parameters dict.
        baseline_stage: Stage path for baselines.
        database: Database to USE.
        validation_schema: Schema containing JSON_FORMAT.
        proc_prefix: Prefix for procedure CALL.

    Returns:
        Result dict with status, row counts, errors, differences.
    """
    session.sql(f"USE DATABASE {database}").collect()
    _ensure_json_format(session, validation_schema)

    params_hash = _hash_params(params)
    result = {
        "procedure": procedure_name,
        "params_hash": params_hash,
        "params": params,
        "status": "",
        "baseline_rows": 0,
        "actual_rows": 0,
        "match_type": "",
        "error": "",
        "differences": [],
        "executed_at": datetime.now(timezone.utc).isoformat(),
    }

    # Load baseline
    baseline = _load_baseline(
        session, baseline_stage, validation_schema, procedure_name, params_hash
    )

    if baseline is None:
        result["status"] = "NO_BASELINE"
        result["error"] = f"Baseline not found (hash: {params_hash})"
        return result

    baseline_success = baseline.get("success", True)
    baseline_error = baseline.get("error", "")
    baseline_rows = (
        baseline.get("result_sets", [[]])[0] if baseline.get("result_sets") else []
    )

    result["baseline_rows"] = len(baseline_rows)

    # Execute procedure
    success, actual_rows, error_msg = _execute_procedure(
        session, procedure_name, params, proc_prefix
    )
    result["actual_rows"] = len(actual_rows)

    # Compare error states
    if not baseline_success and not success:
        if _errors_match(baseline_error, error_msg):
            result["status"] = "PASS"
            result["match_type"] = "error_match"
        else:
            result["status"] = "FAIL"
            result["match_type"] = "error_mismatch"
            result["error"] = (
                f"Baseline: {baseline_error[:200]}, Actual: {error_msg[:200]}"
            )
        return result

    if baseline_success != success:
        result["status"] = "FAIL"
        result["error"] = (
            error_msg[:500]
            if not success
            else f"Baseline errored: {baseline_error[:200]}"
        )
        return result

    # Compare results
    comparison = _compare_results(baseline_rows, actual_rows)
    result["status"] = "PASS" if comparison["match"] else "FAIL"
    result["match_type"] = comparison["match_type"]
    result["differences"] = comparison["differences"][:5]

    return result


def validate_batch(
    session,
    baseline_stage: str,
    pattern: str,
    database: str,
    validation_schema: str = "VALIDATION",
    proc_prefix: str = "",
    results_table: Optional[str] = None,
) -> dict:
    """Validate all baselines matching *pattern* (serial execution).

    Args:
        session: Snowpark session.
        baseline_stage: Stage path for baselines.
        pattern: Regex to filter baseline files.
        database: Database to USE.
        validation_schema: Schema containing JSON_FORMAT.
        proc_prefix: Prefix for procedure calls.
        results_table: Optional table to persist results.

    Returns:
        Summary dict with counts.
    """
    session.sql(f"USE DATABASE {database}").collect()
    _ensure_json_format(session, validation_schema)

    baselines = _list_baselines(session, baseline_stage, validation_schema, pattern)

    if not baselines:
        return {"total": 0, "passed": 0, "failed": 0, "errors": 0, "success": True}

    results = []
    passed = failed = errors = 0

    for info in baselines:
        try:
            result = validate_single(
                session=session,
                procedure_name=info["procedure"],
                params=info["parameters"],
                baseline_stage=baseline_stage,
                database=database,
                validation_schema=validation_schema,
                proc_prefix=proc_prefix,
            )
            results.append(result)
            if result["status"] == "PASS":
                passed += 1
            else:
                failed += 1
        except Exception as exc:
            errors += 1
            print(f"[error] {info.get('procedure', '?')}: {exc}")

    if results and results_table:
        _save_results(session, results, results_table)

    return {
        "total": passed + failed + errors,
        "passed": passed,
        "failed": failed,
        "errors": errors,
        "success": failed == 0 and errors == 0,
    }


# ============================================================================
# Internal helpers
# ============================================================================

def _ensure_json_format(session, schema: str) -> None:
    try:
        session.sql(
            f"CREATE FILE FORMAT IF NOT EXISTS {schema}.JSON_FORMAT TYPE = 'JSON'"
        ).collect()
    except Exception:
        pass


def _hash_params(params: dict) -> str:
    param_str = json.dumps(params, sort_keys=True, default=str)
    return hashlib.md5(param_str.encode()).hexdigest()[:8]


def _load_baseline(
    session, stage: str, schema: str, proc_name: str, params_hash: str
) -> Optional[dict]:
    safe_name = proc_name.replace(".", "_").replace(" ", "_")
    pattern = f".*/{safe_name}/.*{params_hash}.*"
    try:
        rows = session.sql(f"LIST {stage} PATTERN='{pattern}'").collect()
        if not rows:
            return None
        full_path = sorted([r[0] for r in rows])[-1]
        relative = full_path.split("/", 1)[1] if "/" in full_path else full_path
        return _load_json(session, f"{stage}/{relative}", schema)
    except Exception:
        return None


def _load_json(session, stage_path: str, schema: str) -> Optional[dict]:
    try:
        rows = session.sql(
            f"SELECT $1 FROM {stage_path} (FILE_FORMAT => '{schema}.JSON_FORMAT')"
        ).collect()
        if not rows:
            return None
        data = rows[0][0]
        return json.loads(data) if isinstance(data, str) else data
    except Exception:
        return None


def _list_baselines(
    session, stage: str, schema: str, pattern: str = ".*"
) -> list:
    _ensure_json_format(session, schema)
    rows = session.sql(f"LIST {stage} PATTERN='{pattern}'").collect()
    if not rows:
        return []
    baselines = []
    for row in rows:
        file_path = row[0]
        try:
            relative = file_path.split("/", 1)[1] if "/" in file_path else file_path
            data = _load_json(session, f"{stage}/{relative}", schema)
            if data:
                baselines.append({
                    "file_path": file_path,
                    "procedure": data.get("procedure", ""),
                    "parameters": data.get("parameters", {}),
                })
        except Exception as exc:
            print(f"[warn] Failed to load {file_path}: {exc}")
    return baselines


def _execute_procedure(
    session, proc_name: str, params: dict, prefix: str
) -> tuple:
    try:
        schema = prefix + proc_name.split(".")[0]
        session.sql(f"USE SCHEMA {schema}").collect()
        param_parts = [f"{k} => {_format_value(v)}" for k, v in params.items()]
        call_sql = f"CALL {prefix}{proc_name}({', '.join(param_parts)})"
        rows = session.sql(call_sql).collect()
        result = [r.as_dict() if hasattr(r, "as_dict") else dict(r) for r in rows]
        return True, result, ""
    except Exception as exc:
        return False, [], str(exc)


def _format_value(value: Any) -> str:
    if value is None:
        return "NULL"
    if isinstance(value, bool):
        return "TRUE" if value else "FALSE"
    if isinstance(value, (int, float, Decimal)):
        return str(value)
    if isinstance(value, datetime):
        return f"'{value.isoformat()}'"
    escaped = str(value).replace("'", "''")
    return f"'{escaped}'"


def _normalize_value(value: Any) -> Any:
    if value is None:
        return None
    if isinstance(value, datetime):
        return value.isoformat()
    if isinstance(value, Decimal):
        return str(value.normalize())
    if isinstance(value, str):
        if re.match(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", value):
            return value.replace(" ", "T", 1)
        if re.match(r"^\d{4}-\d{2}-\d{2}$", value):
            return f"{value}T00:00:00"
        if re.match(r"^-?\d+\.?\d*$", value) or re.match(
            r"^-?\d+E[+-]?\d+$", value, re.IGNORECASE
        ):
            try:
                return str(Decimal(value).normalize())
            except InvalidOperation:
                pass
    return value


def _normalize_row(row: dict) -> dict:
    return {k.upper(): _normalize_value(v) for k, v in row.items()}


def _compare_results(baseline_rows: list, actual_rows: list) -> dict:
    baseline_norm = [_normalize_row(r) for r in baseline_rows]
    actual_norm = [_normalize_row(r) for r in actual_rows]
    if len(baseline_norm) != len(actual_norm):
        return {
            "match": False,
            "match_type": "row_count_mismatch",
            "differences": [
                {"type": "row_count", "baseline": len(baseline_norm), "actual": len(actual_norm)}
            ],
        }
    to_hash = lambda row: tuple(sorted((k, str(v)) for k, v in row.items()))
    baseline_set = set(to_hash(r) for r in baseline_norm)
    actual_set = set(to_hash(r) for r in actual_norm)
    missing = baseline_set - actual_set
    extra = actual_set - baseline_set
    if missing or extra:
        diffs = []
        for row in list(missing)[:3]:
            diffs.append({"type": "missing_in_actual", "row": dict(row)})
        for row in list(extra)[:3]:
            diffs.append({"type": "extra_in_actual", "row": dict(row)})
        return {"match": False, "match_type": "content_mismatch", "differences": diffs}
    return {"match": True, "match_type": "set", "differences": []}


def _errors_match(baseline_error: str, actual_error: str) -> bool:
    if not baseline_error or not actual_error:
        return baseline_error == actual_error
    return baseline_error.strip().upper().split(":")[0] == actual_error.strip().upper().split(":")[0]


def _save_results(session, results: list, table: str) -> None:
    if not results:
        return
    rows = []
    for r in results:
        params_json = json.dumps(r["params"]).replace("'", "''")
        diffs_json = json.dumps(r.get("differences", [])).replace("'", "''")
        error = (r.get("error") or "").replace("'", "''")
        rows.append(f"""SELECT
            '{r["procedure"]}' AS procedure_name,
            '{r["params_hash"]}' AS params_hash,
            PARSE_JSON('{params_json}') AS parameters,
            '{r["status"]}' AS status,
            {r["baseline_rows"]} AS baseline_rows,
            {r["actual_rows"]} AS actual_rows,
            '{r.get("match_type", "")}' AS match_type,
            '{error}' AS error_message,
            PARSE_JSON('{diffs_json}') AS differences,
            '{r["executed_at"]}'::TIMESTAMP_TZ AS executed_at""")
    sql = f"""
        INSERT INTO {table} (
            procedure_name, params_hash, parameters, status,
            baseline_rows, actual_rows, match_type, error_message,
            differences, executed_at
        )
        {' UNION ALL '.join(rows)}
    """
    session.sql(sql).collect()
