"""PostgREST API view generation from dbt models.

This module automates the generation of PostgREST API views from dbt models:
- Parses dbt manifest.json
- Generates CREATE VIEW statements
- Manages permissions based on dbt tags
- Supports CLI commands for viewing/applying/diffing changes
"""

import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT

from phlo.config.base import BaseConfig
from phlo.logging import get_logger
from pydantic import Field

logger = get_logger(__name__)


class PostgrestViewsSettings(BaseConfig):
    """Settings for PostgREST view generation."""

    dbt_manifest_path: str = Field(
        default="workflows/transforms/dbt/target/manifest.json",
        description="Path to dbt manifest.json",
    )
    dbt_api_source_schema: str | None = Field(
        default=None,
        description="dbt schema to expose through generated PostgREST views",
    )
    postgres_host: str = Field(default="postgres", description="PostgreSQL host")
    postgres_port: int = Field(default=5432, description="PostgreSQL port")
    postgres_user: str = Field(default="phlo", description="PostgreSQL username")
    postgres_password: str = Field(default="phlo", description="PostgreSQL password")
    postgres_db: str = Field(default="phlo", description="PostgreSQL database name")


@dataclass
class DbtModel:
    """Represents a dbt model from manifest."""

    name: str
    schema: str
    description: str
    columns: dict
    tags: list[str]
    unique_id: str


class DbtManifestParser:
    """Parses dbt manifest.json to extract model metadata."""

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

        Args:
            manifest_path: Path to manifest.json. If None, uses config value.
            source_schema: dbt schema to expose. If None, uses config value.
        """
        settings = PostgrestViewsSettings()
        if manifest_path is None:
            manifest_path = settings.dbt_manifest_path
        if source_schema is None:
            source_schema = settings.dbt_api_source_schema

        self.manifest_path = Path(manifest_path)
        self.source_schema = source_schema

        if not self.manifest_path.exists():
            raise FileNotFoundError(f"dbt manifest not found at {manifest_path}")

    def parse(self) -> dict[str, DbtModel]:
        """Parse manifest and extract models.

        Returns:
            Dictionary of model_name -> DbtModel
        """
        with open(self.manifest_path) as f:
            manifest = json.load(f)

        source_schema = self.source_schema or self._infer_source_schema(manifest)

        models = {}
        for unique_id, node in manifest.get("nodes", {}).items():
            if not unique_id.startswith("model."):
                continue

            if node.get("schema") != source_schema:
                continue

            model = DbtModel(
                name=node.get("name"),
                schema=node.get("schema"),
                description=node.get("description", ""),
                columns=node.get("columns", {}),
                tags=node.get("tags", []),
                unique_id=unique_id,
            )

            models[model.name] = model

        return models

    def _infer_source_schema(self, manifest: dict) -> str:
        """Infer the source schema when configuration is omitted."""
        schemas = {
            node.get("schema")
            for unique_id, node in manifest.get("nodes", {}).items()
            if unique_id.startswith("model.") and isinstance(node.get("schema"), str)
        }
        if len(schemas) == 1:
            return next(iter(schemas))
        raise ValueError(
            "dbt_api_source_schema is not configured and manifest contains multiple model "
            f"schemas: {sorted(schemas)}"
        )

    def build_dependency_graph(self) -> dict[str, list[str]]:
        """Build model dependency graph from manifest.

        Returns:
            Dictionary of model_name -> list of dependent model names
        """
        with open(self.manifest_path) as f:
            manifest = json.load(f)

        graph = {}
        for unique_id, node in manifest.get("nodes", {}).items():
            if not unique_id.startswith("model."):
                continue

            model_name = node.get("name")
            depends_on = []

            for dep_id in node.get("depends_on", {}).get("nodes", []):
                if dep_id.startswith("model."):
                    dep_name = dep_id.split(".")[-1]
                    depends_on.append(dep_name)

            graph[model_name] = depends_on

        return graph


class ViewGenerator:
    """Generates PostgREST API views from dbt models."""

    def __init__(
        self,
        manifest_path: Optional[str] = None,
        api_schema: str = "api",
        source_schema: Optional[str] = None,
    ):
        """Initialize view generator.

        Args:
            manifest_path: Path to dbt manifest.json
            api_schema: Schema for API views (default: api)
            source_schema: dbt schema to expose through API views
        """
        self.parser = DbtManifestParser(manifest_path, source_schema=source_schema)
        self.api_schema = api_schema

    def generate_view_sql(self, model: DbtModel) -> str:
        """Generate CREATE VIEW statement for a model.

        Args:
            model: DbtModel instance

        Returns:
            SQL CREATE VIEW statement
        """
        # Extract column names in order
        columns = list(model.columns.keys())
        column_list = ",\n    ".join(columns) if columns else "*"

        sql = f"""-- Auto-generated by phlo api generate-views
