# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
"""CLI commands for Airbyte Cloud operations.

Commands:
    airbyte-ops cloud connector get-version-info - Get connector version info
    airbyte-ops cloud connector set-version-override - Set connector version override
    airbyte-ops cloud connector clear-version-override - Clear connector version override
    airbyte-ops cloud connector regression-test - Run regression tests (single-version or comparison)
    airbyte-ops cloud connector fetch-connection-config - Fetch connection config to local file
"""

from __future__ import annotations

import json
import os
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Annotated, Literal

import requests
import yaml
from airbyte.cloud.workspaces import CloudWorkspace
from airbyte_cdk.models.connector_metadata import MetadataFile
from airbyte_cdk.utils.connector_paths import find_connector_root_from_name
from airbyte_cdk.utils.docker import build_connector_image, verify_docker_installation
from airbyte_protocol.models import ConfiguredAirbyteCatalog
from cyclopts import App, Parameter

from airbyte_ops_mcp.cli._base import app
from airbyte_ops_mcp.cli._shared import (
    exit_with_error,
    print_error,
    print_json,
    print_success,
    print_warning,
)
from airbyte_ops_mcp.cloud_admin.connection_config import fetch_connection_config
from airbyte_ops_mcp.constants import (
    CLOUD_SQL_INSTANCE,
    CLOUD_SQL_PROXY_PID_FILE,
    DEFAULT_CLOUD_SQL_PROXY_PORT,
    ENV_GCP_PROD_DB_ACCESS_CREDENTIALS,
)
from airbyte_ops_mcp.gcp_logs import GCPSeverity, fetch_error_logs
from airbyte_ops_mcp.mcp.cloud_connector_versions import (
    get_cloud_connector_version,
    set_cloud_connector_version_override,
)
from airbyte_ops_mcp.regression_tests.cdk_secrets import get_first_config_from_secrets
from airbyte_ops_mcp.regression_tests.ci_output import (
    generate_regression_report,
    generate_single_version_report,
    write_github_output,
    write_github_outputs,
    write_github_summary,
    write_json_output,
    write_test_summary,
)
from airbyte_ops_mcp.regression_tests.config_overrides import (
    filter_configured_catalog_file,
)
from airbyte_ops_mcp.regression_tests.connection_fetcher import (
    fetch_connection_data,
    save_connection_data_to_files,
)
from airbyte_ops_mcp.regression_tests.connection_secret_retriever import (
    SecretRetrievalError,
    enrich_config_with_secrets,
    should_use_secret_retriever,
)
from airbyte_ops_mcp.regression_tests.connector_runner import (
    ConnectorRunner,
    ensure_image_available,
)
from airbyte_ops_mcp.regression_tests.http_metrics import (
    MitmproxyManager,
    parse_http_dump,
)
from airbyte_ops_mcp.regression_tests.models import (
    Command,
    ConnectorUnderTest,
    ExecutionInputs,
    TargetOrControl,
)
from airbyte_ops_mcp.telemetry import track_regression_test

# Path to connectors directory within the airbyte repo
CONNECTORS_SUBDIR = Path("airbyte-integrations") / "connectors"

# Create the cloud sub-app
cloud_app = App(name="cloud", help="Airbyte Cloud operations.")
app.command(cloud_app)

# Create the connector sub-app under cloud
connector_app = App(
    name="connector", help="Deployed connector operations in Airbyte Cloud."
)
cloud_app.command(connector_app)

# Create the db sub-app under cloud
db_app = App(name="db", help="Database operations for Airbyte Cloud Prod DB Replica.")
cloud_app.command(db_app)

# Create the connection sub-app under cloud
connection_app = App(name="connection", help="Connection operations in Airbyte Cloud.")
cloud_app.command(connection_app)

# Create the state sub-app under connection
state_app = App(name="state", help="Connection state operations in Airbyte Cloud.")
connection_app.command(state_app)

# Create the catalog sub-app under connection
catalog_app = App(
    name="catalog", help="Connection catalog operations in Airbyte Cloud."
)
connection_app.command(catalog_app)

# Create the logs sub-app under cloud
logs_app = App(name="logs", help="GCP Cloud Logging operations for Airbyte Cloud.")
cloud_app.command(logs_app)


