"""Fast catalog.json parser with caching.

catalog.json is generated by 'dbt docs generate' and contains:
- Column names and types from database
- Table statistics (row count, bytes)
- Metadata for all models

Performance: ~10ms vs ~2500ms for BigQuery INFORMATION_SCHEMA
"""

import os
from datetime import datetime, timedelta, timezone
from functools import cached_property
from pathlib import Path
from typing import Any, Optional

try:
    import orjson
    HAS_ORJSON = True
except ImportError:
    import json
    HAS_ORJSON = False


__all__ = ['CatalogParser']


class CatalogParser:
    """Parse catalog.json with caching and validation.

    Examples:
        >>> parser = CatalogParser('~/dbt-state/catalog.json')
        >>> columns = parser.get_columns('core_client__rfm_score')
        >>> print(len(columns))  # 12
        >>> print(columns[0])
        {'name': 'client_id', 'data_type': 'integer'}
    """

    def __init__(self, catalog_path: str):
        """Initialize parser with catalog path.

        Args:
            catalog_path: Path to catalog.json (supports ~ expansion)
        """
        self.catalog_path = str(Path(catalog_path).expanduser())

    @cached_property
    def catalog(self) -> dict[str, Any]:
        """Lazy load and parse catalog.json.

        Returns:
            Parsed catalog dict

        Raises:
            FileNotFoundError: If catalog.json doesn't exist
            ValueError: If catalog.json is invalid JSON
        """
        if not os.path.exists(self.catalog_path):
            raise FileNotFoundError(f"Catalog not found: {self.catalog_path}")

        with open(self.catalog_path, 'rb') as f:
            if HAS_ORJSON:
                return orjson.loads(f.read())
            else:
                return json.load(f)

    def get_columns(
        self,
        model_name: str,
        project_name: str = "admirals_bi_dwh"
    ) -> Optional[list[dict[str, str]]]:
        """Get columns for model from catalog.

        Args:
            model_name: Model name in dbt format (e.g., 'core_client__rfm_score')
            project_name: dbt project name (default: 'admirals_bi_dwh')

        Returns:
            List of column dicts with 'name' and 'data_type', or None if not found

        Examples:
            >>> columns = parser.get_columns('core_client__rfm_score')
            >>> columns[0]
            {'name': 'client_id', 'data_type': 'integer'}
        """
        # Construct node ID (same format as manifest.json)
        node_id = f"model.{project_name}.{model_name}"

        node = self.catalog.get('nodes', {}).get(node_id)
        if not node:
            return None

        columns = node.get('columns', {})
        if not columns:
            return None

        # Convert to dbt-meta format (sorted by index)
        result = []
        for col_name, col_data in columns.items():
            result.append({
                'name': col_data.get('name', col_name),
                'data_type': self._normalize_type(col_data.get('type', 'unknown')),
                '_index': col_data.get('index', 999)  # For sorting
            })

        # Sort by index (column order in table)
        result.sort(key=lambda x: x['_index'])

        # Remove internal _index field
        for col in result:
            del col['_index']

        return result

    def get_table_stats(self, model_name: str, project_name: str = "admirals_bi_dwh") -> Optional[dict[str, Any]]:
        """Get table statistics from catalog.

        Args:
            model_name: Model name in dbt format
            project_name: dbt project name

        Returns:
            Dict with 'row_count' and 'bytes', or None if not found

        Examples:
            >>> stats = parser.get_table_stats('core_client__rfm_score')
            >>> stats
            {'row_count': 123456, 'bytes': 9876543}
        """
        node_id = f"model.{project_name}.{model_name}"
        node = self.catalog.get('nodes', {}).get(node_id)

        if not node:
            return None

        stats = node.get('stats', {})
        return {
            'row_count': stats.get('row_count', {}).get('value'),
            'bytes': stats.get('bytes', {}).get('value'),
        }

    def is_stale(self, max_age_hours: int = 2) -> bool:
        """Check if catalog is too old.

        Args:
            max_age_hours: Maximum age in hours before catalog is considered stale

        Returns:
            True if catalog is older than max_age_hours, False otherwise

        Examples:
            >>> parser.is_stale(max_age_hours=1)
            False  # Generated 30 minutes ago
        """
        metadata = self.catalog.get('metadata', {})
        generated_at_str = metadata.get('generated_at')

        if not generated_at_str:
            # No timestamp - assume stale
            return True

        try:
            # Parse ISO format: "2025-11-20T10:30:00Z"
            generated_at_str = generated_at_str.replace('Z', '+00:00')
            gen_time = datetime.fromisoformat(generated_at_str)

            # Calculate age
            now = datetime.now(timezone.utc)
            age = now - gen_time

            return age > timedelta(hours=max_age_hours)
        except (ValueError, AttributeError):
            # Invalid timestamp - assume stale
            return True

    def get_age_hours(self) -> Optional[float]:
        """Get catalog age in hours.

        Returns:
            Age in hours, or None if timestamp unavailable

        Examples:
            >>> parser.get_age_hours()
            0.5  # 30 minutes old
        """
        metadata = self.catalog.get('metadata', {})
        generated_at_str = metadata.get('generated_at')

        if not generated_at_str:
            return None

        try:
            generated_at_str = generated_at_str.replace('Z', '+00:00')
            gen_time = datetime.fromisoformat(generated_at_str)
            now = datetime.now(timezone.utc)
            age = now - gen_time
            return age.total_seconds() / 3600
        except (ValueError, AttributeError):
            return None

    @staticmethod
    def _normalize_type(bq_type: str) -> str:
        """Normalize BigQuery type to lowercase.

        Args:
            bq_type: BigQuery type (e.g., 'INT64', 'STRING')

        Returns:
            Normalized type (e.g., 'integer', 'string')

        Examples:
            >>> CatalogParser._normalize_type('INT64')
            'integer'
            >>> CatalogParser._normalize_type('STRING')
            'string'
        """
        if not bq_type:
            return 'unknown'

        # BigQuery → dbt-meta type mapping
        type_map = {
            'INT64': 'integer',
            'INTEGER': 'integer',
            'FLOAT64': 'float',
            'FLOAT': 'float',
            'NUMERIC': 'numeric',
            'BIGNUMERIC': 'bignumeric',
            'BOOL': 'boolean',
            'BOOLEAN': 'boolean',
            'STRING': 'string',
            'BYTES': 'bytes',
            'DATE': 'date',
            'DATETIME': 'datetime',
            'TIME': 'time',
            'TIMESTAMP': 'timestamp',
            'STRUCT': 'struct',
            'ARRAY': 'array',
            'GEOGRAPHY': 'geography',
            'JSON': 'json',
        }

        return type_map.get(bq_type.upper(), bq_type.lower())
