"""
Data sink utilities for landing g-gremlin outputs to external systems.

Supports:
- BigQuery (google-cloud-bigquery)
- S3 (boto3)
- GCS (google-cloud-storage)

All sinks support dry-run mode, schema validation, and return structured results.
"""

from __future__ import annotations

import csv
import hashlib
import json
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from g_gremlin.paths import gremlin_path


@dataclass
class SinkResult:
    """Result of a sink operation."""
    success: bool
    target_uri: str = ""
    rows_written: int = 0
    load_id: str = ""  # UUID for idempotence
    schema_hash: str = ""  # Hash of column names for validation
    errors: List[str] = field(default_factory=list)
    warnings: List[str] = field(default_factory=list)
    dry_run: bool = False

    def to_dict(self) -> Dict[str, Any]:
        return {
            "success": self.success,
            "target_uri": self.target_uri,
            "rows_written": self.rows_written,
            "load_id": self.load_id,
            "schema_hash": self.schema_hash,
            "errors": self.errors,
            "warnings": self.warnings,
            "dry_run": self.dry_run,
        }


@dataclass
class SchemaField:
    """Schema field definition for validation."""
    name: str
    type: str = "STRING"  # STRING, INTEGER, FLOAT, BOOLEAN, TIMESTAMP, DATE
    nullable: bool = True
    description: str = ""


def _generate_load_id() -> str:
    """Generate a unique load ID for idempotence."""
    return str(uuid.uuid4())


def _compute_schema_hash(columns: List[str]) -> str:
    """Compute a hash of column names for schema validation."""
    normalized = ",".join(sorted(col.lower().strip() for col in columns))
    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]


def _read_csv_preview(csv_path: Path, max_rows: int = 5) -> Tuple[List[str], List[List[str]], int]:
    """
    Read CSV header and preview rows.

    Returns:
        Tuple of (columns, preview_rows, total_row_count)
    """
    columns: List[str] = []
    preview_rows: List[List[str]] = []
    total_rows = 0

    with csv_path.open("r", encoding="utf-8", newline="") as f:
        reader = csv.reader(f)
        columns = next(reader, [])
        for i, row in enumerate(reader):
            total_rows += 1
            if i < max_rows:
                preview_rows.append(row)

    return columns, preview_rows, total_rows


def _load_schema_spec(schema_path: Path) -> List[SchemaField]:
    """Load schema specification from YAML or JSON file."""
    import yaml  # yaml is already a dependency

    with schema_path.open("r", encoding="utf-8") as f:
        if schema_path.suffix in (".yaml", ".yml"):
            data = yaml.safe_load(f)
        else:
            data = json.load(f)

    fields = []
    for field_def in data.get("fields", data.get("schema", [])):
        if isinstance(field_def, str):
            fields.append(SchemaField(name=field_def))
        elif isinstance(field_def, dict):
            fields.append(SchemaField(
                name=field_def.get("name", ""),
                type=field_def.get("type", "STRING").upper(),
                nullable=field_def.get("nullable", True),
                description=field_def.get("description", ""),
            ))
    return fields


def _validate_csv_against_schema(
    csv_columns: List[str],
    schema_fields: List[SchemaField],
) -> Tuple[bool, List[str]]:
    """
    Validate CSV columns against schema.

    Returns:
        Tuple of (is_valid, error_messages)
    """
    errors = []
    schema_names = {f.name.lower() for f in schema_fields}
    csv_names = {c.lower() for c in csv_columns}

    # Check for missing required columns
    for field in schema_fields:
        if not field.nullable and field.name.lower() not in csv_names:
            errors.append(f"Missing required column: {field.name}")

    # Check for extra columns not in schema (warning, not error)
    extra = csv_names - schema_names
    if extra:
        # This is a warning, not an error
        pass

    return len(errors) == 0, errors


# =============================================================================
# BigQuery Sink
# =============================================================================

def _get_bigquery_client():
    """Lazy import and create BigQuery client."""
    try:
        from google.cloud import bigquery
    except ImportError:
        raise ImportError(
            "BigQuery sink requires google-cloud-bigquery. "
            "Install with: pip install g-gremlin[sink-bq]"
        )

    # Check for credentials override
    creds_path = os.environ.get("G_GREMLIN_BQ_CREDENTIALS")
    if creds_path:
        return bigquery.Client.from_service_account_json(creds_path)

    return bigquery.Client()