@db_app.command(name="start-proxy")
def start_proxy(
    port: Annotated[
        int,
        Parameter(help="Port for the Cloud SQL Proxy to listen on."),
    ] = DEFAULT_CLOUD_SQL_PROXY_PORT,
    daemon: Annotated[
        bool,
        Parameter(
            help="Run as daemon in background (default). Use --no-daemon for foreground."
        ),
    ] = True,
) -> None:
    """Start the Cloud SQL Proxy for database access.

    This command starts the Cloud SQL Auth Proxy to enable connections to the
    Airbyte Cloud Prod DB Replica. The proxy is required for database query tools.

    By default, runs as a daemon (background process). Use --no-daemon to run in
    foreground mode where you can see logs and stop with Ctrl+C.

    Credentials are read from the GCP_PROD_DB_ACCESS_CREDENTIALS environment variable,
    which should contain the service account JSON credentials.

    After starting the proxy, set these environment variables to use database tools:
        export USE_CLOUD_SQL_PROXY=1
        export DB_PORT={port}

    Example:
        airbyte-ops cloud db start-proxy
        airbyte-ops cloud db start-proxy --port 15432
        airbyte-ops cloud db start-proxy --no-daemon
    """
    # Check if proxy is already running on the requested port (idempotency)
    try:
        with socket.create_connection(("127.0.0.1", port), timeout=0.5):
            # Something is already listening on this port
            pid_file = Path(CLOUD_SQL_PROXY_PID_FILE)
            pid_info = ""
            if pid_file.exists():
                pid_info = f" (PID: {pid_file.read_text().strip()})"
            print_success(
                f"Cloud SQL Proxy is already running on port {port}{pid_info}"
            )
            print_success("")
            print_success("To use database tools, set these environment variables:")
            print_success("  export USE_CLOUD_SQL_PROXY=1")
            print_success(f"  export DB_PORT={port}")
            return
    except (OSError, TimeoutError, ConnectionRefusedError):
        pass  # Port not in use, proceed with starting proxy

    # Check if cloud-sql-proxy is installed
    proxy_path = shutil.which("cloud-sql-proxy")
    if not proxy_path:
        exit_with_error(
            "cloud-sql-proxy not found in PATH. "
            "Install it from: https://cloud.google.com/sql/docs/mysql/sql-proxy"
        )

    # Get credentials from environment
    creds_json = os.getenv(ENV_GCP_PROD_DB_ACCESS_CREDENTIALS)
    if not creds_json:
        exit_with_error(
            f"{ENV_GCP_PROD_DB_ACCESS_CREDENTIALS} environment variable is not set. "
            "This should contain the GCP service account JSON credentials."
        )

    # Build the command using --json-credentials to avoid writing to disk
    cmd = [
        proxy_path,
        CLOUD_SQL_INSTANCE,
        f"--port={port}",
        f"--json-credentials={creds_json}",
    ]

    print_success(f"Starting Cloud SQL Proxy on port {port}...")
    print_success(f"Instance: {CLOUD_SQL_INSTANCE}")
    print_success("")
    print_success("To use database tools, set these environment variables:")
    print_success("  export USE_CLOUD_SQL_PROXY=1")
    print_success(f"  export DB_PORT={port}")
    print_success("")

    if daemon:
        # Run in background (daemon mode) with log file for diagnostics
        log_file_path = Path("/tmp/airbyte-cloud-sql-proxy.log")
        log_file = log_file_path.open("ab")
        process = subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=log_file,
            start_new_session=True,
        )

        # Brief wait to verify the process started successfully
        time.sleep(0.5)
        if process.poll() is not None:
            # Process exited immediately - read any error output
            log_file.close()
            error_output = ""
            if log_file_path.exists():
                error_output = log_file_path.read_text()[-1000:]  # Last 1000 chars
            exit_with_error(
                f"Cloud SQL Proxy failed to start (exit code: {process.returncode}).\n"
                f"Check logs at {log_file_path}\n"
                f"Recent output: {error_output}"
            )

        # Write PID to file for stop-proxy command
        pid_file = Path(CLOUD_SQL_PROXY_PID_FILE)
        pid_file.write_text(str(process.pid))
        print_success(f"Cloud SQL Proxy started as daemon (PID: {process.pid})")
        print_success(f"Logs: {log_file_path}")
        print_success("To stop: airbyte-ops cloud db stop-proxy")
    else:
        # Run in foreground - replace current process
        # Signals (Ctrl+C) will be handled directly by the cloud-sql-proxy process
        print_success("Running in foreground. Press Ctrl+C to stop the proxy.")
        print_success("")
        os.execv(proxy_path, cmd)


@db_app.command(name="stop-proxy")
def stop_proxy() -> None:
    """Stop the Cloud SQL Proxy daemon.

    This command stops a Cloud SQL Proxy that was started with 'start-proxy'.
    It reads the PID from the PID file and sends a SIGTERM signal to stop the process.

    Example:
        airbyte-ops cloud db stop-proxy
    """
    pid_file = Path(CLOUD_SQL_PROXY_PID_FILE)

    if not pid_file.exists():
        exit_with_error(
            f"PID file not found at {CLOUD_SQL_PROXY_PID_FILE}. "
            "No Cloud SQL Proxy daemon appears to be running."
        )

    pid_str = pid_file.read_text().strip()
    if not pid_str.isdigit():
        pid_file.unlink()
        exit_with_error(f"Invalid PID in {CLOUD_SQL_PROXY_PID_FILE}: {pid_str}")

    pid = int(pid_str)

    # Check if process is still running
    try:
        os.kill(pid, 0)  # Signal 0 just checks if process exists
    except ProcessLookupError:
        pid_file.unlink()
        print_success(
            f"Cloud SQL Proxy (PID: {pid}) is not running. Cleaned up PID file."
        )
        return
    except PermissionError:
        exit_with_error(f"Permission denied to check process {pid}.")

    # Send SIGTERM to stop the process
    try:
        os.kill(pid, signal.SIGTERM)
        print_success(f"Sent SIGTERM to Cloud SQL Proxy (PID: {pid}).")
    except ProcessLookupError:
        print_success(f"Cloud SQL Proxy (PID: {pid}) already stopped.")
    except PermissionError:
        exit_with_error(f"Permission denied to stop process {pid}.")

    # Clean up PID file
    pid_file.unlink(missing_ok=True)
    print_success("Cloud SQL Proxy stopped.")


@connector_app.command(name="get-version-info")
def get_version_info(
    workspace_id: Annotated[
        str,
        Parameter(help="The Airbyte Cloud workspace ID."),
    ],
    connector_id: Annotated[
        str,
        Parameter(help="The ID of the deployed connector (source or destination)."),
    ],
    connector_type: Annotated[
        Literal["source", "destination"],
        Parameter(help="The type of connector."),
    ],
) -> None:
    """Get the current version information for a deployed connector."""
    result = get_cloud_connector_version(
        workspace_id=workspace_id,
        actor_id=connector_id,
        actor_type=connector_type,
    )
    print_json(result.model_dump())