-- Model: {model.name}
-- Description: {model.description}

CREATE OR REPLACE VIEW {self.api_schema}.{model.name} AS
SELECT
    {column_list}
FROM {model.schema}.{model.name};

COMMENT ON VIEW {self.api_schema}.{model.name} IS '{self._escape_string(model.description)}';
"""
        return sql

    def generate_permissions_sql(self, model: DbtModel) -> str:
        """Generate GRANT and RLS policy SQL for a model.

        Args:
            model: DbtModel instance

        Returns:
            SQL permission and RLS statements
        """
        sql_parts = ["\n-- Permissions"]

        # Map tags to roles
        role_mapping = {
            "public": ["anon"],
            "analyst": ["analyst", "admin"],
            "admin": ["admin"],
        }

        granted_roles = set()
        for tag in model.tags:
            for role in role_mapping.get(tag, []):
                if role not in granted_roles:
                    sql_parts.append(f"GRANT SELECT ON {self.api_schema}.{model.name} TO {role};")
                    granted_roles.add(role)

        # Generate RLS policies for tracked roles
        if granted_roles:
            sql_parts.append("\n-- RLS Policy")
            for role in granted_roles:
                sql_parts.append(
                    f"CREATE POLICY {role}_access ON {self.api_schema}.{model.name} "
                    f"FOR SELECT TO {role} USING (true);"
                )

        return "\n".join(sql_parts)

    def generate_all_views(self, model_filter: Optional[str] = None) -> str:
        """Generate SQL for all views.

        Args:
            model_filter: Glob pattern to filter models (e.g., 'mrt_*')

        Returns:
            Combined SQL for all views and permissions
        """
        models = self.parser.parse()

        # Filter models
        if model_filter:
            from fnmatch import fnmatch

            models = {name: model for name, model in models.items() if fnmatch(name, model_filter)}

        if not models:
            return ""

        # Sort by dependencies (simple topological sort)
        dependency_graph = self.parser.build_dependency_graph()
        sorted_models = self._topological_sort(models, dependency_graph)

        sql_parts = [
            "-- PostgREST API Views",
            f"-- Generated from dbt manifest at {self.parser.manifest_path}",
            f"-- Schema: {self.api_schema}",
            "--\n",
        ]

        for model_name in sorted_models:
            model = models[model_name]
            sql_parts.append(self.generate_view_sql(model))
            sql_parts.append(self.generate_permissions_sql(model))
            sql_parts.append("\n")

        return "\n".join(sql_parts)

    def _topological_sort(
        self, models: dict[str, DbtModel], graph: dict[str, list[str]]
    ) -> list[str]:
        """Topologically sort models by dependencies.

        Args:
            models: Dictionary of models to sort
            graph: Dependency graph

        Returns:
            List of model names in dependency order
        """
        visited = set()
        order = []

        def visit(name: str) -> None:
            """Depth-first visit helper for dependency ordering.

            Args:
                name: Model name to visit.
            """
            if name in visited:
                return
            visited.add(name)

            for dep in graph.get(name, []):
                if dep in models:
                    visit(dep)

            if name in models:
                order.append(name)

        for name in models:
            visit(name)

        return order

    @staticmethod
    def _escape_string(s: str) -> str:
        """Escape string for SQL comments.

        Args:
            s: String to escape

        Returns:
            Escaped string
        """
        return s.replace("'", "''")


class PostgreSTViewManager:
    """Manages PostgreSQL views and permissions."""

    def __init__(
        self,
        host: Optional[str] = None,
        port: Optional[int] = None,
        database: Optional[str] = None,
        user: Optional[str] = None,
        password: Optional[str] = None,
    ):
        """Initialize PostgreSQL connection manager.

        Args:
            host: Database host
            port: Database port
            database: Database name
            user: Database user
            password: Database password
        """
        settings = PostgrestViewsSettings()
        self.host = host or settings.postgres_host
        self.port = int(port or settings.postgres_port)
        self.database = database or settings.postgres_db
        self.user = user or settings.postgres_user
        self.password = password or settings.postgres_password

    def get_connection(self):
        """Get PostgreSQL connection."""
        conn = psycopg2.connect(
            host=self.host,
            port=self.port,
            database=self.database,
            user=self.user,
            password=self.password,
        )
        conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
        return conn

    def execute_sql(self, sql: str, verbose: bool = True) -> None:
        """Execute SQL statements.

        Args:
            sql: SQL to execute
            verbose: Print execution messages
        """
        conn = self.get_connection()
        cursor = conn.cursor()

        try:
            if verbose:
                logger.info("Executing %s characters of SQL...", len(sql))
            cursor.execute(sql)
            if verbose:
                logger.info("✓ SQL executed successfully")
        except Exception as e:
            logger.error("✗ SQL execution failed: %s", e)
            raise
        finally:
            cursor.close()
            conn.close()

    def get_existing_views(self, schema: str = "api") -> set[str]:
        """Get list of existing views in schema.

        Args:
            schema: Schema name

        Returns:
            Set of view names
        """
        conn = self.get_connection()
        cursor = conn.cursor()

        try:
            cursor.execute(
                """
                SELECT table_name FROM information_schema.tables
                WHERE table_schema = %s AND table_type = 'VIEW'
            """,
                (schema,),
            )
            return {row[0] for row in cursor.fetchall()}
        finally:
            cursor.close()
            conn.close()

    def generate_diff(self, new_sql: str, schema: str = "api") -> str:
        """Generate diff of new views vs existing.

        Args:
            new_sql: Generated SQL
            schema: Schema name

        Returns:
            Diff summary
        """
        existing_views = self.get_existing_views(schema)

        # Parse generated SQL to find new views
        import re

        new_views = set(re.findall(rf"CREATE OR REPLACE VIEW {schema}\.(\w+)", new_sql))

        lines = ["Views to be created/updated:"]
        for view in sorted(new_views):
            status = "(updated)" if view in existing_views else "(new)"
            lines.append(f"  {view} {status}")

        removed = existing_views - new_views
        if removed:
            lines.append("Views to be removed:")
            for view in sorted(removed):
                lines.append(f"  {view} (orphaned)")

        return "\n".join(lines)


def generate_views(
    output: Optional[str] = None,
    apply: bool = False,
    diff: bool = False,
    models: Optional[str] = None,
    manifest_path: Optional[str] = None,
    api_schema: str = "api",
    source_schema: Optional[str] = None,
    verbose: bool = True,
) -> str:
    """Generate PostgREST API views from dbt models.

    Args:
        output: Output file path (default: stdout)
        apply: Apply SQL directly to database
        diff: Show diff only
        models: Model filter pattern (e.g., 'mrt_*')
        manifest_path: Path to dbt manifest.json
        api_schema: Schema for API views
        source_schema: dbt schema to expose
        verbose: Print progress messages

    Returns:
        Generated SQL or diff summary
    """
    if verbose:
        logger.info("=" * 60)
        logger.info("PostgREST API View Generation")
        logger.info("=" * 60)

    # Generate SQL
    generator = ViewGenerator(manifest_path, api_schema, source_schema=source_schema)
    sql = generator.generate_all_views(models)

    if not sql:
        if verbose:
            logger.info("No models found matching filter")
        return ""

    if verbose:
        logger.info("Generated SQL for %s characters", len(sql))

    # Handle diff
    if diff:
        manager = PostgreSTViewManager()
        diff_output = manager.generate_diff(sql, api_schema)
        if verbose:
            logger.info("\n%s", diff_output)
        return diff_output

    # Handle apply
    if apply:
        if verbose:
            logger.info("Applying to database...")
        view_names = sorted(
            set(re.findall(rf"CREATE OR REPLACE VIEW {re.escape(api_schema)}\.(\w+)", sql))
        )
        logger.info(
            "postgrest_view_apply_started",
            schema=api_schema,
            view_count=len(view_names),
            view_names=view_names,
        )
        manager = PostgreSTViewManager()
        try:
            manager.execute_sql(sql, verbose=verbose)
        except Exception:
            logger.exception(
                "postgrest_view_apply_failed",
                schema=api_schema,
                view_count=len(view_names),
                view_names=view_names,
            )
            raise
        logger.info(
            "postgrest_view_apply_succeeded",
            schema=api_schema,
            view_count=len(view_names),
            view_names=view_names,
        )
        return "Views applied successfully"

    # Handle output file
    if output:
        output_path = Path(output)
        output_path.write_text(sql)
        if verbose:
            logger.info("✓ SQL written to %s", output)
        return f"SQL written to {output}"

    # Default: print to stdout
    return sql