def sink_to_bigquery(
    csv_path: Path,
    project: str,
    dataset: str,
    table: str,
    mode: str = "replace",  # replace, append, truncate
    schema_path: Optional[Path] = None,
    dry_run: bool = False,
) -> SinkResult:
    """
    Sink CSV data to BigQuery.

    Args:
        csv_path: Path to CSV file
        project: GCP project ID
        dataset: BigQuery dataset name
        table: BigQuery table name
        mode: Write mode (replace, append, truncate)
        schema_path: Optional schema file for validation
        dry_run: If True, validate without writing

    Returns:
        SinkResult with operation details
    """
    load_id = _generate_load_id()
    target_uri = f"bq://{project}.{dataset}.{table}"

    # Pre-flight: Read CSV metadata
    try:
        columns, preview_rows, row_count = _read_csv_preview(csv_path)
    except Exception as e:
        return SinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            errors=[f"Failed to read CSV: {e}"],
            dry_run=dry_run,
        )

    schema_hash = _compute_schema_hash(columns)

    # Validate against schema if provided
    warnings = []
    if schema_path:
        try:
            schema_fields = _load_schema_spec(schema_path)
            is_valid, schema_errors = _validate_csv_against_schema(columns, schema_fields)
            if not is_valid:
                return SinkResult(
                    success=False,
                    target_uri=target_uri,
                    load_id=load_id,
                    schema_hash=schema_hash,
                    errors=schema_errors,
                    dry_run=dry_run,
                )
        except Exception as e:
            return SinkResult(
                success=False,
                target_uri=target_uri,
                load_id=load_id,
                schema_hash=schema_hash,
                errors=[f"Failed to load schema: {e}"],
                dry_run=dry_run,
            )

    if dry_run:
        return SinkResult(
            success=True,
            target_uri=target_uri,
            rows_written=row_count,
            load_id=load_id,
            schema_hash=schema_hash,
            warnings=warnings,
            dry_run=True,
        )

    # Execute BigQuery load
    try:
        from google.cloud import bigquery

        client = _get_bigquery_client()
        table_ref = f"{project}.{dataset}.{table}"

        # Map mode to BigQuery write disposition
        write_disposition_map = {
            "replace": bigquery.WriteDisposition.WRITE_TRUNCATE,
            "truncate": bigquery.WriteDisposition.WRITE_TRUNCATE,
            "append": bigquery.WriteDisposition.WRITE_APPEND,
        }
        write_disposition = write_disposition_map.get(
            mode.lower(),
            bigquery.WriteDisposition.WRITE_TRUNCATE,
        )

        job_config = bigquery.LoadJobConfig(
            source_format=bigquery.SourceFormat.CSV,
            skip_leading_rows=1,  # Skip header
            autodetect=True if not schema_path else False,
            write_disposition=write_disposition,
        )

        with csv_path.open("rb") as f:
            load_job = client.load_table_from_file(
                f,
                table_ref,
                job_config=job_config,
            )

        # Wait for job to complete
        load_job.result()

        # Verify row count
        loaded_table = client.get_table(table_ref)
        actual_rows = loaded_table.num_rows

        if mode.lower() in ("replace", "truncate") and actual_rows != row_count:
            warnings.append(
                f"Row count mismatch: expected {row_count}, got {actual_rows}"
            )

        return SinkResult(
            success=True,
            target_uri=target_uri,
            rows_written=actual_rows,
            load_id=load_id,
            schema_hash=schema_hash,
            warnings=warnings,
            dry_run=False,
        )

    except ImportError:
        return SinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            schema_hash=schema_hash,
            errors=[
                "BigQuery sink requires google-cloud-bigquery. "
                "Install with: pip install g-gremlin[sink-bq]"
            ],
            dry_run=dry_run,
        )
    except Exception as e:
        return SinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            schema_hash=schema_hash,
            errors=[f"BigQuery load failed: {e}"],
            dry_run=dry_run,
        )


# =============================================================================
# S3 Sink
# =============================================================================

def _get_s3_client():
    """Lazy import and create S3 client."""
    try:
        import boto3
    except ImportError:
        raise ImportError(
            "S3 sink requires boto3. "
            "Install with: pip install g-gremlin[sink-s3]"
        )

    # Use standard AWS env vars or default credential chain
    region = os.environ.get("G_GREMLIN_S3_REGION", os.environ.get("AWS_DEFAULT_REGION"))
    if region:
        return boto3.client("s3", region_name=region)
    return boto3.client("s3")