@connector_app.command(name="set-version-override")
def set_version_override(
    workspace_id: Annotated[
        str,
        Parameter(help="The Airbyte Cloud workspace ID."),
    ],
    connector_id: Annotated[
        str,
        Parameter(help="The ID of the deployed connector (source or destination)."),
    ],
    connector_type: Annotated[
        Literal["source", "destination"],
        Parameter(help="The type of connector."),
    ],
    version: Annotated[
        str,
        Parameter(
            help="The semver version string to pin to (e.g., '2.1.5-preview.abc1234')."
        ),
    ],
    reason: Annotated[
        str,
        Parameter(help="Explanation for the override (min 10 characters)."),
    ],
    issue_url: Annotated[
        str,
        Parameter(help="GitHub issue URL providing context for this operation."),
    ],
    approval_comment_url: Annotated[
        str,
        Parameter(help="GitHub comment URL where admin authorized this deployment."),
    ],
    ai_agent_session_url: Annotated[
        str | None,
        Parameter(
            help="URL to AI agent session driving this operation (for auditability)."
        ),
    ] = None,
    reason_url: Annotated[
        str | None,
        Parameter(help="Optional URL with more context (e.g., issue link)."),
    ] = None,
) -> None:
    """Set a version override for a deployed connector.

    Requires admin authentication via AIRBYTE_INTERNAL_ADMIN_FLAG and
    AIRBYTE_INTERNAL_ADMIN_USER environment variables.
    """
    admin_user_email = os.environ.get("AIRBYTE_INTERNAL_ADMIN_USER")
    result = set_cloud_connector_version_override(
        workspace_id=workspace_id,
        actor_id=connector_id,
        actor_type=connector_type,
        version=version,
        unset=False,
        override_reason=reason,
        override_reason_reference_url=reason_url,
        admin_user_email=admin_user_email,
        issue_url=issue_url,
        approval_comment_url=approval_comment_url,
        ai_agent_session_url=ai_agent_session_url,
    )
    if result.success:
        print_success(result.message)
    else:
        print_error(result.message)
    print_json(result.model_dump())


@connector_app.command(name="clear-version-override")
def clear_version_override(
    workspace_id: Annotated[
        str,
        Parameter(help="The Airbyte Cloud workspace ID."),
    ],
    connector_id: Annotated[
        str,
        Parameter(help="The ID of the deployed connector (source or destination)."),
    ],
    connector_type: Annotated[
        Literal["source", "destination"],
        Parameter(help="The type of connector."),
    ],
    issue_url: Annotated[
        str,
        Parameter(help="GitHub issue URL providing context for this operation."),
    ],
    approval_comment_url: Annotated[
        str,
        Parameter(help="GitHub comment URL where admin authorized this deployment."),
    ],
    ai_agent_session_url: Annotated[
        str | None,
        Parameter(
            help="URL to AI agent session driving this operation (for auditability)."
        ),
    ] = None,
) -> None:
    """Clear a version override from a deployed connector.

    Requires admin authentication via AIRBYTE_INTERNAL_ADMIN_FLAG and
    AIRBYTE_INTERNAL_ADMIN_USER environment variables.
    """
    admin_user_email = os.environ.get("AIRBYTE_INTERNAL_ADMIN_USER")
    result = set_cloud_connector_version_override(
        workspace_id=workspace_id,
        actor_id=connector_id,
        actor_type=connector_type,
        version=None,
        unset=True,
        override_reason=None,
        override_reason_reference_url=None,
        admin_user_email=admin_user_email,
        issue_url=issue_url,
        approval_comment_url=approval_comment_url,
        ai_agent_session_url=ai_agent_session_url,
    )
    if result.success:
        print_success(result.message)
    else:
        print_error(result.message)
    print_json(result.model_dump())


def _load_json_file(file_path: Path) -> dict | None:
    """Load a JSON file and return its contents.

    Returns None if the file doesn't exist or contains invalid JSON.
    """
    if not file_path.exists():
        return None
    try:
        return json.loads(file_path.read_text())
    except json.JSONDecodeError as e:
        print_error(f"Failed to parse JSON in file: {file_path}\nError: {e}")
        return None


def _run_connector_command(
    connector_image: str,
    command: Command,
    output_dir: Path,
    target_or_control: TargetOrControl,
    config_path: Path | None = None,
    catalog_path: Path | None = None,
    state_path: Path | None = None,
    proxy_url: str | None = None,
) -> dict:
    """Run a connector command and return results as a dict.

    Args:
        connector_image: Full connector image name with tag.
        command: The Airbyte command to run.
        output_dir: Directory to store output files.
        target_or_control: Whether this is target or control version.
        config_path: Path to connector config JSON file.
        catalog_path: Path to configured catalog JSON file.
        state_path: Path to state JSON file.
        proxy_url: Optional HTTP proxy URL for traffic capture.

    Returns:
        Dictionary with execution results.
    """
    connector = ConnectorUnderTest.from_image_name(connector_image, target_or_control)

    config = _load_json_file(config_path) if config_path else None
    state = _load_json_file(state_path) if state_path else None

    configured_catalog = None
    if catalog_path and catalog_path.exists():
        catalog_json = catalog_path.read_text()
        configured_catalog = ConfiguredAirbyteCatalog.parse_raw(catalog_json)

    execution_inputs = ExecutionInputs(
        connector_under_test=connector,
        command=command,
        output_dir=output_dir,
        config=config,
        configured_catalog=configured_catalog,
        state=state,
    )

    runner = ConnectorRunner(execution_inputs, proxy_url=proxy_url)
    result = runner.run()

    result.save_artifacts(output_dir)

    return {
        "connector": connector_image,
        "command": command.value,
        "success": result.success,
        "exit_code": result.exit_code,
        "stdout_file": str(result.stdout_file_path),
        "stderr_file": str(result.stderr_file_path),
        "message_counts": {
            k.value: v for k, v in result.get_message_count_per_type().items()
        },
        "record_counts_per_stream": result.get_record_count_per_stream(),
    }


