"""
Deployment Tracker

Tracks deployments in the target catalog's tracking schema (named schemax).
Provides database-backed deployment history and audit trail.
"""

import json
from typing import Any, TypedDict, cast

from schemax.providers.base.executor import (
    ExecutionResult,
    SQLRunner,
    SQLRunResult,
    StatementResult,
)
from schemax.providers.base.operations import Operation
from schemax.version import SCHEMAX_VERSION


class DeploymentOpDetail(TypedDict):
    """Shape of one row from deployment_ops used in deployment records."""

    id: str
    type: str
    target: str
    payload: dict[str, Any]
    status: str
    executionOrder: int


class DeploymentRecord(TypedDict, total=False):
    """Shape of a deployment record (from DB row + merged ops)."""

    id: str
    environment: str
    version: str
    fromVersion: str | None
    previousDeploymentId: str | None
    deployedAt: Any
    deployedBy: str | None
    status: str
    statementCount: int | None
    errorMessage: str | None
    executionTimeMs: int | None
    opsApplied: list[str]
    opsDetails: list[DeploymentOpDetail]
    successfulStatements: int
    failedStatementIndex: int | None


_EXPECTED_NOT_FOUND_PATTERNS = (
    "catalog",
    "schema",
    "not found",
    "does not exist",
    "table_or_view_not_found",
    "cannot be found",
)


def _is_expected_not_found_error(message: str) -> bool:
    """Return True if the error message indicates catalog/schema/table not found."""
    lower = message.lower()
    return any(p in lower for p in _EXPECTED_NOT_FOUND_PATTERNS)


def _row_to_deployment_record(row: list) -> DeploymentRecord:
    """Convert a deployments table row to a deployment record dict."""
    return {
        "id": row[0],
        "environment": row[1],
        "version": row[2],
        "fromVersion": row[3],
        "previousDeploymentId": row[4],
        "deployedAt": row[5],
        "deployedBy": row[6],
        "status": row[7],
        "statementCount": row[8],
        "errorMessage": row[9],
        "executionTimeMs": row[10],
    }


def _parse_ops_response(
    ops_response: Any,
) -> tuple[list[str], list[DeploymentOpDetail], int | None]:
    """Parse deployment_ops query result into ops_applied, ops_details, failed_index."""
    ops_applied: list[str] = []
    ops_details: list[DeploymentOpDetail] = []
    failed_statement_index: int | None = None
    if not ops_response.result or not ops_response.result.data_array:
        return (ops_applied, ops_details, failed_statement_index)
    for i, row in enumerate(ops_response.result.data_array):
        op_id = row[0]
        op_type = row[1]
        op_target = row[2]
        op_payload_json = row[3]
        op_status = row[4]
        execution_order = row[5]
        try:
            op_payload = json.loads(op_payload_json) if op_payload_json else {}
        except json.JSONDecodeError:
            op_payload = {}
        ops_applied.append(op_id)
        ops_details.append(
            {
                "id": op_id,
                "type": op_type,
                "target": op_target,
                "payload": op_payload,
                "status": op_status,
                "executionOrder": execution_order,
            }
        )
        if op_status == "failed" and failed_statement_index is None:
            failed_statement_index = i
    return (ops_applied, ops_details, failed_statement_index)


def _normalize_deployed_at(deployed_at: Any) -> str | None:
    """Normalize deployed_at to Spark/Databricks-friendly timestamp string."""
    if deployed_at is None:
        return None
    if hasattr(deployed_at, "isoformat"):
        out = deployed_at.isoformat()
    else:
        out = str(deployed_at)
    out = out.replace("T", " ").split("+")[0].split("Z")[0].strip()
    if "." in out:
        base, frac = out.split(".", 1)
        frac = (frac + "000000")[:6]
        out = f"{base}.{frac}"
    return cast(str, out.replace("'", "''"))


class _ResultData:
    """Holds result data in the shape expected by response consumers (response.result.data_array)."""

    def __init__(self, data_array: list[list[Any]] | None) -> None:
        self.data_array = data_array


class _RunnerResultAdapter:
    """Adapts SQLRunResult to look like a Statement Execution API response."""

    def __init__(self, run_result: SQLRunResult) -> None:
        self.result = _ResultData(run_result.data_array)