def sink_to_s3(
    csv_path: Path,
    bucket: str,
    prefix: str = "",
    filename: Optional[str] = None,
    compress: bool = False,
    dry_run: bool = False,
) -> SinkResult:
    """
    Sink CSV data to S3.

    Args:
        csv_path: Path to CSV file
        bucket: S3 bucket name
        prefix: Key prefix (folder path)
        filename: Optional filename override (default: original filename)
        compress: If True, gzip compress before upload
        dry_run: If True, validate without uploading

    Returns:
        SinkResult with operation details
    """
    load_id = _generate_load_id()

    # Determine target key
    base_filename = filename or csv_path.name
    if compress and not base_filename.endswith(".gz"):
        base_filename = f"{base_filename}.gz"

    key = f"{prefix.rstrip('/')}/{base_filename}" if prefix else base_filename
    target_uri = f"s3://{bucket}/{key}"

    # Pre-flight: Read CSV metadata
    try:
        columns, preview_rows, row_count = _read_csv_preview(csv_path)
    except Exception as e:
        return SinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            errors=[f"Failed to read CSV: {e}"],
            dry_run=dry_run,
        )

    schema_hash = _compute_schema_hash(columns)

    if dry_run:
        return SinkResult(
            success=True,
            target_uri=target_uri,
            rows_written=row_count,
            load_id=load_id,
            schema_hash=schema_hash,
            dry_run=True,
        )

    # Execute S3 upload
    try:
        import boto3

        s3_client = _get_s3_client()

        if compress:
            import gzip
            import io

            with csv_path.open("rb") as f:
                csv_data = f.read()

            compressed = gzip.compress(csv_data)
            s3_client.put_object(
                Bucket=bucket,
                Key=key,
                Body=compressed,
                ContentType="text/csv",
                ContentEncoding="gzip",
                Metadata={"load_id": load_id, "schema_hash": schema_hash},
            )
        else:
            with csv_path.open("rb") as f:
                s3_client.put_object(
                    Bucket=bucket,
                    Key=key,
                    Body=f,
                    ContentType="text/csv",
                    Metadata={"load_id": load_id, "schema_hash": schema_hash},
                )

        return SinkResult(
            success=True,
            target_uri=target_uri,
            rows_written=row_count,
            load_id=load_id,
            schema_hash=schema_hash,
            dry_run=False,
        )

    except ImportError:
        return SinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            schema_hash=schema_hash,
            errors=[
                "S3 sink requires boto3. "
                "Install with: pip install g-gremlin[sink-s3]"
            ],
            dry_run=dry_run,
        )
    except Exception as e:
        return SinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            schema_hash=schema_hash,
            errors=[f"S3 upload failed: {e}"],
            dry_run=dry_run,
        )


# =============================================================================
# GCS Sink
# =============================================================================

def _get_gcs_client():
    """Lazy import and create GCS client."""
    try:
        from google.cloud import storage
    except ImportError:
        raise ImportError(
            "GCS sink requires google-cloud-storage. "
            "Install with: pip install g-gremlin[sink-gcs]"
        )

    # Check for credentials override
    creds_path = os.environ.get("G_GREMLIN_GCS_CREDENTIALS")
    if creds_path:
        return storage.Client.from_service_account_json(creds_path)

    return storage.Client()