def _build_connector_image_from_source(
    connector_name: str,
    repo_root: Path | None = None,
    tag: str = "dev",
) -> str | None:
    """Build a connector image from source code.

    Args:
        connector_name: Name of the connector (e.g., 'source-pokeapi').
        repo_root: Optional path to the airbyte repo root. If not provided,
            will attempt to auto-detect from current directory.
        tag: Tag to apply to the built image (default: 'dev').

    Returns:
        The full image name with tag if successful, None if build fails.
    """
    if not verify_docker_installation():
        print_error("Docker is not installed or not running")
        return None

    try:
        connector_directory = find_connector_root_from_name(connector_name)
    except FileNotFoundError:
        if repo_root:
            connector_directory = repo_root / CONNECTORS_SUBDIR / connector_name
            if not connector_directory.exists():
                print_error(f"Connector directory not found: {connector_directory}")
                return None
        else:
            print_error(
                f"Could not find connector '{connector_name}'. "
                "Try providing --repo-root to specify the airbyte repo location."
            )
            return None

    metadata_file_path = connector_directory / "metadata.yaml"
    if not metadata_file_path.exists():
        print_error(f"metadata.yaml not found at {metadata_file_path}")
        return None

    metadata = MetadataFile.from_file(metadata_file_path)
    print_success(f"Building image for connector: {connector_name}")

    built_image = build_connector_image(
        connector_name=connector_name,
        connector_directory=connector_directory,
        metadata=metadata,
        tag=tag,
        no_verify=False,
    )
    print_success(f"Successfully built image: {built_image}")
    return built_image


def _fetch_control_image_from_metadata(connector_name: str) -> str | None:
    """Fetch the current released connector image from metadata.yaml on main branch.

    This fetches the connector's metadata.yaml from the airbyte monorepo's master branch
    and extracts the dockerRepository and dockerImageTag to construct the control image.

    Args:
        connector_name: The connector name (e.g., 'source-github').

    Returns:
        The full connector image with tag (e.g., 'airbyte/source-github:1.0.0'),
        or None if the metadata could not be fetched or parsed.
    """
    metadata_url = (
        f"https://raw.githubusercontent.com/airbytehq/airbyte/master/"
        f"airbyte-integrations/connectors/{connector_name}/metadata.yaml"
    )
    response = requests.get(metadata_url, timeout=30)
    if not response.ok:
        print_error(
            f"Failed to fetch metadata for {connector_name}: "
            f"HTTP {response.status_code} from {metadata_url}"
        )
        return None

    metadata = yaml.safe_load(response.text)
    if not isinstance(metadata, dict):
        print_error(f"Invalid metadata format for {connector_name}: expected dict")
        return None

    data = metadata.get("data", {})
    docker_repository = data.get("dockerRepository")
    docker_image_tag = data.get("dockerImageTag")

    if not docker_repository or not docker_image_tag:
        print_error(
            f"Could not find dockerRepository/dockerImageTag in metadata for {connector_name}"
        )
        return None

    return f"{docker_repository}:{docker_image_tag}"


def _run_with_optional_http_metrics(
    connector_image: str,
    command: Command,
    output_dir: Path,
    target_or_control: TargetOrControl,
    enable_http_metrics: bool,
    config_path: Path | None,
    catalog_path: Path | None,
    state_path: Path | None,
) -> dict:
    """Run a connector command with optional HTTP metrics capture.

    When enable_http_metrics is True, starts mitmproxy to capture HTTP traffic.
    If mitmproxy fails to start, falls back to running without metrics.

    Args:
        connector_image: Full connector image name with tag.
        command: The Airbyte command to run.
        output_dir: Directory to store output files.
        target_or_control: Whether this is target or control version.
        enable_http_metrics: Whether to capture HTTP metrics via mitmproxy.
        config_path: Path to connector config JSON file.
        catalog_path: Path to configured catalog JSON file.
        state_path: Path to state JSON file.

    Returns:
        Dictionary with execution results, optionally including http_metrics.
    """
    if not enable_http_metrics:
        return _run_connector_command(
            connector_image=connector_image,
            command=command,
            output_dir=output_dir,
            target_or_control=target_or_control,
            config_path=config_path,
            catalog_path=catalog_path,
            state_path=state_path,
        )

    with MitmproxyManager.start(output_dir) as session:
        if session is None:
            print_error("Mitmproxy unavailable, running without HTTP metrics")
            return _run_connector_command(
                connector_image=connector_image,
                command=command,
                output_dir=output_dir,
                target_or_control=target_or_control,
                config_path=config_path,
                catalog_path=catalog_path,
                state_path=state_path,
            )

        print_success(f"Started mitmproxy on {session.proxy_url}")
        result = _run_connector_command(
            connector_image=connector_image,
            command=command,
            output_dir=output_dir,
            target_or_control=target_or_control,
            config_path=config_path,
            catalog_path=catalog_path,
            state_path=state_path,
            proxy_url=session.proxy_url,
        )

        http_metrics = parse_http_dump(session.dump_file_path)
        result["http_metrics"] = {
            "flow_count": http_metrics.flow_count,
            "duplicate_flow_count": http_metrics.duplicate_flow_count,
        }
        print_success(
            f"Captured {http_metrics.flow_count} HTTP flows "
            f"({http_metrics.duplicate_flow_count} duplicates)"
        )
        return result


