#!/usr/bin/env python3
"""
dbt Manifest Parser

Extracts resolved database and schema information from dbt's compiled manifest.json.

This parser reads dbt's compilation output to determine the actual database and schema
where each model is materialized. This eliminates the need for manual specification
and ensures metadata stays in sync with dbt configuration.

Key Benefits:
- Single source of truth (dbt's compilation)
- Handles complex Jinja templating in dbt_project.yml
- Works with custom generate_database_name/generate_schema_name macros
- Environment-aware (respects dbt targets)
- Universal compatibility (all dbt projects)
- Manifest comparison for selective generation
"""

import json
import os
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Tuple

from snowflake_semantic_tools.shared.utils import get_logger

logger = get_logger(__name__)


# =============================================================================
# Data Classes
# =============================================================================


@dataclass
class ManifestDiff:
    """
    Result of comparing two dbt manifests.

    Used for selective generation to identify which models have changed
    between the current state and a reference (defer) manifest.

    Attributes:
        added: Models in current manifest but not in reference
        removed: Models in reference but not in current manifest
        modified: Models with different checksums between manifests
        unchanged: Models with identical checksums
    """

    added: List[str] = field(default_factory=list)
    removed: List[str] = field(default_factory=list)
    modified: List[str] = field(default_factory=list)
    unchanged: List[str] = field(default_factory=list)

    @property
    def changed(self) -> List[str]:
        """Models that need regeneration (added + modified)."""
        return self.added + self.modified

    @property
    def total_changes(self) -> int:
        """Total number of changed models."""
        return len(self.added) + len(self.modified)

    def summary(self) -> str:
        """Return a human-readable summary of changes."""
        parts = []
        if self.added:
            parts.append(f"{len(self.added)} added")
        if self.modified:
            parts.append(f"{len(self.modified)} modified")
        if self.removed:
            parts.append(f"{len(self.removed)} removed")
        if self.unchanged:
            parts.append(f"{len(self.unchanged)} unchanged")

        return ", ".join(parts) if parts else "no changes"