def sink_to_gcs(
    csv_path: Path,
    bucket: str,
    prefix: str = "",
    filename: Optional[str] = None,
    compress: bool = False,
    dry_run: bool = False,
) -> SinkResult:
    """
    Sink CSV data to Google Cloud Storage.

    Args:
        csv_path: Path to CSV file
        bucket: GCS bucket name
        prefix: Blob prefix (folder path)
        filename: Optional filename override (default: original filename)
        compress: If True, gzip compress before upload
        dry_run: If True, validate without uploading

    Returns:
        SinkResult with operation details
    """
    load_id = _generate_load_id()

    # Determine target blob name
    base_filename = filename or csv_path.name
    if compress and not base_filename.endswith(".gz"):
        base_filename = f"{base_filename}.gz"

    blob_name = f"{prefix.rstrip('/')}/{base_filename}" if prefix else base_filename
    target_uri = f"gs://{bucket}/{blob_name}"

    # Pre-flight: Read CSV metadata
    try:
        columns, preview_rows, row_count = _read_csv_preview(csv_path)
    except Exception as e:
        return SinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            errors=[f"Failed to read CSV: {e}"],
            dry_run=dry_run,
        )

    schema_hash = _compute_schema_hash(columns)

    if dry_run:
        return SinkResult(
            success=True,
            target_uri=target_uri,
            rows_written=row_count,
            load_id=load_id,
            schema_hash=schema_hash,
            dry_run=True,
        )

    # Execute GCS upload
    try:
        from google.cloud import storage

        gcs_client = _get_gcs_client()
        bucket_obj = gcs_client.bucket(bucket)
        blob = bucket_obj.blob(blob_name)

        # Set metadata
        blob.metadata = {"load_id": load_id, "schema_hash": schema_hash}

        if compress:
            import gzip

            with csv_path.open("rb") as f:
                csv_data = f.read()

            compressed = gzip.compress(csv_data)
            blob.content_encoding = "gzip"
            blob.upload_from_string(compressed, content_type="text/csv")
        else:
            blob.upload_from_filename(str(csv_path), content_type="text/csv")

        return SinkResult(
            success=True,
            target_uri=target_uri,
            rows_written=row_count,
            load_id=load_id,
            schema_hash=schema_hash,
            dry_run=False,
        )

    except ImportError:
        return SinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            schema_hash=schema_hash,
            errors=[
                "GCS sink requires google-cloud-storage. "
                "Install with: pip install g-gremlin[sink-gcs]"
            ],
            dry_run=dry_run,
        )
    except Exception as e:
        return SinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            schema_hash=schema_hash,
            errors=[f"GCS upload failed: {e}"],
            dry_run=dry_run,
        )


# =============================================================================
# Config helpers
# =============================================================================