@connector_app.command(name="regression-test")
def regression_test(
    skip_compare: Annotated[
        bool,
        Parameter(
            help="If True, skip comparison and run single-version tests only. "
            "If False (default), run comparison tests (target vs control)."
        ),
    ] = False,
    test_image: Annotated[
        str | None,
        Parameter(
            help="Test connector image with tag (e.g., airbyte/source-github:1.0.0). "
            "This is the image under test - in comparison mode, it's compared against control_image."
        ),
    ] = None,
    control_image: Annotated[
        str | None,
        Parameter(
            help="Control connector image (baseline version) with tag (e.g., airbyte/source-github:1.0.0). "
            "Ignored if `skip_compare=True`."
        ),
    ] = None,
    connector_name: Annotated[
        str | None,
        Parameter(
            help="Connector name to build image from source (e.g., 'source-pokeapi'). "
            "If provided, builds the image locally with tag 'dev'. "
            "For comparison tests (default), this builds the target image. "
            "For single-version tests (skip_compare=True), this builds the test image."
        ),
    ] = None,
    repo_root: Annotated[
        str | None,
        Parameter(
            help="Path to the airbyte repo root. Required if connector_name is provided "
            "and the repo cannot be auto-detected."
        ),
    ] = None,
    command: Annotated[
        Literal["spec", "check", "discover", "read"],
        Parameter(help="The Airbyte command to run."),
    ] = "check",
    connection_id: Annotated[
        str | None,
        Parameter(
            help="Airbyte Cloud connection ID to fetch config/catalog from. "
            "Mutually exclusive with config-path/catalog-path. "
            "If provided, test_image/control_image can be auto-detected."
        ),
    ] = None,
    config_path: Annotated[
        str | None,
        Parameter(help="Path to the connector config JSON file."),
    ] = None,
    catalog_path: Annotated[
        str | None,
        Parameter(help="Path to the configured catalog JSON file (required for read)."),
    ] = None,
    state_path: Annotated[
        str | None,
        Parameter(help="Path to the state JSON file (optional for read)."),
    ] = None,
    output_dir: Annotated[
        str,
        Parameter(help="Directory to store test artifacts."),
    ] = "/tmp/regression_test_artifacts",
    enable_http_metrics: Annotated[
        bool,
        Parameter(
            help="Capture HTTP traffic metrics via mitmproxy (experimental). "
            "Requires mitmdump to be installed. Only used in comparison mode."
        ),
    ] = False,
    selected_streams: Annotated[
        str | None,
        Parameter(
            help="Comma-separated list of stream names to include in the read. "
            "Only these streams will be included in the configured catalog. "
            "This is useful to limit data volume by testing only specific streams."
        ),
    ] = None,
) -> None:
    """Run regression tests on connectors.

    This command supports two modes:

    Comparison mode (skip_compare=False, default):
        Runs the specified Airbyte protocol command against both the target (new)
        and control (baseline) connector versions, then compares the results.
        This helps identify regressions between versions.

    Single-version mode (skip_compare=True):
        Runs the specified Airbyte protocol command against a single connector
        and validates the output. No comparison is performed.

    Results are written to the output directory and to GitHub Actions outputs
    if running in CI.

    You can provide the test image in three ways:
    1. --test-image: Use a pre-built image from Docker registry
    2. --connector-name: Build the image locally from source code
    3. --connection-id: Auto-detect from an Airbyte Cloud connection

    You can provide config/catalog either via file paths OR via a connection_id
    that fetches them from Airbyte Cloud.
    """
    output_path = Path(output_dir)
    output_path.mkdir(parents=True, exist_ok=True)

    cmd = Command(command)

    config_file: Path | None = None
    catalog_file: Path | None = None
    state_file = Path(state_path) if state_path else None

    # Resolve the test image (used in both single-version and comparison modes)
    resolved_test_image: str | None = test_image
    resolved_control_image: str | None = control_image

    # Validate conflicting parameters
    # Single-version mode: reject comparison-specific parameters
    if skip_compare and control_image:
        write_github_output("success", False)
        write_github_output(
            "error", "Cannot specify control_image with skip_compare=True"
        )
        exit_with_error(
            "Cannot specify --control-image with --skip-compare. "
            "Control image is only used in comparison mode."
        )

    # If connector_name is provided, build the image from source
    if connector_name:
        if resolved_test_image:
            write_github_output("success", False)
            write_github_output(
                "error", "Cannot specify both test_image and connector_name"
            )
            exit_with_error("Cannot specify both --test-image and --connector-name")

        repo_root_path = Path(repo_root) if repo_root else None
        built_image = _build_connector_image_from_source(
            connector_name=connector_name,
            repo_root=repo_root_path,
            tag="dev",
        )
        if not built_image:
            write_github_output("success", False)
            write_github_output("error", f"Failed to build image for {connector_name}")
            exit_with_error(f"Failed to build image for {connector_name}")
        resolved_test_image = built_image

    if connection_id:
        if config_path or catalog_path:
            write_github_output("success", False)
            write_github_output(
                "error", "Cannot specify both connection_id and file paths"
            )
            exit_with_error(
                "Cannot specify both connection_id and config_path/catalog_path"
            )

        print_success(f"Fetching config/catalog from connection: {connection_id}")
        connection_data = fetch_connection_data(connection_id)

        # Check if we should retrieve unmasked secrets
        if should_use_secret_retriever():
            print_success(
                "USE_CONNECTION_SECRET_RETRIEVER enabled - enriching config with unmasked secrets..."
            )
            try:
                connection_data = enrich_config_with_secrets(
                    connection_data,
                    retrieval_reason="Regression test with USE_CONNECTION_SECRET_RETRIEVER=true",
                )
                print_success("Successfully retrieved unmasked secrets from database")
            except SecretRetrievalError as e:
                write_github_output("success", False)
                write_github_output("error", str(e))
                exit_with_error(
                    f"{e}\n\n"
                    f"This connection cannot be used for regression testing. "
                    f"Please use a connection from a non-EU workspace, or use GSM-based "
                    f"integration test credentials instead (by omitting --connection-id)."
                )

        config_file, catalog_file = save_connection_data_to_files(
            connection_data, output_path / "connection_data"
        )
        print_success(
            f"Fetched config for source: {connection_data.source_name} "
            f"with {len(connection_data.stream_names)} streams"
        )

        # Auto-detect test/control image from connection if not provided
        if not resolved_test_image and connection_data.connector_image:
            resolved_test_image = connection_data.connector_image
            print_success(f"Auto-detected test image: {resolved_test_image}")

        if (
            not skip_compare
            and not resolved_control_image
            and connection_data.connector_image
        ):
            resolved_control_image = connection_data.connector_image
            print_success(f"Auto-detected control image: {resolved_control_image}")
    elif config_path:
        config_file = Path(config_path)
        catalog_file = Path(catalog_path) if catalog_path else None
    elif connector_name:
        # Fallback: fetch integration test secrets from GSM using PyAirbyte API
        print_success(
            f"No connection_id or config_path provided. "
            f"Attempting to fetch integration test config from GSM for {connector_name}..."
        )
        gsm_config = get_first_config_from_secrets(connector_name)
        if gsm_config:
            # Write config to a temp file (not in output_path to avoid artifact upload)
            gsm_config_dir = Path(
                tempfile.mkdtemp(prefix=f"gsm-config-{connector_name}-")
            )
            gsm_config_dir.chmod(0o700)
            gsm_config_file = gsm_config_dir / "config.json"
            gsm_config_file.write_text(json.dumps(gsm_config, indent=2))
            gsm_config_file.chmod(0o600)
            config_file = gsm_config_file
            # Use catalog_path if provided (e.g., generated from discover output)
            catalog_file = Path(catalog_path) if catalog_path else None
            print_success(
                f"Fetched integration test config from GSM for {connector_name}"
            )
        else:
            print_error(
                f"Failed to fetch integration test config from GSM for {connector_name}."
            )
            config_file = None
            # Use catalog_path if provided (e.g., generated from discover output)
            catalog_file = Path(catalog_path) if catalog_path else None
    else:
        config_file = None
        catalog_file = Path(catalog_path) if catalog_path else None

    # Auto-detect control_image from metadata.yaml if connector_name is provided (comparison mode only)
    if not skip_compare and not resolved_control_image and connector_name:
        resolved_control_image = _fetch_control_image_from_metadata(connector_name)
        if resolved_control_image:
            print_success(
                f"Auto-detected control image from metadata.yaml: {resolved_control_image}"
            )

    # Validate that we have the required images
    if not resolved_test_image:
        write_github_output("success", False)
        write_github_output("error", "No test image specified")
        exit_with_error(
            "You must provide one of the following: a test_image, a connector_name "
            "to build the image from source, or a connection_id to auto-detect the image."
        )

    if not skip_compare and not resolved_control_image:
        write_github_output("success", False)
        write_github_output("error", "No control image specified")
        exit_with_error(
            "You must provide one of the following: a control_image, a connection_id "
            "for a connection that has an associated connector image, or a connector_name "
            "to auto-detect the control image from the airbyte repo's metadata.yaml."
        )

    # Pull images if they weren't just built locally
    # If connector_name was provided, we just built the test image locally
    if not connector_name and not ensure_image_available(resolved_test_image):
        write_github_output("success", False)
        write_github_output("error", f"Failed to pull image: {resolved_test_image}")
        exit_with_error(f"Failed to pull test image: {resolved_test_image}")

    if (
        not skip_compare
        and resolved_control_image
        and not ensure_image_available(resolved_control_image)
    ):
        write_github_output("success", False)
        write_github_output("error", f"Failed to pull image: {resolved_control_image}")
        exit_with_error(
            f"Failed to pull control connector image: {resolved_control_image}"
        )

    # Apply selected_streams filter to catalog if requested
    if selected_streams and catalog_file:
        streams_set = {s.strip() for s in selected_streams.split(",") if s.strip()}
        if streams_set:
            print_success(
                f"Filtering catalog to {len(streams_set)} selected streams: "
                f"{', '.join(sorted(streams_set))}"
            )
            filter_configured_catalog_file(catalog_file, streams_set)

    # Track telemetry for the regression test
    # Extract version from image tag (e.g., "airbyte/source-github:1.0.0" -> "1.0.0")
    target_version = (
        resolved_test_image.rsplit(":", 1)[-1]
        if ":" in resolved_test_image
        else "unknown"
    )
    control_version = None
    if resolved_control_image and ":" in resolved_control_image:
        control_version = resolved_control_image.rsplit(":", 1)[-1]

    # Get tester identity from environment (GitHub Actions sets GITHUB_ACTOR)
    tester = os.getenv("GITHUB_ACTOR") or os.getenv("USER")

    track_regression_test(
        user_id=tester,
        connector_image=resolved_test_image,
        command=command,
        target_version=target_version,
        control_version=control_version,
        additional_properties={
            "connection_id": connection_id,
            "skip_compare": skip_compare,
        },
    )

    # Execute the appropriate mode
    if skip_compare:
        # Single-version mode: run only the connector image
        result = _run_connector_command(
            connector_image=resolved_test_image,
            command=cmd,
            output_dir=output_path,
            target_or_control=TargetOrControl.TARGET,
            config_path=config_file,
            catalog_path=catalog_file,
            state_path=state_file,
        )

        print_json(result)

        write_github_outputs(
            {
                "success": result["success"],
                "connector": resolved_test_image,
                "command": command,
                "exit_code": result["exit_code"],
            }
        )

        write_test_summary(
            connector_image=resolved_test_image,
            test_type="regression-test",
            success=result["success"],
            results={
                "command": command,
                "exit_code": result["exit_code"],
                "output_dir": output_dir,
            },
        )

        # Generate report.md with detailed metrics
        report_path = generate_single_version_report(
            connector_image=resolved_test_image,
            command=command,
            result=result,
            output_dir=output_path,
        )
        print_success(f"Generated report: {report_path}")

        # Write report to GITHUB_STEP_SUMMARY (if env var exists)
        write_github_summary(report_path.read_text())

        if result["success"]:
            print_success(
                f"Single-version regression test passed for {resolved_test_image}"
            )
        else:
            print_error(
                f"Single-version regression test failed for {resolved_test_image}"
            )
    else:
        # Comparison mode: run both target and control images
        target_output = output_path / "target"
        control_output = output_path / "control"

        target_result = _run_with_optional_http_metrics(
            connector_image=resolved_test_image,
            command=cmd,
            output_dir=target_output,
            target_or_control=TargetOrControl.TARGET,
            enable_http_metrics=enable_http_metrics,
            config_path=config_file,
            catalog_path=catalog_file,
            state_path=state_file,
        )

        control_result = _run_with_optional_http_metrics(
            connector_image=resolved_control_image,  # type: ignore[arg-type]
            command=cmd,
            output_dir=control_output,
            target_or_control=TargetOrControl.CONTROL,
            enable_http_metrics=enable_http_metrics,
            config_path=config_file,
            catalog_path=catalog_file,
            state_path=state_file,
        )

        both_succeeded = target_result["success"] and control_result["success"]
        regression_detected = target_result["success"] != control_result["success"]

        combined_result = {
            "target": target_result,
            "control": control_result,
            "both_succeeded": both_succeeded,
            "regression_detected": regression_detected,
        }

        print_json(combined_result)

        both_failed = not target_result["success"] and not control_result["success"]

        write_github_outputs(
            {
                "success": not regression_detected,
                "target_image": resolved_test_image,
                "control_image": resolved_control_image,
                "command": command,
                "target_exit_code": target_result["exit_code"],
                "control_exit_code": control_result["exit_code"],
                "regression_detected": regression_detected,
                "both_failed": both_failed,
            }
        )

        write_json_output("regression_report", combined_result)

        report_path = generate_regression_report(
            target_image=resolved_test_image,
            control_image=resolved_control_image,  # type: ignore[arg-type]
            command=command,
            target_result=target_result,
            control_result=control_result,
            output_dir=output_path,
        )
        print_success(f"Generated regression report: {report_path}")

        # Write report to GITHUB_STEP_SUMMARY (if env var exists)
        write_github_summary(report_path.read_text())

        if regression_detected:
            print_error(
                f"Regression detected between {resolved_test_image} and {resolved_control_image}"
            )
        elif both_succeeded:
            print_success(
                f"Regression test passed for {resolved_test_image} vs {resolved_control_image}"
            )
        else:
            # Both versions failed, but no regression between them was detected; treat this as a
            # test environment issue (e.g., expired credentials, transient API errors, rate limiting).
            # Exit successfully since no regression between versions was detected.
            print_warning(
                f"Both versions failed for {resolved_test_image} vs {resolved_control_image}. "
                "No regression detected. This may indicate expired credentials or a transient API issue."
            )


