#!/usr/bin/env python3
"""
STAC Ingestion Pipeline CLI - High-Performance Async HTTP Processing

Process STAC items from URL lists into partitioned GeoParquet catalogs with
3-6x performance improvements through async HTTP processing.

IMPORTANT PRODUCTION FEATURES:
    - JOB RESUMPTION: If cancelled (Ctrl+C) or failed, resume with --resume
    - AUTOMATIC STATISTICS: Stats automatically saved to {catalog}/ingestion_stats.json
    - INCREMENTAL UPDATES: Merge new data without full reprocessing
    - PROGRESS TRACKING: Job state persisted to {catalog}/jobs/ for recovery

    Job tracking files:
        - {catalog}/jobs/{job_id}/manifest.json - Job state and progress
        - {catalog}/jobs/logs/{date}-ingest.txt - Structured log entries
        - {catalog}/ingestion_stats.json - Per-partition statistics (auto-saved)

    Cancel mid-flight? Just re-run with --resume to continue from checkpoint.

Features:
    - Async HTTP processing: 50-100+ concurrent requests per worker
    - Intelligent spatial partitioning: H3, S2, MGRS, UTM grids
    - Temporal organization: Year, month, or day-based binning
    - Distributed processing: Local multi-threading or Dask clusters
    - Cloud storage: Native S3, GCS, Azure support
    - Memory efficiency: Handles 100M+ URL datasets
    - Production ready: Comprehensive error handling and monitoring

Performance:
    - Sequential HTTP: ~16 requests/second
    - Async HTTP: 50-100+ requests/second (3-6x improvement)
    - Large datasets: 100M URLs processed in 7-14 hours vs 71 hours sequential

Usage Examples:
    Basic (async enabled by default):
        stac-ingest --input urls.parquet --output ./catalog --scratch ./scratch

    Resume interrupted job:
        stac-ingest --input urls.parquet --output ./catalog --scratch ./scratch --resume

    With custom statistics location:
        stac-ingest --input urls.parquet --output ./catalog --scratch ./scratch \\
                   --stats-file custom_stats.json

    High-performance (fast networks):
        stac-ingest --input urls.parquet --output ./catalog --scratch ./scratch \\
                   --concurrent-requests 100 --batch-size 2000 --workers 32

    Conservative (unreliable networks):
        stac-ingest --input urls.parquet --output ./catalog --scratch ./scratch \\
                   --concurrent-requests 25 --request-timeout 60 --retry-attempts 5

    Debug mode (disable async):
        stac-ingest --input urls.parquet --output ./catalog --scratch ./scratch \\
                   --disable-concurrent-http --workers 1 --verbose

Output Statistics:
    INGESTION COMPLETE
    ==================
    Total partitions: 42
    Total items: 125,430
      - Existing: 5,200
      - New: 120,230
"""

import argparse
import json
import logging
import os
import sys
from pathlib import Path

# Import from earthcatalog package
from earthcatalog.config import load_config, merge_cli_overrides
from earthcatalog.ingestion_pipeline import (
    DaskDistributedProcessor,
    DistributedProcessor,
    LocalProcessor,
    ProcessingConfig,
    STACIngestionPipeline,
)


def setup_logging(verbose: bool = False):
    """Configure logging."""
    level = logging.DEBUG if verbose else logging.INFO
    logging.basicConfig(
        level=level,
        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )


def main():
    parser = argparse.ArgumentParser(
        description="STAC Ingestion Pipeline - Process URLs into partitioned GeoParquet catalogs",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
EXAMPLES:

Job Management:
  # Resume interrupted job (Ctrl+C, crash, etc.)
  %(prog)s --input urls.parquet --output ./catalog --scratch ./scratch --resume

  # Custom statistics location (default: {catalog}/ingestion_stats.json)
  %(prog)s --input urls.parquet --output ./catalog --scratch ./scratch \\
           --stats-file s3://bucket/custom-stats.json

Basic Usage:
  # Basic processing (async HTTP enabled by default - 3-6x speedup)
  %(prog)s --input urls.parquet --output ./catalog --scratch ./scratch --workers 4

  # High-performance async processing (100M+ URLs, fast networks)
  %(prog)s --input urls.parquet --output ./catalog --scratch ./scratch \\
           --workers 32 --concurrent-requests 100 --batch-size 2000

  # Conservative async settings (unreliable networks)
  %(prog)s --input urls.parquet --output ./catalog --scratch ./scratch \\
           --concurrent-requests 25 --request-timeout 60 --retry-attempts 5

Input Formats:
  # Process CSV file with custom URL column
  %(prog)s --input urls.csv --output ./catalog --scratch ./scratch \\
           --input-format csv --url-column item_url

  # Process multiple NDJSON files using glob pattern
  %(prog)s --input ./data --input-pattern "./data/2020_*.ndjson" \\
           --output ./catalog --scratch ./scratch

Distributed Processing:
  # S3 processing with remote Dask cluster
  %(prog)s --input s3://bucket/urls.csv --output s3://bucket/catalog \\
           --scratch s3://bucket/scratch --processor dask \\
           --dask-scheduler-address tcp://scheduler:8786

  # Local Dask cluster
  %(prog)s --input urls.parquet --output ./catalog --scratch ./scratch \\
           --processor dask --workers 8

Grid Configuration:
  # Custom grid and temporal binning
  %(prog)s --input urls.parquet --output ./catalog --scratch ./scratch \\
           --grid h3 --grid-resolution 7 --temporal-bin day

  # Process S3 files with glob pattern (ITS_LIVE bulk data)
  %(prog)s --input s3://bucket/bulk \\
           --input-pattern "s3://its-live-data/stac-ingest/bulk/2020_*.ndjson" \\
           --output s3://bucket/catalog --scratch s3://bucket/scratch \\
           --grid h3 --grid-resolution 2

JOB TRACKING:
  Job state is automatically persisted to {output}/jobs/{job_id}/manifest.json
  If a job is interrupted, re-run with --resume to continue from checkpoint.

OUTPUT STATISTICS:
  INGESTION COMPLETE
  ==================
  Total partitions: 42
  Total items: 125,430
    - Existing: 5,200
    - New: 120,230
  Output catalog: ./catalog
        """,
    )

    # Required arguments
    parser.add_argument(
        "--input",
        "-i",
        required=True,
        help="Input file with URLs (local path or s3://). Supports multiple formats. "
        "Use --input-pattern for glob pattern matching of multiple files.",
    )
    parser.add_argument(
        "--input-pattern",
        help="Glob pattern for multiple input files (local or s3://). "
        "Example: 's3://bucket/bulk/2020_*.ndjson' or './data/2020_*.ndjson'. "
        "When specified, --input is used as base path and this pattern discovers files.",
    )
    parser.add_argument(
        "--output",
        "-o",
        required=True,
        help="Output catalog directory (local path or s3://)",
    )
    parser.add_argument(
        "--scratch",
        "-s",
        required=True,
        help="Scratch/temporary directory for intermediate files (local path or s3://)",
    )

    # Processing configuration
    parser.add_argument(
        "--processor",
        choices=["local", "dask"],
        default="local",
        help="Processing backend (default: local)",
    )
    parser.add_argument(
        "--dask-scheduler-address",
        type=str,
        default="",
        help="Dask scheduler address for distributed processing (e.g., 'tcp://localhost:8786'). "
        "If provided with --processor dask, connects to existing cluster instead of creating local one. "
        "For remote Dask clusters, use cloud storage (s3://, gs://) for --scratch and --output.",
    )
    parser.add_argument(
        "--workers",
        "-w",
        type=int,
        default=4,
        help="Number of parallel workers (default: 4)",
    )
    parser.add_argument(
        "--items-per-shard",
        type=int,
        default=10000,
        help="Number of items per worker shard (default: 10000)",
    )

    # Grid system configuration
    parser.add_argument(
        "--grid",
        choices=["h3", "s2", "mgrs", "utm", "latlon", "itslive", "geojson"],
        default="h3",
        help="Spatial grid system for partitioning (default: h3)",
    )
    parser.add_argument(
        "--grid-resolution",
        type=int,
        default=2,
        help="Grid system resolution (default: 2 for H3)",
    )

    # Temporal binning
    parser.add_argument(
        "--temporal-bin",
        choices=["year", "month", "day"],
        default="month",
        help="Temporal partition depth using Hive-style directories. "
        "year: year=YYYY/, month: year=YYYY/month=MM/, day: year=YYYY/month=MM/day=DD/. "
        "Enables directory-level pruning in DuckDB/Athena/Spark (default: month)",
    )

    # Output format options
    parser.add_argument(
        "--output-format",
        choices=["geoparquet", "ndjson"],
        default="geoparquet",
        help="Output format: geoparquet (default) or ndjson for compatibility",
    )
    parser.add_argument(
        "--mission-field",
        default="dataset_id",
        help="STAC property field to extract mission/dataset ID from (default: dataset_id)",
    )

    # Global partitioning configuration
    parser.add_argument(
        "--disable-global-partitioning",
        action="store_true",
        help="Disable global partitioning for multi-cell geometries",
    )
    parser.add_argument(
        "--global-threshold",
        type=int,
        default=1,
        help="Default fallback threshold for global partitioning (default: 1)",
    )
    parser.add_argument(
        "--global-thresholds-file",
        help="JSON file with custom resolution-specific thresholds",
    )

    # Sorting configuration
    parser.add_argument(
        "--sort-key",
        default="datetime",
        help="Field to sort items by (default: datetime)",
    )
    parser.add_argument(
        "--sort-desc",
        action="store_true",
        help="Sort in descending order (default: ascending)",
    )

    # Input format options
    parser.add_argument(
        "--input-format",
        choices=["auto", "parquet", "csv", "tsv"],
        default="auto",
        help="Input file format (default: auto-detect based on file extension)",
    )
    parser.add_argument(
        "--url-column",
        default="url",
        help="Column/field name containing URLs (default: url)",
    )

    # Output options
    parser.add_argument(
        "--stats-file",
        help="Custom path for statistics JSON (default: {catalog}/ingestion_stats.json)",
    )
    parser.add_argument(
        "--generate-schema",
        action="store_true",
        default=True,
        help="Generate catalog schema metadata file (default: enabled)",
    )
    parser.add_argument(
        "--no-generate-schema",
        action="store_true",
        dest="no_generate_schema",
        help="Disable catalog schema generation",
    )
    parser.add_argument(
        "--schema-filename",
        default="catalog_schema.json",
        help="Name of catalog schema metadata file (default: catalog_schema.json, generated by default)",
    )
    parser.add_argument(
        "--geojson-path",
        help="Path to custom GeoJSON tiles file (required when using --grid geojson)",
    )
    # Async HTTP Performance Configuration
    async_group = parser.add_argument_group(
        "Async HTTP Performance", "High-performance async HTTP settings for faster URL processing"
    )
    async_group.add_argument(
        "--enable-async",
        action="store_true",
        default=True,
        help="Enable async HTTP processing for 3-6x speedup (default: enabled)",
    )
    async_group.add_argument(
        "--disable-async",
        action="store_true",
        help="Disable async HTTP processing (use traditional sync requests)",
    )
    async_group.add_argument(
        "--concurrent-requests",
        type=int,
        default=50,
        help="Number of concurrent HTTP requests per worker (default: 50)",
    )
    async_group.add_argument(
        "--batch-size",
        type=int,
        default=1000,
        help="URLs processed per async batch (default: 1000)",
    )
    async_group.add_argument(
        "--request-timeout",
        type=int,
        default=30,
        help="HTTP request timeout in seconds (default: 30)",
    )
    async_group.add_argument(
        "--retry-attempts",
        type=int,
        default=3,
        help="Maximum retry attempts for failed requests (default: 3)",
    )
    async_group.add_argument(
        "--connection-pool-size",
        type=int,
        default=100,
        help="HTTP connection pool size (default: 100)",
    )

    # Batch mode configuration
    batch_group = parser.add_argument_group("Batch Mode", "Control when to use simple local vs distributed processing")
    batch_group.add_argument(
        "--config",
        "-c",
        type=str,
        default=None,
        help="Path to YAML config file (default: ./earthcatalog.yaml if exists)",
    )
    batch_group.add_argument(
        "--batch-threshold",
        type=int,
        default=None,
        help="URL count threshold for distributed mode (default: 10000). Below this, use simple local processing.",
    )
    batch_group.add_argument(
        "--distributed",
        action="store_true",
        default=None,
        dest="force_distributed",
        help="Force distributed processing regardless of URL count",
    )
    batch_group.add_argument(
        "--no-distributed",
        action="store_false",
        dest="force_distributed",
        help="Force simple local processing regardless of URL count",
    )
    batch_group.add_argument(
        "--stac-hook",
        type=str,
        default="passthrough",
        help="STAC hook for generating items from input. "
        "Options: 'passthrough' (default - input is pre-fetched STAC JSON), "
        "'default' (fetch from URL), "
        "'module:package.module:function' or 'script:/path/to/script'",
    )

    parser.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        help="Enable verbose logging",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Validate configuration and show what would be processed without actually running",
    )
    parser.add_argument(
        "--resume",
        action="store_true",
        help="Resume the most recent incomplete job. "
        "Job state is tracked automatically - if a job is cancelled (Ctrl+C) or fails, "
        "you can resume from where it left off without reprocessing already-completed work.",
    )

    args = parser.parse_args()

    # Setup logging
    setup_logging(args.verbose)
    logger = logging.getLogger(__name__)

    try:
        # Load configuration from file (if exists)
        file_config = load_config(getattr(args, "config", None))
        if file_config:
            logger.info("Loaded configuration from file")

        # Load custom global thresholds if provided
        if getattr(args, "global_thresholds_file", None):
            with open(args.global_thresholds_file) as f:
                json.load(f)

        # Validate geojson grid requirements
        if args.grid == "geojson" and not args.geojson_path:
            raise ValueError("--geojson-path is required when using --grid geojson")

        # Determine async HTTP settings
        enable_async_http = args.enable_async and not args.disable_async

        if enable_async_http:
            logger.info("Async HTTP enabled - expect 3-6x faster processing!")
            logger.info(f"   Concurrent requests: {args.concurrent_requests}")
            logger.info(f"   Batch size: {args.batch_size}")
            logger.info(f"   Request timeout: {args.request_timeout}s")
        else:
            logger.info("Using sync HTTP processing (--disable-async specified)")

        # Build CLI args dict for merging (only non-None values)
        cli_config = {
            "input_file": args.input,
            "output_catalog": args.output,
            "scratch_location": args.scratch,
            "input_pattern": getattr(args, "input_pattern", "") or "",
            "input_format": args.input_format,
            "url_column": args.url_column,
            "grid_system": args.grid,
            "grid_resolution": args.grid_resolution,
            "temporal_bin": args.temporal_bin,
            "sort_key": args.sort_key,
            "sort_ascending": not args.sort_desc,
            "items_per_shard": args.items_per_shard,
            "max_workers": args.workers,
            "enable_global_partitioning": not getattr(args, "disable_global_partitioning", False),
            "global_partition_threshold": getattr(args, "global_threshold", 50),
            "generate_schema": args.generate_schema and not getattr(args, "no_generate_schema", False),
            "schema_filename": args.schema_filename,
            "geojson_path": getattr(args, "geojson_path", "") or "",
            "enable_concurrent_http": enable_async_http,
            "concurrent_requests": args.concurrent_requests,
            "batch_size": args.batch_size,
            "request_timeout": args.request_timeout,
            "retry_attempts": args.retry_attempts,
            "connection_pool_size": args.connection_pool_size,
            "output_format": args.output_format,
            "mission_field": args.mission_field,
            "dask_scheduler_address": getattr(args, "dask_scheduler_address", "") or "",
        }

        # Add new batch mode options if specified
        if getattr(args, "batch_threshold", None) is not None:
            cli_config["batch_threshold"] = args.batch_threshold
        if getattr(args, "force_distributed", None) is not None:
            cli_config["distributed"] = args.force_distributed
        if getattr(args, "stac_hook", None) is not None:
            cli_config["stac_hook"] = args.stac_hook

        # Merge file config with CLI overrides
        merged_config = merge_cli_overrides(file_config, cli_config)

        # Create processing configuration from merged dict
        config = ProcessingConfig(
            input_file=merged_config["input_file"],
            output_catalog=merged_config["output_catalog"],
            scratch_location=merged_config["scratch_location"],
            input_format=merged_config.get("input_format", "auto"),
            url_column=merged_config.get("url_column", "url"),
            grid_system=merged_config.get("grid_system", "h3"),
            grid_resolution=merged_config.get("grid_resolution", 2),
            temporal_bin=merged_config.get("temporal_bin", "month"),
            sort_key=merged_config.get("sort_key", "datetime"),
            sort_ascending=merged_config.get("sort_ascending", True),
            items_per_shard=merged_config.get("items_per_shard", 10000),
            max_workers=merged_config.get("max_workers", 8),
            enable_global_partitioning=merged_config.get("enable_global_partitioning", True),
            global_partition_threshold=merged_config.get("global_partition_threshold", 50),
            generate_schema=merged_config.get("generate_schema", True),
            schema_filename=merged_config.get("schema_filename", "catalog_schema.json"),
            geojson_path=merged_config.get("geojson_path", ""),
            input_pattern=merged_config.get("input_pattern", ""),
            enable_concurrent_http=merged_config.get("enable_concurrent_http", True),
            concurrent_requests=merged_config.get("concurrent_requests", 50),
            batch_size=merged_config.get("batch_size", 1000),
            request_timeout=merged_config.get("request_timeout", 30),
            retry_attempts=merged_config.get("retry_attempts", 3),
            connection_pool_size=merged_config.get("connection_pool_size", 100),
            output_format=merged_config.get("output_format", "geoparquet"),
            mission_field=merged_config.get("mission_field", "dataset_id"),
            # New batch mode options
            batch_threshold=merged_config.get("batch_threshold", 10000),
            distributed=merged_config.get("distributed", None),
            stac_hook=merged_config.get("stac_hook", "passthrough"),
            dask_scheduler_address=merged_config.get("dask_scheduler_address", ""),
        )

        # Handle dry-run mode
        if args.dry_run:
            logger.info("=" * 80)
            logger.info("DRY RUN MODE - No changes will be made")
            logger.info("=" * 80)
            logger.info(f"Input file: {args.input}")
            if getattr(args, "input_pattern", None):
                logger.info(f"Input pattern: {args.input_pattern}")
            logger.info(f"Output catalog: {args.output}")
            logger.info(f"Scratch location: {args.scratch}")
            logger.info(f"Grid system: {args.grid} (resolution: {args.grid_resolution})")
            logger.info(f"Temporal binning: {args.temporal_bin}")
            logger.info(f"Workers: {args.workers}")
            logger.info(f"Async HTTP: {'enabled' if enable_async_http else 'disabled'}")
            if enable_async_http:
                logger.info(f"  Concurrent requests: {args.concurrent_requests}")
                logger.info(f"  Batch size: {args.batch_size}")
            # Batch mode info
            logger.info(f"Batch threshold: {config.batch_threshold:,}")
            if config.distributed is True:
                logger.info("Distributed mode: forced ON")
            elif config.distributed is False:
                logger.info("Distributed mode: forced OFF (simple local)")
            else:
                logger.info("Distributed mode: auto (based on URL count)")
            if config.stac_hook != "passthrough":
                logger.info(f"STAC hook: {config.stac_hook}")
            # Dask scheduler info
            logger.info(f"Processor: {args.processor}")
            if args.processor == "dask" and config.dask_scheduler_address:
                logger.info(f"Dask scheduler: {config.dask_scheduler_address}")
            logger.info("=" * 80)
            logger.info("Configuration validated successfully. Ready to run.")
            return 0

        # Initialize processor
        processor: DistributedProcessor
        if args.processor == "dask":
            scheduler_address = config.dask_scheduler_address or None
            # Auto-detect scheduler from environment if not explicitly provided
            if not scheduler_address:
                scheduler_address = os.environ.get("DASK_SCHEDULER_ADDRESS") or os.environ.get("DASK_ADDRESS") or None
            if scheduler_address:
                logger.info(f"Connecting to Dask scheduler at {scheduler_address}")
            else:
                logger.info(f"Creating local Dask cluster with {args.workers} workers")
            processor = DaskDistributedProcessor(n_workers=args.workers, scheduler_address=scheduler_address)
        else:
            logger.info(f"Initializing local processor with {args.workers} workers")
            processor = LocalProcessor(n_workers=args.workers)

        # Create and run pipeline
        pipeline = STACIngestionPipeline(config, processor)

        try:
            stats = pipeline.run(resume=args.resume)

            # Print summary
            print("\n" + "=" * 80)
            print("INGESTION COMPLETE")
            print("=" * 80)
            print(f"Total partitions: {len(stats)}")

            total_items = sum(s["total_items"] for s in stats.values())
            new_items = sum(s["new_items"] for s in stats.values())
            existing_items = sum(s["existing_items"] for s in stats.values())

            print(f"Total items: {total_items:,}")
            print(f"  - Existing: {existing_items:,}")
            print(f"  - New: {new_items:,}")
            print(f"\nOutput catalog: {args.output}")

            # Save stats to file (default: catalog path, or custom path via --stats-file)
            stats_path = args.stats_file if args.stats_file else f"{args.output}/ingestion_stats.json"
            # Use storage backend to support S3 paths
            from earthcatalog.storage_backends import get_storage_backend

            stats_storage = get_storage_backend(stats_path)
            stats_storage.makedirs(Path(stats_path).parent)

            # Write JSON content
            content = json.dumps(stats, indent=2).encode("utf-8")
            with stats_storage.open(stats_path, "wb") as f:
                f.write(content)
            print(f"Statistics saved to: {stats_path}")

            print("=" * 80 + "\n")

        finally:
            # Cleanup processor
            processor.close()

        return 0

    except KeyboardInterrupt:
        logger.error("\nInterrupted by user")
        return 130
    except Exception as e:
        logger.error(f"Pipeline failed: {e}", exc_info=args.verbose)
        return 1


if __name__ == "__main__":
    sys.exit(main())


def validate_command():
    """CLI command to validate GeoParquet catalog files."""
    parser = argparse.ArgumentParser(
        description="Validate STAC GeoParquet catalog files for spec compliance",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Validate a single GeoParquet file
  %(prog)s --path ./catalog/partition.parquet

  # Validate all parquet files in a catalog directory
  %(prog)s --path ./catalog --recursive

  # Validate S3-based catalog
  %(prog)s --path s3://bucket/catalog --recursive

  # Output validation results to JSON
  %(prog)s --path ./catalog --output validation_report.json

  # Strict mode - fail on warnings
  %(prog)s --path ./catalog --strict
        """,
    )

    parser.add_argument(
        "--path",
        "-p",
        required=True,
        help="Path to GeoParquet file or catalog directory (local or s3://)",
    )
    parser.add_argument(
        "--recursive",
        "-r",
        action="store_true",
        help="Recursively validate all parquet files in directory",
    )
    parser.add_argument(
        "--pattern",
        default="**/*.parquet",
        help="Glob pattern for finding parquet files (default: **/*.parquet)",
    )
    parser.add_argument(
        "--expected-crs",
        default="EPSG:4326",
        help="Expected CRS for GeoParquet files (default: EPSG:4326)",
    )
    parser.add_argument(
        "--output",
        "-o",
        help="Output file for validation results (JSON format)",
    )
    parser.add_argument(
        "--strict",
        action="store_true",
        help="Treat warnings as errors (exit code 1 if any warnings)",
    )
    parser.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        help="Enable verbose output",
    )

    args = parser.parse_args()

    # Setup logging
    setup_logging(args.verbose)
    logger = logging.getLogger(__name__)

    try:
        from pathlib import Path

        from earthcatalog.validation import (
            validate_catalog,
            validate_catalog_s3,
            validate_geoparquet_file,
        )

        path = args.path

        # Determine validation type based on path
        if path.startswith("s3://"):
            logger.info(f"Validating S3 catalog: {path}")
            result = validate_catalog_s3(
                path,
                expected_crs=args.expected_crs,
                pattern=args.pattern,
            )
        elif Path(path).is_file():
            logger.info(f"Validating single file: {path}")
            file_result = validate_geoparquet_file(path, args.expected_crs)

            # Create a simple catalog result for single file
            from earthcatalog.validation import CatalogValidationResult

            result = CatalogValidationResult()
            result.add_file_result(path, file_result)
        else:
            logger.info(f"Validating catalog directory: {path}")
            result = validate_catalog(
                path,
                expected_crs=args.expected_crs,
                recursive=args.recursive,
                pattern=args.pattern,
            )

        # Print results
        print("\n" + "=" * 80)
        print("VALIDATION RESULTS")
        print("=" * 80)
        print(result.summary())
        print("=" * 80 + "\n")

        # Save results to file if requested
        if args.output:
            output_data = {
                "total_files": result.total_files,
                "valid_files": result.valid_files,
                "invalid_files": result.invalid_files,
                "warnings_count": result.warnings_count,
                "errors_count": result.errors_count,
                "files": {},
            }
            for file_path, file_result in result.file_results.items():
                output_data["files"][file_path] = {
                    "is_valid": file_result.is_valid,
                    "warnings": [
                        {"code": w.code, "message": w.message, "context": w.context} for w in file_result.warnings
                    ],
                    "errors": [
                        {"code": e.code, "message": e.message, "context": e.context} for e in file_result.errors
                    ],
                    "metadata": file_result.metadata,
                }

            with open(args.output, "w") as f:
                json.dump(output_data, f, indent=2, default=str)
            print(f"Validation results saved to: {args.output}")

        # Determine exit code
        if result.errors_count > 0:
            return 1
        if args.strict and result.warnings_count > 0:
            logger.warning("Strict mode: treating warnings as errors")
            return 1
        return 0

    except KeyboardInterrupt:
        logger.error("\nInterrupted by user")
        return 130
    except Exception as e:
        logger.error(f"Validation failed: {e}", exc_info=args.verbose)
        return 1


if __name__ == "__main__":
    sys.exit(main())