def load_sink_config() -> Dict[str, Any]:
    """
    Load sink configuration from the resolved g-gremlin home config.json.

    Returns config with structure:
    {
        "sink": {
            "bq": {"default_project": "myproject"},
            "s3": {"default_bucket": "my-bucket", "region": "us-east-1"},
            "gcs": {"default_bucket": "my-bucket"}
        }
    }
    """
    config_path = gremlin_path("config.json")
    if not config_path.exists():
        return {}

    try:
        with config_path.open("r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}


def get_default_project() -> Optional[str]:
    """Get default BQ project from env or config."""
    # Env var takes precedence
    if os.environ.get("G_GREMLIN_BQ_PROJECT"):
        return os.environ["G_GREMLIN_BQ_PROJECT"]

    config = load_sink_config()
    return config.get("sink", {}).get("bq", {}).get("default_project")


def get_default_s3_bucket() -> Optional[str]:
    """Get default S3 bucket from env or config."""
    if os.environ.get("G_GREMLIN_S3_BUCKET"):
        return os.environ["G_GREMLIN_S3_BUCKET"]

    config = load_sink_config()
    return config.get("sink", {}).get("s3", {}).get("default_bucket")


def get_default_gcs_bucket() -> Optional[str]:
    """Get default GCS bucket from env or config."""
    if os.environ.get("G_GREMLIN_GCS_BUCKET"):
        return os.environ["G_GREMLIN_GCS_BUCKET"]

    config = load_sink_config()
    return config.get("sink", {}).get("gcs", {}).get("default_bucket")


# =============================================================================
# Snowflake Sink (via S3/GCS staging)
# =============================================================================

@dataclass
class SnowflakeSinkResult(SinkResult):
    """Extended result for Snowflake sink with staging info."""
    stage_uri: str = ""
    manifest_path: str = ""
    copy_command: str = ""


def _get_snowflake_connection():
    """Lazy import and create Snowflake connection."""
    try:
        import snowflake.connector
    except ImportError:
        raise ImportError(
            "Snowflake sink requires snowflake-connector-python. "
            "Install with: pip install g-gremlin[sink-snowflake]"
        )

    # Get connection params from env vars
    account = os.environ.get("SNOWFLAKE_ACCOUNT")
    user = os.environ.get("SNOWFLAKE_USER")
    password = os.environ.get("SNOWFLAKE_PASSWORD")
    warehouse = os.environ.get("SNOWFLAKE_WAREHOUSE")

    if not all([account, user, password]):
        raise ValueError(
            "Snowflake credentials required. Set SNOWFLAKE_ACCOUNT, "
            "SNOWFLAKE_USER, and SNOWFLAKE_PASSWORD environment variables."
        )

    return snowflake.connector.connect(
        account=account,
        user=user,
        password=password,
        warehouse=warehouse,
    )


def sink_to_snowflake(
    csv_path: Path,
    stage_bucket: str,
    database: str,
    schema: str,
    table: str,
    mode: str = "replace",  # replace, append, truncate
    stage_prefix: str = "g_gremlin_staging",
    use_gcs: bool = False,  # False = S3, True = GCS
    dry_run: bool = False,
    execute_copy: bool = True,
) -> SnowflakeSinkResult:
    """
    Sink CSV data to Snowflake via S3/GCS staging.

    Flow:
    1. Upload CSV to staging bucket
    2. Generate COPY INTO command
    3. Execute COPY if credentials are available

    Args:
        csv_path: Path to CSV file
        stage_bucket: S3 or GCS bucket for staging
        database: Snowflake database name
        schema: Snowflake schema name
        table: Snowflake table name
        mode: Write mode (replace, append, truncate)
        stage_prefix: Prefix in staging bucket
        use_gcs: If True, use GCS instead of S3
        dry_run: If True, validate and generate command without executing
        execute_copy: If True, execute COPY INTO (requires Snowflake creds)

    Returns:
        SnowflakeSinkResult with staging and copy details
    """
    load_id = _generate_load_id()
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    target_uri = f"snowflake://{database}.{schema}.{table}"

    # Pre-flight: Read CSV metadata
    try:
        columns, preview_rows, row_count = _read_csv_preview(csv_path)
    except Exception as e:
        return SnowflakeSinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            errors=[f"Failed to read CSV: {e}"],
            dry_run=dry_run,
        )

    schema_hash = _compute_schema_hash(columns)

    # Stage the file
    stage_filename = f"{csv_path.stem}_{timestamp}_{load_id[:8]}.csv.gz"
    stage_key = f"{stage_prefix}/{stage_filename}"

    if use_gcs:
        stage_uri = f"gs://{stage_bucket}/{stage_key}"
        stage_result = sink_to_gcs(
            csv_path=csv_path,
            bucket=stage_bucket,
            prefix=stage_prefix,
            filename=stage_filename,
            compress=True,
            dry_run=dry_run,
        )
    else:
        stage_uri = f"s3://{stage_bucket}/{stage_key}"
        stage_result = sink_to_s3(
            csv_path=csv_path,
            bucket=stage_bucket,
            prefix=stage_prefix,
            filename=stage_filename,
            compress=True,
            dry_run=dry_run,
        )

    if not stage_result.success:
        return SnowflakeSinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            schema_hash=schema_hash,
            errors=[f"Staging failed: {'; '.join(stage_result.errors)}"],
            stage_uri=stage_uri,
            dry_run=dry_run,
        )

    # Write manifest
    manifest_path = ""
    if not dry_run:
        manifest_dir = Path(".g_gremlin/sink_manifests")
        manifest_dir.mkdir(parents=True, exist_ok=True)
        manifest_file = manifest_dir / f"{load_id}.json"
        manifest_data = {
            "load_id": load_id,
            "created_at": datetime.now(timezone.utc).isoformat(),
            "stage_uri": stage_uri,
            "target": target_uri,
            "row_count": row_count,
            "schema_hash": schema_hash,
            "mode": mode,
        }
        manifest_file.write_text(json.dumps(manifest_data, indent=2), encoding="utf-8")
        manifest_path = str(manifest_file)

    # Generate COPY INTO command
    truncate_stmt = ""
    if mode.lower() in ("replace", "truncate"):
        truncate_stmt = f"TRUNCATE TABLE {database}.{schema}.{table};\n"

    copy_command = f"""{truncate_stmt}COPY INTO {database}.{schema}.{table}
FROM '{stage_uri}'
FILE_FORMAT = (TYPE = 'CSV' SKIP_HEADER = 1 COMPRESSION = 'GZIP')
ON_ERROR = 'ABORT_STATEMENT';"""

    if dry_run:
        return SnowflakeSinkResult(
            success=True,
            target_uri=target_uri,
            rows_written=row_count,
            load_id=load_id,
            schema_hash=schema_hash,
            stage_uri=stage_uri,
            manifest_path=manifest_path,
            copy_command=copy_command,
            dry_run=True,
        )

    # Execute COPY if requested and credentials available
    if execute_copy:
        try:
            conn = _get_snowflake_connection()
            cursor = conn.cursor()

            if mode.lower() in ("replace", "truncate"):
                cursor.execute(f"TRUNCATE TABLE {database}.{schema}.{table}")

            cursor.execute(f"""
                COPY INTO {database}.{schema}.{table}
                FROM '{stage_uri}'
                FILE_FORMAT = (TYPE = 'CSV' SKIP_HEADER = 1 COMPRESSION = 'GZIP')
                ON_ERROR = 'ABORT_STATEMENT'
            """)

            # Get rows loaded from result
            result = cursor.fetchone()
            rows_loaded = result[0] if result else row_count

            cursor.close()
            conn.close()

            return SnowflakeSinkResult(
                success=True,
                target_uri=target_uri,
                rows_written=rows_loaded,
                load_id=load_id,
                schema_hash=schema_hash,
                stage_uri=stage_uri,
                manifest_path=manifest_path,
                copy_command=copy_command,
                dry_run=False,
            )

        except ImportError:
            # No Snowflake connector - just stage the file
            return SnowflakeSinkResult(
                success=True,
                target_uri=target_uri,
                rows_written=row_count,
                load_id=load_id,
                schema_hash=schema_hash,
                stage_uri=stage_uri,
                manifest_path=manifest_path,
                copy_command=copy_command,
                warnings=["Snowflake connector not installed. File staged; run COPY manually."],
                dry_run=False,
            )
        except Exception as e:
            return SnowflakeSinkResult(
                success=False,
                target_uri=target_uri,
                load_id=load_id,
                schema_hash=schema_hash,
                stage_uri=stage_uri,
                manifest_path=manifest_path,
                copy_command=copy_command,
                errors=[f"Snowflake COPY failed: {e}"],
                dry_run=False,
            )

    # Just staging, no COPY execution
    return SnowflakeSinkResult(
        success=True,
        target_uri=target_uri,
        rows_written=row_count,
        load_id=load_id,
        schema_hash=schema_hash,
        stage_uri=stage_uri,
        manifest_path=manifest_path,
        copy_command=copy_command,
        warnings=["File staged. Run the COPY command manually to load into Snowflake."],
        dry_run=False,
    )