@connector_app.command(name="fetch-connection-config")
def fetch_connection_config_cmd(
    connection_id: Annotated[
        str,
        Parameter(help="The UUID of the Airbyte Cloud connection."),
    ],
    output_path: Annotated[
        str | None,
        Parameter(
            help="Path to output file or directory. "
            "If directory, writes connection-<id>-config.json. "
            "Default: ./connection-<id>-config.json"
        ),
    ] = None,
    with_secrets: Annotated[
        bool,
        Parameter(
            name="--with-secrets",
            negative="--no-secrets",
            help="If set, fetches unmasked secrets from the internal database. "
            "Requires GCP_PROD_DB_ACCESS_CREDENTIALS env var or `gcloud auth application-default login`. "
            "Must be used with --oc-issue-url.",
        ),
    ] = False,
    oc_issue_url: Annotated[
        str | None,
        Parameter(
            help="OC issue URL for audit logging. Required when using --with-secrets."
        ),
    ] = None,
) -> None:
    """Fetch connection configuration from Airbyte Cloud to a local file.

    This command retrieves the source configuration for a given connection ID
    and writes it to a local JSON file.

    Requires authentication via AIRBYTE_CLOUD_CLIENT_ID and
    AIRBYTE_CLOUD_CLIENT_SECRET environment variables.

    When --with-secrets is specified, the command fetches unmasked secrets from
    the internal database using the connection-retriever. This additionally requires:
    - An OC issue URL for audit logging (--oc-issue-url)
    - GCP credentials via `GCP_PROD_DB_ACCESS_CREDENTIALS` env var or `gcloud auth application-default login`
    - If `CI=true`: expects `cloud-sql-proxy` running on localhost, or
      direct network access to the Cloud SQL instance.
    """
    path = Path(output_path) if output_path else None
    result = fetch_connection_config(
        connection_id=connection_id,
        output_path=path,
        with_secrets=with_secrets,
        oc_issue_url=oc_issue_url,
    )
    if result.success:
        print_success(result.message)
    else:
        print_error(result.message)
    print_json(result.model_dump())