class DeploymentTracker:
    """Track deployments in target catalog's tracking schema (named schemax)

    Creates and manages deployment tracking tables in <catalog>.schemax:
    - deployments: Main deployment records
    - deployment_ops: Individual operation tracking
    """

    def __init__(self, runner: SQLRunner, catalog: str) -> None:
        """Initialize deployment tracker.

        Args:
            runner: SQLRunner for executing SQL statements
            catalog: Target catalog name
        """
        self.catalog = catalog
        self.schema = f"`{catalog}`.`schemax`"
        self._runner = runner

    def _execute_statement_sync(self, sql: str) -> Any | None:
        """Execute a read-only statement; return response or None on expected not-found errors."""
        try:
            result = self._runner.run_sql(sql)
        except Exception as e:
            if _is_expected_not_found_error(str(e)):
                return None
            raise
        if result.status == "failed":
            error_msg = result.error_message or ""
            if _is_expected_not_found_error(error_msg):
                return None
            raise RuntimeError(f"Database query failed: {error_msg or 'No error message'}")
        return _RunnerResultAdapter(result)

    def _execute_statement_raise(self, sql: str) -> Any:
        """Execute a statement; raise on any failure."""
        result = self._runner.run_sql(sql)
        if result.status == "failed":
            error_msg = result.error_message or "No error message"
            raise RuntimeError(f"Database query failed: {error_msg}")
        return _RunnerResultAdapter(result)

    def ensure_tracking_schema(self, auto_create: bool = True) -> None:
        """Create tracking schema and tables if needed

        Args:
            auto_create: If True, create schema and tables automatically
        """
        if not auto_create:
            return

        # Create schema
        self._execute_ddl(
            f"CREATE SCHEMA IF NOT EXISTS {self.schema} "
            f"COMMENT 'SchemaX deployment tracking and metadata'"
        )

        # Create deployments table
        self._execute_ddl(self._get_deployments_table_ddl())

        # Create deployment_ops table
        self._execute_ddl(self._get_deployment_ops_table_ddl())

    def start_deployment(
        self,
        deployment_id: str,
        environment: str,
        snapshot_version: str,
        project_name: str,
        provider_type: str,
        provider_version: str,
        schemax_version: str = SCHEMAX_VERSION,
        from_snapshot_version: str | None = None,
        previous_deployment_id: str | None = None,
    ) -> None:
        """Record deployment start (status: pending)

        Args:
            deployment_id: Unique deployment ID
            environment: Target environment (dev/test/prod)
            snapshot_version: Version being deployed (or 'changelog')
            project_name: SchemaX project name
            provider_type: Provider type (unity/hms)
            provider_version: Provider version
            schemax_version: SchemaX CLI version
            from_snapshot_version: Previous snapshot version (for diff tracking)
            previous_deployment_id: Deployment that was current before this one (for partial rollback)
        """
        from_version_sql = f"'{from_snapshot_version}'" if from_snapshot_version else "NULL"
        prev_deploy_sql = (
            f"'{previous_deployment_id.replace(chr(39), chr(39) + chr(39))}'"
            if previous_deployment_id
            else "NULL"
        )

        sql = f"""
        INSERT INTO {self.schema}.deployments
        (id, environment, snapshot_version, from_snapshot_version, previous_deployment_id,
         deployed_at, deployed_by, project_name, provider_type, provider_version,
         status, schemax_version)
        VALUES (
            '{deployment_id}',
            '{environment}',
            '{snapshot_version}',
            {from_version_sql},
            {prev_deploy_sql},
            current_timestamp(),
            current_user(),
            '{project_name}',
            '{provider_type}',
            '{provider_version}',
            'pending',
            '{schemax_version}'
        )
        """
        self._execute_ddl(sql)

    def record_operation(
        self,
        deployment_id: str,
        operation: Operation,
        sql_stmt: str,
        result: StatementResult,
        execution_order: int,
    ) -> None:
        """Record individual operation result

        Args:
            deployment_id: Parent deployment ID
            operation: Operation that was executed
            sql_stmt: Generated SQL statement
            result: Statement execution result
            execution_order: Order of execution (1-indexed)
        """
        # Escape single quotes in strings
        op_id = operation.id.replace("'", "''")
        op_type = operation.op.replace("'", "''")
        op_target = operation.target.replace("'", "''") if operation.target else ""

        # Serialize payload as JSON for exact matching
        payload_json = json.dumps(operation.payload, sort_keys=True)
        payload_escaped = payload_json.replace("'", "''")

        sql_escaped = sql_stmt.replace("'", "''")
        error_msg = result.error_message.replace("'", "''") if result.error_message else ""

        sql = f"""
        INSERT INTO {self.schema}.deployment_ops
        (deployment_id, op_id, op_type, op_target, op_payload, sql_statement,
         executed_at, execution_order, status, error_message)
        VALUES (
            '{deployment_id}',
            '{op_id}',
            '{op_type}',
            '{op_target}',
            '{payload_escaped}',
            '{sql_escaped}',
            current_timestamp(),
            {execution_order},
            '{result.status}',
            {f"'{error_msg}'" if result.error_message else "NULL"}
        )
        """
        self._execute_ddl(sql)

    def complete_deployment(
        self, deployment_id: str, result: ExecutionResult, error_message: str | None = None
    ) -> None:
        """Update deployment status (success/failed/partial)

        Args:
            deployment_id: Deployment ID to update
            result: Overall execution result
            error_message: Error summary (if failed)
        """
        error_escaped = error_message.replace("'", "''") if error_message else ""

        sql = f"""
        UPDATE {self.schema}.deployments
        SET
            status = '{result.status}',
            ops_count = {result.total_statements},
            execution_time_ms = {result.total_execution_time_ms},
            error_message = {f"'{error_escaped}'" if error_message else "NULL"}
        WHERE id = '{deployment_id}'
        """
        self._execute_ddl(sql)

    def get_latest_deployment(self, environment: str) -> dict[str, str] | None:
        """Get the latest successful deployment for an environment

        Queries the database (source of truth) to find the most recent
        successful deployment.

        Returns None if:
        - Catalog doesn't exist (expected on first deployment)
        - Tracking schema doesn't exist (expected on first deployment)
        - No successful deployments found (expected on first deployment)

        Raises exception for real errors:
        - Connection failures
        - Authentication errors
        - Timeout errors
        - Permission errors

        Args:
            environment: Target environment name (dev/test/prod)

        Returns:
            Deployment record with 'version' and 'id', or None if first deployment

        Raises:
            Exception: For database connection or query errors (not catalog-not-found)
        """
        sql = f"""
        SELECT snapshot_version, id, deployed_at
        FROM {self.schema}.deployments
        WHERE environment = '{environment}'
          AND status = 'success'
        ORDER BY deployed_at DESC
        LIMIT 1
        """
        response = self._execute_statement_sync(sql)
        if response is None:
            return None
        if response.result and response.result.data_array:
            row = response.result.data_array[0]
            return {"version": row[0], "id": row[1]}
        return None

    def get_most_recent_deployment_id(self, environment: str) -> str | None:
        """Get the ID of the most recent deployment for an environment (any status)

        Used when starting a new deployment to set previous_deployment_id.
        Returns None if no deployments exist (first deployment) or on expected
        errors (catalog/schema not found).

        Args:
            environment: Target environment name (dev/test/prod)

        Returns:
            Deployment ID string, or None
        """
        env_esc = environment.replace("'", "''")
        sql = f"""
        SELECT id
        FROM {self.schema}.deployments
        WHERE environment = '{env_esc}'
        ORDER BY deployed_at DESC
        LIMIT 1
        """
        response = self._execute_statement_sync(sql)
        if response is None:
            return None
        if response.result and response.result.data_array and len(response.result.data_array) > 0:
            return cast(str, response.result.data_array[0][0])
        return None

    def get_deployment_by_id(self, deployment_id: str) -> DeploymentRecord | None:
        """Get a specific deployment by ID from the database

        Queries the database (source of truth) to find a deployment by its ID.
        Also queries deployment_ops to get the list of operations and their statuses.

        Returns None if:
        - Catalog doesn't exist (expected on first deployment)
        - Tracking schema doesn't exist (expected on first deployment)
        - Deployment ID not found

        Raises exception for real errors (connection, auth, timeout, etc.)

        Args:
            deployment_id: Deployment ID to look up

        Returns:
            Deployment record with operations list, or None if not found

        Raises:
            Exception: For database connection or query errors (not catalog-not-found)
        """
        sql = f"""
        SELECT
            id,
            environment,
            snapshot_version,
            from_snapshot_version,
            previous_deployment_id,
            deployed_at,
            deployed_by,
            status,
            ops_count,
            error_message,
            execution_time_ms
        FROM {self.schema}.deployments
        WHERE id = '{deployment_id}'
        LIMIT 1
        """
        try:
            response = self._execute_statement_sync(sql)
            if response is None or not response.result or not response.result.data_array:
                return None
            deployment = _row_to_deployment_record(response.result.data_array[0])
            return self._merge_ops_into_deployment(deployment, deployment_id)
        except Exception as e:
            if _is_expected_not_found_error(str(e)):
                return None
            raise

    def _merge_ops_into_deployment(
        self, deployment: DeploymentRecord, deployment_id: str
    ) -> DeploymentRecord:
        """Query deployment_ops and merge ops_applied, opsDetails, failedStatementIndex."""
        ops_sql = f"""
        SELECT
            op_id,
            op_type,
            op_target,
            op_payload,
            status,
            execution_order,
            error_message
        FROM {self.schema}.deployment_ops
        WHERE deployment_id = '{deployment_id}'
        ORDER BY execution_order
        """
        ops_response = self._execute_statement_raise(ops_sql)
        ops_applied, ops_details, failed_idx = _parse_ops_response(ops_response)
        deployment["opsApplied"] = ops_applied
        deployment["opsDetails"] = ops_details
        deployment["successfulStatements"] = sum(
            1 for d in ops_details if d.get("status") == "success"
        )
        deployment["failedStatementIndex"] = failed_idx
        return deployment

    def _query_previous_deployment_id(self, environment: str, deployed_at_str: str) -> str | None:
        """Return the deployment ID immediately before the given timestamp, or None."""
        env_esc = environment.replace("'", "''")
        sql = f"""
        SELECT id
        FROM {self.schema}.deployments
        WHERE environment = '{env_esc}'
          AND deployed_at < CAST('{deployed_at_str}' AS TIMESTAMP)
        ORDER BY deployed_at DESC
        LIMIT 1
        """
        response = self._execute_statement_sync(sql)
        if response is None:
            return None
        if response.result and response.result.data_array and len(response.result.data_array) > 0:
            return cast(str, response.result.data_array[0][0])
        return None

    def get_previous_deployment(
        self, environment: str, current_deployment_id: str
    ) -> DeploymentRecord | None:
        """Get the deployment that ran immediately before the given deployment (same environment).

        Used for partial rollback to compute "state before this deployment" when a prior
        deployment (e.g. failed) already created objects (e.g. catalog).

        Returns:
            Full deployment record with opsDetails, or None if no previous deployment exists.
        """
        try:
            current = self.get_deployment_by_id(current_deployment_id)
            deployed_at_str = _normalize_deployed_at(current.get("deployedAt")) if current else None
            if not current or not deployed_at_str:
                return None
            prev_id = self._query_previous_deployment_id(environment, deployed_at_str)
            return self.get_deployment_by_id(prev_id) if prev_id else None
        except Exception:
            return None

    def _execute_ddl(self, sql: str) -> None:
        """Execute DDL statement and wait for completion."""
        result = self._runner.run_sql(sql)
        if result.status == "failed":
            raise RuntimeError(f"DDL execution failed: {result.error_message}")

    def _get_deployments_table_ddl(self) -> str:
        """Get DDL for deployments table

        Returns:
            CREATE TABLE statement for deployments
        """
        return f"""
        CREATE TABLE IF NOT EXISTS {self.schema}.deployments (
            id STRING COMMENT 'Unique deployment ID',
            environment STRING COMMENT 'Target environment (dev/test/prod)',
            snapshot_version STRING COMMENT 'Snapshot version deployed (e.g., v1.0.0 or changelog)',
            from_snapshot_version STRING COMMENT 'Previous snapshot version (source of diff)',
            previous_deployment_id STRING COMMENT 'Deployment that was current before this one (for partial rollback)',
            snapshot_id STRING COMMENT 'Snapshot UUID',
            deployed_at TIMESTAMP COMMENT 'Deployment timestamp',
            deployed_by STRING COMMENT 'User/system that deployed',
            project_name STRING COMMENT 'SchemaX project name',
            provider_type STRING COMMENT 'Provider type (unity/hms)',
            provider_version STRING COMMENT 'Provider schema version',
            ops_count INT COMMENT 'Number of operations applied',
            status STRING COMMENT 'pending/success/failed/partial',
            error_message STRING COMMENT 'Error details if failed',
            execution_time_ms BIGINT COMMENT 'Execution time in milliseconds',
            schemax_version STRING COMMENT 'SchemaX CLI version',
            CONSTRAINT pk_deployments PRIMARY KEY (id)
        )
        COMMENT 'SchemaX deployment history'
        """

    def _get_deployment_ops_table_ddl(self) -> str:
        """Get DDL for deployment_ops table

        Returns:
            CREATE TABLE statement for deployment_ops
        """
        return f"""
        CREATE TABLE IF NOT EXISTS {self.schema}.deployment_ops (
            deployment_id STRING COMMENT 'Parent deployment ID',
            op_id STRING COMMENT 'Operation ID from changelog',
            op_type STRING COMMENT 'Operation type (e.g., unity.add_catalog)',
            op_target STRING COMMENT 'Target resource (catalog/schema/table ID)',
            op_payload STRING COMMENT 'Operation payload as JSON for exact matching',
            sql_statement STRING COMMENT 'Generated SQL for this operation',
            executed_at TIMESTAMP COMMENT 'Execution timestamp',
            execution_order INT COMMENT 'Order of execution',
            status STRING COMMENT 'pending/success/failed/skipped',
            error_message STRING COMMENT 'Error details if failed',
            CONSTRAINT pk_deployment_ops PRIMARY KEY (deployment_id, op_id)
        )
        COMMENT 'Individual operation tracking per deployment'
        """