# =============================================================================
# Redshift Sink (via S3 staging)
# =============================================================================

@dataclass
class RedshiftSinkResult(SinkResult):
    """Extended result for Redshift sink with staging info."""
    stage_uri: str = ""
    manifest_path: str = ""
    copy_command: str = ""


def _get_redshift_connection(
    host: str,
    port: int,
    database: str,
    user: str,
    password: str,
):
    """Lazy import and create Redshift connection via psycopg2."""
    try:
        import psycopg2
    except ImportError:
        raise ImportError(
            "Redshift sink requires psycopg2. "
            "Install with: pip install g-gremlin[sink-redshift]"
        )

    return psycopg2.connect(
        host=host,
        port=port,
        dbname=database,
        user=user,
        password=password,
    )


def sink_to_redshift(
    csv_path: Path,
    stage_bucket: str,
    host: str,
    database: str,
    schema: str,
    table: str,
    iam_role: str,
    mode: str = "replace",  # replace, append, truncate
    stage_prefix: str = "g_gremlin_staging",
    port: int = 5439,
    dry_run: bool = False,
    execute_copy: bool = True,
) -> RedshiftSinkResult:
    """
    Sink CSV data to Redshift via S3 staging.

    Flow:
    1. Upload CSV to S3 staging bucket
    2. Generate COPY command with IAM role
    3. Execute COPY if credentials are available

    Args:
        csv_path: Path to CSV file
        stage_bucket: S3 bucket for staging
        host: Redshift cluster endpoint
        database: Redshift database name
        schema: Redshift schema name
        table: Redshift table name
        iam_role: IAM role ARN for COPY command
        mode: Write mode (replace, append, truncate)
        stage_prefix: Prefix in staging bucket
        port: Redshift port (default: 5439)
        dry_run: If True, validate and generate command without executing
        execute_copy: If True, execute COPY (requires Redshift creds)

    Returns:
        RedshiftSinkResult with staging and copy details
    """
    load_id = _generate_load_id()
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    target_uri = f"redshift://{host}/{database}.{schema}.{table}"

    # Pre-flight: Read CSV metadata
    try:
        columns, preview_rows, row_count = _read_csv_preview(csv_path)
    except Exception as e:
        return RedshiftSinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            errors=[f"Failed to read CSV: {e}"],
            dry_run=dry_run,
        )

    schema_hash = _compute_schema_hash(columns)

    # Stage the file to S3
    stage_filename = f"{csv_path.stem}_{timestamp}_{load_id[:8]}.csv.gz"
    stage_key = f"{stage_prefix}/{stage_filename}"
    stage_uri = f"s3://{stage_bucket}/{stage_key}"

    stage_result = sink_to_s3(
        csv_path=csv_path,
        bucket=stage_bucket,
        prefix=stage_prefix,
        filename=stage_filename,
        compress=True,
        dry_run=dry_run,
    )

    if not stage_result.success:
        return RedshiftSinkResult(
            success=False,
            target_uri=target_uri,
            load_id=load_id,
            schema_hash=schema_hash,
            errors=[f"S3 staging failed: {'; '.join(stage_result.errors)}"],
            stage_uri=stage_uri,
            dry_run=dry_run,
        )

    # Write manifest
    manifest_path = ""
    if not dry_run:
        manifest_dir = Path(".g_gremlin/sink_manifests")
        manifest_dir.mkdir(parents=True, exist_ok=True)
        manifest_file = manifest_dir / f"{load_id}.json"
        manifest_data = {
            "load_id": load_id,
            "created_at": datetime.now(timezone.utc).isoformat(),
            "stage_uri": stage_uri,
            "target": target_uri,
            "row_count": row_count,
            "schema_hash": schema_hash,
            "mode": mode,
        }
        manifest_file.write_text(json.dumps(manifest_data, indent=2), encoding="utf-8")
        manifest_path = str(manifest_file)

    # Generate COPY command
    truncate_stmt = ""
    if mode.lower() in ("replace", "truncate"):
        truncate_stmt = f"TRUNCATE TABLE {schema}.{table};\n"

    copy_command = f"""{truncate_stmt}COPY {schema}.{table}
FROM '{stage_uri}'
IAM_ROLE '{iam_role}'
FORMAT AS CSV
IGNOREHEADER 1
GZIP;"""

    if dry_run:
        return RedshiftSinkResult(
            success=True,
            target_uri=target_uri,
            rows_written=row_count,
            load_id=load_id,
            schema_hash=schema_hash,
            stage_uri=stage_uri,
            manifest_path=manifest_path,
            copy_command=copy_command,
            dry_run=True,
        )

    # Execute COPY if requested
    if execute_copy:
        # Get credentials from env
        user = os.environ.get("REDSHIFT_USER")
        password = os.environ.get("REDSHIFT_PASSWORD")

        if not all([user, password]):
            return RedshiftSinkResult(
                success=True,
                target_uri=target_uri,
                rows_written=row_count,
                load_id=load_id,
                schema_hash=schema_hash,
                stage_uri=stage_uri,
                manifest_path=manifest_path,
                copy_command=copy_command,
                warnings=["REDSHIFT_USER/REDSHIFT_PASSWORD not set. File staged; run COPY manually."],
                dry_run=False,
            )

        try:
            conn = _get_redshift_connection(host, port, database, user, password)
            cursor = conn.cursor()

            if mode.lower() in ("replace", "truncate"):
                cursor.execute(f"TRUNCATE TABLE {schema}.{table}")

            cursor.execute(f"""
                COPY {schema}.{table}
                FROM '{stage_uri}'
                IAM_ROLE '{iam_role}'
                FORMAT AS CSV
                IGNOREHEADER 1
                GZIP
            """)

            conn.commit()
            cursor.close()
            conn.close()

            return RedshiftSinkResult(
                success=True,
                target_uri=target_uri,
                rows_written=row_count,
                load_id=load_id,
                schema_hash=schema_hash,
                stage_uri=stage_uri,
                manifest_path=manifest_path,
                copy_command=copy_command,
                dry_run=False,
            )

        except ImportError:
            return RedshiftSinkResult(
                success=True,
                target_uri=target_uri,
                rows_written=row_count,
                load_id=load_id,
                schema_hash=schema_hash,
                stage_uri=stage_uri,
                manifest_path=manifest_path,
                copy_command=copy_command,
                warnings=["psycopg2 not installed. File staged; run COPY manually."],
                dry_run=False,
            )
        except Exception as e:
            return RedshiftSinkResult(
                success=False,
                target_uri=target_uri,
                load_id=load_id,
                schema_hash=schema_hash,
                stage_uri=stage_uri,
                manifest_path=manifest_path,
                copy_command=copy_command,
                errors=[f"Redshift COPY failed: {e}"],
                dry_run=False,
            )

    # Just staging, no COPY execution
    return RedshiftSinkResult(
        success=True,
        target_uri=target_uri,
        rows_written=row_count,
        load_id=load_id,
        schema_hash=schema_hash,
        stage_uri=stage_uri,
        manifest_path=manifest_path,
        copy_command=copy_command,
        warnings=["File staged. Run the COPY command manually to load into Redshift."],
        dry_run=False,
    )