def _read_json_input(
    json_str: str | None,
    input_file: str | None,
) -> dict:
    """Read JSON from a positional arg, --input file, or STDIN (in that priority)."""
    if json_str is not None:
        return json.loads(json_str)
    if input_file is not None:
        return json.loads(Path(input_file).read_text())
    if not sys.stdin.isatty():
        return json.loads(sys.stdin.read())
    exit_with_error(
        "No JSON input provided. Pass it as a positional argument, "
        "via --input <file>, or pipe to STDIN."
    )
    raise SystemExit(1)  # unreachable, but satisfies type checker


@state_app.command(name="get")
def get_connection_state(
    connection_id: Annotated[
        str,
        Parameter(help="The connection ID (UUID) to fetch state for."),
    ],
    stream_name: Annotated[
        str | None,
        Parameter(
            help="Optional stream name to filter state for a single stream.",
            name="--stream",
        ),
    ] = None,
    stream_namespace: Annotated[
        str | None,
        Parameter(
            help="Optional stream namespace to narrow the stream filter.",
            name="--namespace",
        ),
    ] = None,
    output: Annotated[
        str | None,
        Parameter(
            help="Path to write the state JSON output to a file.",
            name="--output",
        ),
    ] = None,
) -> None:
    """Get the current state for an Airbyte Cloud connection."""
    workspace = CloudWorkspace.from_env()
    conn = workspace.get_connection(connection_id)

    if stream_name is not None:
        stream_state = conn.get_stream_state(
            stream_name=stream_name,
            stream_namespace=stream_namespace,
        )
        result = {
            "connection_id": connection_id,
            "stream_name": stream_name,
            "stream_namespace": stream_namespace,
            "stream_state": stream_state,
        }
    else:
        result = conn.dump_raw_state()

    json_output = json.dumps(result, indent=2, default=str)
    if output is not None:
        Path(output).write_text(json_output + "\n")
        print(f"State written to {output}", file=sys.stderr)
    else:
        print(json_output)