class ManifestParser:
    """
    Parse dbt manifest.json for database/schema resolution.

    The manifest.json file is generated by dbt during compilation and contains
    the fully resolved database, schema, and table names for every model after
    all Jinja templating and macro execution.

    Usage:
        parser = ManifestParser()
        if parser.load():
            location = parser.get_location('my_model')
            print(f"Database: {location['database']}, Schema: {location['schema']}")
    """

    def __init__(self, manifest_path: Optional[Path] = None):
        """
        Initialize manifest parser.

        Args:
            manifest_path: Path to manifest.json. If not provided, will look for:
                          - ./target/manifest.json
                          - ./target_prod/manifest.json (common pattern)
                          - ./target_{env}/manifest.json
        """
        self.manifest_path = manifest_path
        self.manifest = None
        self.model_locations = {}  # Cache: model_name -> location dict
        self._search_paths = []

    def _find_manifest(self) -> Optional[Path]:
        """
        Search for manifest.json in common locations.

        Returns:
            Path to manifest.json or None if not found
        """
        # If explicit path provided, use it
        if self.manifest_path and self.manifest_path.exists():
            return self.manifest_path

        # Search common locations
        search_paths = [
            Path.cwd() / "target" / "manifest.json",
            Path.cwd() / "target_prod" / "manifest.json",
        ]

        # Also search for target_{env} directories
        for target_dir in Path.cwd().glob("target_*"):
            if target_dir.is_dir():
                manifest_candidate = target_dir / "manifest.json"
                if manifest_candidate.exists():
                    search_paths.append(manifest_candidate)

        # Return first found
        for path in search_paths:
            if path.exists():
                logger.debug(f"Found manifest at: {path}")
                return path

        logger.debug(f"No manifest found. Searched: {[str(p) for p in search_paths]}")
        return None

    def load(self) -> bool:
        """
        Load and parse manifest.json.

        Returns:
            True if successful, False if manifest not found/invalid
        """
        manifest_path = self._find_manifest()

        if not manifest_path:
            logger.info("No dbt manifest.json found. Will use explicit database/schema.")
            return False

        try:
            with open(manifest_path, "r", encoding="utf-8") as f:
                self.manifest = json.load(f)

            self.manifest_path = manifest_path
            logger.info(f"Loaded manifest from: {manifest_path}")

            # Validate manifest structure
            if "nodes" not in self.manifest:
                logger.warning(f"Manifest missing 'nodes' key: {manifest_path}")
                return False

            # Build location cache
            self._build_location_cache()

            # Log summary
            model_count = len([k for k in self.manifest["nodes"].keys() if k.startswith("model.")])
            logger.info(f"Parsed {model_count} models from manifest")

            return True

        except json.JSONDecodeError as e:
            logger.error(f"Invalid JSON in manifest: {e}")
            return False
        except IOError as e:
            logger.error(f"Error reading manifest: {e}")
            return False
        except Exception as e:
            logger.error(f"Unexpected error loading manifest: {e}")
            return False

    def _build_location_cache(self):
        """
        Build cache of model locations from manifest nodes.

        Extracts database, schema, alias, and path information for all models.
        Only processes 'model' resource types (not tests, seeds, snapshots, etc.)
        """
        if not self.manifest or "nodes" not in self.manifest:
            return

        for node_id, node in self.manifest["nodes"].items():
            # Only process models (skip tests, seeds, snapshots, etc.)
            if node.get("resource_type") != "model":
                continue

            model_name = node.get("name")
            if not model_name:
                continue

            # Extract location information
            # Note: database and schema are RESOLVED by dbt (no more Jinja)
            database = node.get("database", "")
            schema = node.get("schema", "")

            # Validate that database and schema are non-empty before uppercasing
            if not database or not schema:
                logger.warning(f"Model '{model_name}' has empty database or schema in manifest. Skipping.")
                continue

            database = database.upper()
            schema = schema.upper()
            alias = node.get("alias", model_name)
            relation_name = node.get("relation_name", "")
            original_file_path = node.get("original_file_path", "")

            # Store in cache
            self.model_locations[model_name] = {
                "database": database,
                "schema": schema,
                "alias": alias,
                "relation_name": relation_name,
                "original_file_path": original_file_path,
                "unique_id": node_id,
            }

            logger.debug(f"Cached location for {model_name}: {database}.{schema}")

    def get_location(self, model_name: str) -> Optional[Dict[str, str]]:
        """
        Get database and schema for a model by name.

        Args:
            model_name: Name of the dbt model (e.g., 'memberships_members')

        Returns:
            Dict with keys: database, schema, alias, relation_name, original_file_path
            Returns None if model not found in manifest

        Example:
            >>> parser.get_location('memberships_members')
            {
                'database': 'ANALYTICS',
                'schema': 'MEMBERSHIPS',
                'alias': 'memberships_members',
                'relation_name': '"ANALYTICS"."MEMBERSHIPS"."MEMBERSHIPS_MEMBERS"',
                'original_file_path': 'models/analytics/memberships/memberships_members.sql'
            }
        """
        location = self.model_locations.get(model_name)

        if not location:
            logger.debug(f"Model '{model_name}' not found in manifest")

        return location

    def get_location_by_path(self, model_path: Path) -> Optional[Dict[str, str]]:
        """
        Get database and schema for a model by its file path.

        Useful when processing models by directory. Tries multiple matching strategies:
        1. Extract model name from filename stem
        2. Match against original_file_path in manifest

        Args:
            model_path: Path to the model .sql file

        Returns:
            Dict with database, schema, etc. or None if not found

        Example:
            >>> parser.get_location_by_path(Path('models/analytics/memberships/members.sql'))
            {'database': 'ANALYTICS', 'schema': 'MEMBERSHIPS', ...}
        """
        # Strategy 1: Try exact name match
        model_name = model_path.stem
        location = self.get_location(model_name)
        if location:
            return location

        # Strategy 2: Match by original_file_path
        # Normalize paths for comparison (use forward slashes)
        model_path_str = str(model_path).replace("\\", "/")

        # Convert to Path objects for more precise matching
        from pathlib import Path as PathObj

        model_path_obj = PathObj(model_path_str)

        for cached_name, cached_location in self.model_locations.items():
            original_path = cached_location.get("original_file_path", "")
            if not original_path:
                continue

            original_path_obj = PathObj(original_path.replace("\\", "/"))

            # Match by comparing resolved paths or checking if one ends with the other
            # Use parts comparison for more precise matching
            try:
                # Check if the paths are the same or if model_path ends with original_path
                if (
                    model_path_obj == original_path_obj
                    or model_path_str.endswith("/" + original_path)
                    or model_path_obj.parts[-len(original_path_obj.parts) :] == original_path_obj.parts
                ):
                    logger.debug(f"Matched by path: {model_path} -> {cached_name}")
                    return cached_location
            except (IndexError, ValueError):
                # Path comparison failed, continue to next candidate
                continue

        logger.debug(f"No manifest entry found for path: {model_path}")
        return None

    def get_all_models_in_directory(self, directory: Path) -> List[Dict[str, any]]:
        """
        Get all models that exist under a specific directory.

        Args:
            directory: Directory path (e.g., 'models/analytics/memberships')

        Returns:
            List of dicts with model_name and location info

        Example:
            >>> parser.get_all_models_in_directory(Path('models/analytics/memberships'))
            [
                {'model_name': 'memberships_members', 'database': 'ANALYTICS', ...},
                {'model_name': 'memberships_subscriptions', 'database': 'ANALYTICS', ...}
            ]
        """
        directory_str = str(directory).replace("\\", "/")
        matches = []

        for model_name, location in self.model_locations.items():
            original_path = location.get("original_file_path", "")
            if not original_path:
                continue

            # Check if model is under this directory
            if original_path.startswith(directory_str):
                matches.append({"model_name": model_name, **location})

        logger.debug(f"Found {len(matches)} models in directory: {directory}")
        return matches

    def get_target_name(self) -> Optional[str]:
        """
        Get the target name (environment) from manifest metadata.

        The target name indicates which dbt profile target was used during compilation
        (e.g., 'prod', 'dev', 'qa').

        Returns:
            Target name string or None if not available

        Example:
            >>> parser.get_target_name()
            'prod'
        """
        if not self.manifest:
            return None

        metadata = self.manifest.get("metadata", {})
        target = metadata.get("target_name")

        if target:
            logger.debug(f"Manifest target: {target}")

        return target

    def is_prod_target(self) -> bool:
        """
        Check if manifest was compiled for production target.

        Looks for target names commonly used for production: 'prod', 'production'.
        Case-insensitive comparison.

        Returns:
            True if production target, False otherwise
        """
        target = self.get_target_name()
        if not target:
            return False

        return target.lower() in ("prod", "production")

    def get_dbt_version(self) -> Optional[str]:
        """
        Get dbt version that generated the manifest.

        Returns:
            dbt version string (e.g., '1.7.0') or None
        """
        if not self.manifest:
            return None

        metadata = self.manifest.get("metadata", {})
        return metadata.get("dbt_version")

    def get_project_name(self) -> Optional[str]:
        """
        Get dbt project name from manifest.

        Returns:
            Project name string or None
        """
        if not self.manifest:
            return None

        metadata = self.manifest.get("metadata", {})
        return metadata.get("project_name")

    def validate_against_yaml(self, model_name: str, yaml_database: str, yaml_schema: str) -> Dict[str, any]:
        """
        Compare manifest location with YAML metadata to detect drift.

        Args:
            model_name: Name of the model
            yaml_database: Database from YAML meta.sst.database
            yaml_schema: Schema from YAML meta.sst.schema

        Returns:
            Dict with 'matches' bool and 'differences' list

        Example:
            >>> parser.validate_against_yaml('members', 'ANALYTICS', 'MEMBERSHIPS')
            {'matches': True, 'differences': []}

            >>> parser.validate_against_yaml('members', 'WRONG_DB', 'MEMBERSHIPS')
            {'matches': False, 'differences': ['database: WRONG_DB (yaml) vs ANALYTICS (manifest)']}
        """
        location = self.get_location(model_name)

        if not location:
            return {"matches": False, "differences": [f"Model '{model_name}' not found in manifest"]}

        differences = []

        # Compare database (case-insensitive)
        if location["database"].upper() != yaml_database.upper():
            differences.append(f"database: {yaml_database} (yaml) vs {location['database']} (manifest)")

        # Compare schema (case-insensitive)
        if location["schema"].upper() != yaml_schema.upper():
            differences.append(f"schema: {yaml_schema} (yaml) vs {location['schema']} (manifest)")

        return {"matches": len(differences) == 0, "differences": differences}

    def get_summary(self) -> Dict[str, any]:
        """
        Get summary statistics about the loaded manifest.

        Returns:
            Dict with summary information
        """
        if not self.manifest:
            return {"loaded": False}

        nodes = self.manifest.get("nodes", {})
        model_count = len([k for k in nodes.keys() if k.startswith("model.")])

        # Count by database
        databases = {}
        for location in self.model_locations.values():
            db = location["database"]
            databases[db] = databases.get(db, 0) + 1

        return {
            "loaded": True,
            "manifest_path": str(self.manifest_path),
            "target_name": self.get_target_name(),
            "dbt_version": self.get_dbt_version(),
            "project_name": self.get_project_name(),
            "total_models": model_count,
            "models_by_database": databases,
        }

    def get_manifest_age(self) -> Optional[timedelta]:
        """
        Get the age of the manifest file.

        Returns:
            timedelta representing how long ago manifest was created, or None if not loaded
        """
        if not self.manifest_path or not self.manifest_path.exists():
            return None

        try:
            mtime = os.path.getmtime(self.manifest_path)
            manifest_time = datetime.fromtimestamp(mtime)
            return datetime.now() - manifest_time
        except Exception as e:
            logger.warning(f"Could not determine manifest age: {e}")
            return None

    def is_manifest_stale(self, threshold_hours: int = 24) -> Tuple[bool, Optional[str]]:
        """
        Check if manifest is stale (old or outdated by model files).

        Checks two conditions:
        1. Manifest is older than threshold_hours
        2. Any .sql model files are newer than manifest

        Args:
            threshold_hours: Consider manifest stale if older than this many hours

        Returns:
            Tuple of (is_stale, reason_message)
        """
        if not self.manifest_path or not self.manifest_path.exists():
            return (True, "Manifest file not found")

        try:
            # Get manifest modification time
            manifest_mtime = os.path.getmtime(self.manifest_path)
            manifest_time = datetime.fromtimestamp(manifest_mtime)
            age = datetime.now() - manifest_time

            # Check 1: Age threshold
            if age.total_seconds() > (threshold_hours * 3600):
                hours = int(age.total_seconds() / 3600)
                days = hours // 24
                if days > 0:
                    return (
                        True,
                        f"Manifest is {days} day(s) old (last compiled: {manifest_time.strftime('%Y-%m-%d %H:%M')})",
                    )
                else:
                    return (
                        True,
                        f"Manifest is {hours} hour(s) old (last compiled: {manifest_time.strftime('%Y-%m-%d %H:%M')})",
                    )

            # Check 2: Are any .sql files newer than manifest?
            models_dir = Path.cwd() / "models"
            if models_dir.exists():
                sql_files = list(models_dir.rglob("*.sql"))
                newer_files = []

                for sql_file in sql_files:
                    try:
                        if os.path.getmtime(sql_file) > manifest_mtime:
                            newer_files.append(sql_file.name)
                            # Stop after finding a few to avoid long messages
                            if len(newer_files) >= 3:
                                break
                    except Exception:
                        pass  # Skip files we can't check

                if newer_files:
                    files_str = ", ".join(newer_files[:3])
                    more = f" and {len(newer_files) - 3} more" if len(newer_files) > 3 else ""
                    return (True, f"Model files modified since manifest ({files_str}{more})")

            # Manifest is fresh
            return (False, None)

        except Exception as e:
            logger.warning(f"Could not check manifest staleness: {e}")
            return (False, None)  # Assume fresh if we can't check

    def compare_to(self, other: "ManifestParser") -> ManifestDiff:
        """
        Compare this manifest to another manifest for change detection.

        Uses dbt's checksum field to detect model changes. The checksum
        is computed by dbt from the model's SQL content and configuration.

        This method is used for selective generation to identify which
        models have changed between the current state and a reference
        (defer) manifest.

        Args:
            other: Another ManifestParser instance to compare against
                   (typically a production/defer manifest)

        Returns:
            ManifestDiff with categorized model lists

        Example:
            >>> current = ManifestParser(Path("target/manifest.json"))
            >>> current.load()
            >>> defer = ManifestParser(Path("prod_artifacts/manifest.json"))
            >>> defer.load()
            >>> diff = current.compare_to(defer)
            >>> print(f"Changed models: {diff.changed}")
            >>> print(f"Summary: {diff.summary()}")
        """
        diff = ManifestDiff()

        if not self.manifest or not other.manifest:
            logger.warning("Cannot compare manifests: one or both not loaded")
            return diff

        # Get all model names from both manifests
        current_models = set(self.model_locations.keys())
        other_models = set(other.model_locations.keys())

        # Find added models (in current but not in reference)
        diff.added = list(current_models - other_models)

        # Find removed models (in reference but not in current)
        diff.removed = list(other_models - current_models)

        # For models in both, compare checksums
        common_models = current_models & other_models

        for model_name in common_models:
            current_checksum = self._get_model_checksum(model_name)
            other_checksum = other._get_model_checksum(model_name)

            if current_checksum != other_checksum:
                diff.modified.append(model_name)
            else:
                diff.unchanged.append(model_name)

        # Sort all lists for consistent output
        diff.added.sort()
        diff.removed.sort()
        diff.modified.sort()
        diff.unchanged.sort()

        logger.info(f"Manifest comparison: {diff.summary()}")

        return diff

    def _get_model_checksum(self, model_name: str) -> Optional[str]:
        """
        Get the checksum for a model from the manifest.

        The checksum is stored in manifest.nodes[node_id].checksum.checksum

        Args:
            model_name: Name of the model

        Returns:
            Checksum string or None if not found
        """
        if not self.manifest:
            return None

        # Find the node for this model
        for node_id, node in self.manifest.get("nodes", {}).items():
            if node.get("resource_type") != "model":
                continue
            if node.get("name") == model_name:
                checksum_info = node.get("checksum", {})
                return checksum_info.get("checksum")

        return None

    def get_models_for_tables(self, table_names: List[str]) -> List[str]:
        """
        Get model names that correspond to a list of table names.

        This is useful when filtering models for selective generation
        based on which tables are referenced by semantic views.

        Args:
            table_names: List of table names (may include database.schema.table format)

        Returns:
            List of model names found in the manifest

        Example:
            >>> parser.get_models_for_tables(["customers", "orders"])
            ["customers", "orders"]
        """
        matched_models = []

        for table_name in table_names:
            # Handle fully qualified names (DATABASE.SCHEMA.TABLE)
            parts = table_name.split(".")
            simple_name = parts[-1].lower()  # Get just the table name

            # Look for matching model
            if simple_name in self.model_locations:
                matched_models.append(simple_name)
            else:
                # Try case-insensitive match
                for model_name in self.model_locations.keys():
                    if model_name.lower() == simple_name:
                        matched_models.append(model_name)
                        break

        return matched_models