# =============================================================================
# Cleanup utilities
# =============================================================================

def cleanup_staged_files(
    older_than_days: int = 7,
    manifests_dir: Optional[Path] = None,
    delete_from_cloud: bool = False,
    dry_run: bool = False,
) -> Dict[str, Any]:
    """
    Clean up old staging files and manifests.

    Args:
        older_than_days: Delete files older than this many days
        manifests_dir: Directory containing manifests (default: .g_gremlin/sink_manifests)
        delete_from_cloud: If True, also delete staged files from S3/GCS
        dry_run: If True, report what would be deleted without deleting

    Returns:
        Dict with cleanup results
    """
    manifests_dir = manifests_dir or Path(".g_gremlin/sink_manifests")

    if not manifests_dir.exists():
        return {
            "manifests_deleted": 0,
            "staged_files_deleted": 0,
            "errors": [],
            "dry_run": dry_run,
        }

    cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
    manifests_deleted = 0
    staged_files_deleted = 0
    errors = []

    for manifest_path in manifests_dir.glob("*.json"):
        try:
            manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
            created_at = datetime.fromisoformat(manifest["created_at"].replace("Z", "+00:00"))

            if created_at < cutoff:
                stage_uri = manifest.get("stage_uri", "")

                # Delete staged file from cloud if requested
                if delete_from_cloud and stage_uri:
                    try:
                        if stage_uri.startswith("s3://"):
                            _delete_s3_object(stage_uri, dry_run)
                            if not dry_run:
                                staged_files_deleted += 1
                        elif stage_uri.startswith("gs://"):
                            _delete_gcs_object(stage_uri, dry_run)
                            if not dry_run:
                                staged_files_deleted += 1
                    except Exception as e:
                        errors.append(f"Failed to delete {stage_uri}: {e}")

                # Delete manifest
                if not dry_run:
                    manifest_path.unlink()
                manifests_deleted += 1

        except Exception as e:
            errors.append(f"Failed to process {manifest_path}: {e}")

    return {
        "manifests_deleted": manifests_deleted,
        "staged_files_deleted": staged_files_deleted,
        "errors": errors,
        "dry_run": dry_run,
    }


def _delete_s3_object(uri: str, dry_run: bool = False) -> None:
    """Delete an S3 object by URI."""
    if dry_run:
        return

    try:
        import boto3
    except ImportError:
        raise ImportError("S3 deletion requires boto3")

    # Parse URI: s3://bucket/key
    parts = uri.replace("s3://", "").split("/", 1)
    bucket = parts[0]
    key = parts[1] if len(parts) > 1 else ""

    client = _get_s3_client()
    client.delete_object(Bucket=bucket, Key=key)


def _delete_gcs_object(uri: str, dry_run: bool = False) -> None:
    """Delete a GCS object by URI."""
    if dry_run:
        return

    try:
        from google.cloud import storage
    except ImportError:
        raise ImportError("GCS deletion requires google-cloud-storage")

    # Parse URI: gs://bucket/blob
    parts = uri.replace("gs://", "").split("/", 1)
    bucket_name = parts[0]
    blob_name = parts[1] if len(parts) > 1 else ""

    client = _get_gcs_client()
    bucket = client.bucket(bucket_name)
    blob = bucket.blob(blob_name)
    blob.delete()


# Need to import timedelta for cleanup
from datetime import timedelta