@state_app.command(name="set")
def set_connection_state(
    connection_id: Annotated[
        str,
        Parameter(help="The connection ID (UUID) to update state for."),
    ],
    state_json: Annotated[
        str | None,
        Parameter(
            help="The connection state as a JSON string. When --stream is used, "
            'this is just the stream\'s state blob (e.g., \'{"cursor": "2024-01-01"}\').'
            " Otherwise, must include 'stateType', 'connectionId', and the appropriate "
            "state field. Can also be provided via --input or STDIN."
        ),
    ] = None,
    stream_name: Annotated[
        str | None,
        Parameter(
            help="Optional stream name to update state for a single stream only.",
            name="--stream",
        ),
    ] = None,
    stream_namespace: Annotated[
        str | None,
        Parameter(
            help="Optional stream namespace to identify the stream.",
            name="--namespace",
        ),
    ] = None,
    input_file: Annotated[
        str | None,
        Parameter(
            help="Path to a JSON file containing the state to set.",
            name="--input",
        ),
    ] = None,
) -> None:
    """Set the state for an Airbyte Cloud connection.

    State JSON can be provided as a positional argument, via --input <file>,
    or piped through STDIN.

    Uses the safe variant that prevents updates while a sync is running.
    When --stream is provided, only that stream's state is updated within
    the existing connection state.
    """
    parsed_state = _read_json_input(state_json, input_file)
    workspace = CloudWorkspace.from_env()
    conn = workspace.get_connection(connection_id)

    if stream_name is not None:
        conn.set_stream_state(
            stream_name=stream_name,
            state_blob_dict=parsed_state,
            stream_namespace=stream_namespace,
        )
    else:
        conn.import_raw_state(parsed_state)

    result = conn.dump_raw_state()
    json_output = json.dumps(result, indent=2, default=str)
    print(json_output)


@catalog_app.command(name="get")
def get_connection_catalog(
    connection_id: Annotated[
        str,
        Parameter(help="The connection ID (UUID) to fetch catalog for."),
    ],
    output: Annotated[
        str | None,
        Parameter(
            help="Path to write the catalog JSON output to a file.",
            name="--output",
        ),
    ] = None,
) -> None:
    """Get the configured catalog for an Airbyte Cloud connection."""
    workspace = CloudWorkspace.from_env()
    conn = workspace.get_connection(connection_id)
    result = conn.dump_raw_catalog()
    if result is None:
        exit_with_error("No configured catalog found for this connection.")
        raise SystemExit(1)  # unreachable, but satisfies type checker

    json_output = json.dumps(result, indent=2, default=str)
    if output is not None:
        Path(output).write_text(json_output + "\n")
        print(f"Catalog written to {output}", file=sys.stderr)
    else:
        print(json_output)


@catalog_app.command(name="set")
def set_connection_catalog(
    connection_id: Annotated[
        str,
        Parameter(help="The connection ID (UUID) to update catalog for."),
    ],
    catalog_json: Annotated[
        str | None,
        Parameter(
            help="The configured catalog as a JSON string. "
            "Can also be provided via --input or STDIN."
        ),
    ] = None,
    input_file: Annotated[
        str | None,
        Parameter(
            help="Path to a JSON file containing the catalog to set.",
            name="--input",
        ),
    ] = None,
) -> None:
    """Set the configured catalog for an Airbyte Cloud connection.

    Catalog JSON can be provided as a positional argument, via --input <file>,
    or piped through STDIN.

    WARNING: This replaces the entire configured catalog.
    """
    parsed_catalog = _read_json_input(catalog_json, input_file)
    workspace = CloudWorkspace.from_env()
    conn = workspace.get_connection(connection_id)
    conn.import_raw_catalog(parsed_catalog)

    result = conn.dump_raw_catalog()
    if result is None:
        exit_with_error("Failed to retrieve catalog after import.")
        raise SystemExit(1)  # unreachable, but satisfies type checker

    json_output = json.dumps(result, indent=2, default=str)
    print(json_output)


@logs_app.command(name="lookup-cloud-backend-error")
def lookup_cloud_backend_error(
    error_id: Annotated[
        str,
        Parameter(
            help=(
                "The error ID (UUID) to search for. This is typically returned "
                "in API error responses as {'errorId': '...'}"
            )
        ),
    ],
    lookback_days: Annotated[
        int,
        Parameter(help="Number of days to look back in logs."),
    ] = 7,
    min_severity_filter: Annotated[
        GCPSeverity | None,
        Parameter(
            help="Optional minimum severity level to filter logs.",
        ),
    ] = None,
    raw: Annotated[
        bool,
        Parameter(help="Output raw JSON instead of formatted text."),
    ] = False,
) -> None:
    """Look up error details from GCP Cloud Logging by error ID.

    When an Airbyte Cloud API returns an error response with only an error ID
    (e.g., {"errorId": "3173452e-8f22-4286-a1ec-b0f16c1e078a"}), this command
    fetches the full stack trace and error details from GCP Cloud Logging.

    Requires GCP credentials with Logs Viewer role on the target project.
    Set up credentials with: gcloud auth application-default login
    """
    print(f"Searching for error ID: {error_id}", file=sys.stderr)
    print(f"Lookback days: {lookback_days}", file=sys.stderr)
    if min_severity_filter:
        print(f"Severity filter: {min_severity_filter}", file=sys.stderr)
    print(file=sys.stderr)

    result = fetch_error_logs(
        error_id=error_id,
        lookback_days=lookback_days,
        min_severity_filter=min_severity_filter,
    )

    if raw:
        print_json(result.model_dump())
        return

    print(f"Found {result.total_entries_found} log entries", file=sys.stderr)
    print(file=sys.stderr)

    if result.payloads:
        for i, payload in enumerate(result.payloads):
            print(f"=== Log Group {i + 1} ===")
            print(f"Timestamp: {payload.timestamp}")
            print(f"Severity: {payload.severity}")
            if payload.resource.labels.pod_name:
                print(f"Pod: {payload.resource.labels.pod_name}")
            print(f"Lines: {payload.num_log_lines}")
            print()
            print(payload.message)
            print()
    elif result.entries:
        print("No grouped payloads, showing raw entries:", file=sys.stderr)
        for entry in result.entries:
            print(f"[{entry.timestamp}] {entry.severity}: {entry.payload}")
    else:
        print_error("No log entries found for this error ID.")
