import json
import pprint
import requests
import snowflake.connector
import re
import time
from typing import Dict, Any, Union, List, Set, Iterable
from datetime import datetime
from pathlib import Path

try:
    from google.cloud import bigquery
    from google.api_core.exceptions import Conflict, NotFound, Forbidden

    BIGQUERY_AVAILABLE = True
except ImportError:
    BIGQUERY_AVAILABLE = False
    bigquery = None
    Conflict = None
    NotFound = None
    Forbidden = None

from cruxctl.command_groups.auth.connectors.snowflake_conn import (
    get_snowflake_connection_instance,
)
from cruxctl.command_groups.profile.profile import get_current_profile, console
from cruxctl.common.utils.api_utils import set_api_token
from requests.exceptions import HTTPError, Timeout, RequestException
from snowflake.connector import DictCursor
from cruxctl.common.utils.typer_utils import typer, logger


class WranglesError(Exception):
    """Base exception for Wrangles operations"""

    pass


class AuthorizationError(WranglesError):
    """Exception for authorization failures"""

    pass


class ValidationError(WranglesError):
    """Exception for validation failures"""

    pass


class Wrangles:
    # Constants for API endpoints
    API_BASE_URL = "https://api.cruxinformatics.com/v2/ops"

    # Snowflake configuration
    SNOWFLAKE_CONFIG = {
        "account": "ts91674",
        "account_path": "ts91674.us-east-1",
        "replication_source": None,
        "region": "Master.us-east-1",
        "location": "US",
        "database": "CX_DATA_LAKE",
        "table_schema": "PRIVATE",
        "view_schema": "CRUX_GENERAL",
        "curated_view_schema": "CRUX_CURATED",
        "wrangle_view_schema": "CRUX_WRANGLE",
        "wrangle_uat_view_schema": "CRUX_WRANGLE_UAT",
    }

    # BigQuery configuration
    BIGQUERY_PROJECT = "crux-data-prd"
    BIGQUERY_DATASET_PREFIX = "wrangle"

    # Field constants
    CRUX_DATE_FIELD = "crux_supplier_implied_dt"
    DEV_IN_PROGRESS_FIELD = {
        "obj_type": "ViewRow",
        "deleted": False,
        "out_field_name": "Dev_In_Progress",
        "out_field_type": "STRING",
        "out_field_description": "Development In Progress",
    }

    # Retry configuration
    MAX_RETRIES = 3
    RETRY_DELAY = 2  # seconds
    TIMEOUT = 30  # seconds

    @staticmethod
    def retry_on_failure(func):
        """Decorator to retry operations on transient failures"""

        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, Wrangles.MAX_RETRIES + 1):
                try:
                    return func(*args, **kwargs)
                except (Timeout, RequestException) as e:
                    last_exception = e
                    if attempt < Wrangles.MAX_RETRIES:
                        wait_time = Wrangles.RETRY_DELAY * attempt
                        logger.warning(
                            f"Attempt {attempt} failed: {e}. "
                            f"Retrying in {wait_time}s..."
                        )
                        time.sleep(wait_time)
                    else:
                        logger.error(f"All {Wrangles.MAX_RETRIES} attempts failed")
                except Exception:
                    raise
            raise last_exception

        return wrapper

    @staticmethod
    def validate_inputs(**kwargs):
        """Validate input parameters"""
        for key, value in kwargs.items():
            if value is None or (isinstance(value, str) and not value.strip()):
                raise ValidationError(f"Parameter '{key}' cannot be empty or None")

    def list_all_wrangles(self, cdu_id):
        """List all wrangles for a CDU or globally"""
        profile = get_current_profile()
        token = set_api_token(console, profile)
        if not token:
            raise typer.Exit(1)

        dataframe_cat = {}
        display_wrangle = []
        if cdu_id:
            catalog_url = (
                f"{self.API_BASE_URL}/"
                f"dataframe-catalog?filter=outFrame-cduId.eq.{cdu_id.capitalize()},"
                "type.eq.WRANGLE&limit=100&offset=0"
            )
        else:
            catalog_url = (
                f"{self.API_BASE_URL}/"
                "dataframe-catalog?filter="
                "type.eq.WRANGLE&limit=5000&offset=0"
            )

        try:
            response = requests.get(
                catalog_url,
                headers={
                    "Accept": "application/json",
                    "Authorization": f"Bearer {token}",
                },
                timeout=self.TIMEOUT,
            )
            response.raise_for_status()
        except HTTPError as http_err:
            logger.error(f"HTTP error occurred: {http_err}")
            logger.error(f"HTTP ERROR RESPONSE: {response.text}")
            raise
        except Exception as err:
            logger.error(f"Other error occurred: {err}")
            raise

        catalog_json = response.json()

        if not catalog_json:
            logger.warning(f"No wrangles found for CDU {cdu_id}")
            return

        for item in catalog_json:
            id = item["id"]
            status = (
                "PROD"
                if item["status"] == "LIVE"
                else "UAT"
                if item["status"] == "GENERATED"
                else "UNKNOWN"
            )
            dataframe_cat[id] = {
                "name": item["name"],
                "status": status,
            }

        for id, value in dataframe_cat.items():
            name = value["name"]
            status = value["status"]
            dwtype = "UNKNOWN"

            config_url = (
                f"{self.API_BASE_URL}/"
                f"mapping/dataframe-configs/{id}?limit=100&offset=0"
            )

            try:
                response = requests.get(
                    config_url,
                    headers={
                        "Accept": "application/json",
                        "Authorization": f"Bearer {token}",
                    },
                    timeout=self.TIMEOUT,
                )
                response.raise_for_status()
            except HTTPError as http_err:
                logger.error(f"HTTP error occurred for {id}: {http_err}")
                logger.error(f"HTTP ERROR RESPONSE: {response.text}")
                continue
            except Exception as err:
                logger.error(f"Other error occurred for {id}: {err}")
                continue

            config_json = response.json()

            for item in config_json:
                to_id = item.get("toId")
                attrs = item.get("attributes", {}).get("manual_ddl", {})

                if to_id == "wrangle":
                    if "snowflake" in attrs:
                        dwtype = "SF"
                        break
                    elif "bigquery" in attrs:
                        dwtype = "BQ"
                        break
                elif to_id == "sfDDL":
                    dwtype = "SF"
                    break
                elif to_id == "bqDDL":
                    dwtype = "BQ"
                    break

            display_wrangle.append({"Name": name, "Status": status, "Type": dwtype})

        sorted_data = sorted(display_wrangle, key=lambda x: x["Name"])
        for entry in sorted_data:
            logger.info(
                f"Name: {entry['Name']} | Status: {entry['Status']} | Type: {entry['Type']}"
            )

    def list_wrangle(self, cdu_id, wrangle_name, output_file):
        """List specific wrangle and export SQL"""
        profile = get_current_profile()
        token = set_api_token(console, profile)
        if not token:
            raise typer.Exit(1)

        catalog_url = (
            f"{self.API_BASE_URL}/"
            f"dataframe-catalog?filter=name.eq.{wrangle_name},"
            f"type.eq.WRANGLE&outFrame-cduId.eq.{cdu_id}"
            f"&limit=100&offset=0"
        )
        try:
            response = requests.get(
                catalog_url,
                headers={
                    "Accept": "application/json",
                    "Authorization": f"Bearer {token}",
                },
                timeout=self.TIMEOUT,
            )
            response.raise_for_status()
        except HTTPError as http_err:
            logger.error(f"HTTP error occurred: {http_err}")
            logger.error(f"HTTP ERROR RESPONSE: {response.text}")
            raise
        except Exception as err:
            logger.error(f"Other error occurred: {err}")
            raise

        catalog_json = response.json()
        if not catalog_json:
            logger.info(f"Creating new wrangle {wrangle_name}")
            return

        for item in catalog_json:
            id = item["id"]
            catalog_url = (
                f"{self.API_BASE_URL}/mapping/"
                f"dataframe-configs/{id}?limit=100&offset=0"
            )
            try:
                response = requests.get(
                    catalog_url,
                    headers={
                        "Accept": "application/json",
                        "Authorization": f"Bearer {token}",
                    },
                    timeout=self.TIMEOUT,
                )
                response.raise_for_status()
            except HTTPError as http_err:
                logger.error(f"HTTP error occurred: {http_err}")
                logger.error(f"HTTP ERROR RESPONSE: {response.text}")
                raise
            except Exception as err:
                logger.error(f"Other error occurred: {err}")
                raise

        config_json = response.json()

        for item in config_json:
            to_id = item.get("toId")
            manual_ddl = item.get("attributes", {}).get("manual_ddl", {})

            if to_id == "wrangle":
                if "snowflake" in manual_ddl:
                    sql_query = manual_ddl["snowflake"].get("ddl", {})
                    if sql_query:
                        pprint.pprint(sql_query)
                        try:
                            output_file.write_text(sql_query)
                            typer.echo(f"SQL written to: {output_file}")
                        except Exception as e:
                            logger.error(f"Failed to write SQL: {e}")
                            raise typer.Exit(code=1)
                        break
                elif "bigquery" in manual_ddl:
                    sql_query = manual_ddl["bigquery"].get("ddl", {})
                    if sql_query:
                        pprint.pprint(sql_query)
                        try:
                            output_file.write_text(sql_query)
                            typer.echo(f"SQL written to: {output_file}")
                        except Exception as e:
                            logger.error(f"Failed to write SQL: {e}")
                            raise typer.Exit(code=1)
                        break
            elif to_id == "sfDDL":
                sql_query = (
                    item.get("attributes", {})
                    .get("manual_ddl", {})
                    .get("snowflake", {})
                    .get("ddl", {})
                )
                if sql_query:
                    pprint.pprint(sql_query)
                    try:
                        output_file.write_text(sql_query)
                        typer.echo(f"SQL written to: {output_file}")
                    except Exception as e:
                        logger.error(f"Failed to write SQL: {e}")
                        raise typer.Exit(code=1)
                    break
            elif to_id == "bqDDL":
                sql_query = item.get("attributes", {}).get("bqDDL", {})
                if sql_query:
                    pprint.pprint(sql_query)
                    try:
                        output_file.write_text(sql_query)
                        typer.echo(f"SQL written to: {output_file}")
                    except Exception as e:
                        logger.error(f"Failed to write SQL: {e}")
                        raise typer.Exit(code=1)
                    break

    @staticmethod
    def extract_source_tables_from_sql(sql: str) -> Set[str]:
        """
        Extract source table references from SQL query with enhanced pattern matching.

        Returns set of fully-qualified table names (project.dataset.table)
        """
        source_tables = set()

        # Comprehensive patterns to match fully qualified table names
        patterns = [
            # Basic FROM and JOIN patterns
            (r"FROM\s+`?([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\." r"([a-zA-Z0-9_-]+)`?"),
            (r"JOIN\s+`?([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\." r"([a-zA-Z0-9_-]+)`?"),
            # Explicit JOIN types
            (
                r"(?:LEFT|RIGHT|INNER|OUTER|CROSS|FULL)\s+(?:OUTER\s+)?JOIN\s+`?"
                r"([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)`?"
            ),
            # UNION patterns
            (
                r"UNION\s+ALL\s+SELECT\s+.*?\s+FROM\s+`?"
                r"([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)`?"
            ),
            # WITH clause (CTE)
            (
                r"WITH\s+\w+\s+AS\s*\([^)]*FROM\s+`?"
                r"([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)`?"
            ),
            # Subqueries
            (
                r"\(\s*SELECT\s+.*?\s+FROM\s+`?"
                r"([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)`?"
            ),
            # INSERT, UPDATE, DELETE, MERGE
            (
                r"(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM|MERGE\s+INTO)\s+`?"
                r"([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)`?"
            ),
        ]

        for pattern in patterns:
            matches = re.finditer(
                pattern, sql, re.IGNORECASE | re.MULTILINE | re.DOTALL
            )
            for match in matches:
                if len(match.groups()) == 3:
                    table_ref = f"{match.group(1)}.{match.group(2)}.{match.group(3)}"
                    if not any(
                        sys_table in table_ref.upper()
                        for sys_table in [
                            "INFORMATION_SCHEMA",
                            "__TABLES__",
                            "__PARTITIONS__",
                        ]
                    ):
                        source_tables.add(table_ref)
                        logger.debug(f"Found source table: {table_ref}")

        if source_tables:
            logger.info(f"Extracted {len(source_tables)} source tables from SQL")
        else:
            logger.warning(
                "No source tables found in SQL - this may cause authorization issues"
            )

        return source_tables

    @staticmethod
    def authorize_bq_view(
        client: bigquery.Client, view_id: str, sql: str, raise_on_error: bool = True
    ) -> List[str]:
        """
        Authorize a BigQuery view to access source tables referenced in its SQL.

        This is CRITICAL for views to work - without authorization, users will get
        "Access Denied" errors when querying the view.

        Args:
            client: BigQuery client
            view_id: Full view ID (project.dataset.view_name)
            sql: SQL query of the view
            raise_on_error: If True, raise AuthorizationError on failure

        Returns:
            List of errors (empty if successful)

        Raises:
            AuthorizationError: If raise_on_error=True and authorization fails
        """
        errors = []

        try:
            logger.info(f"Authorizing view: {view_id}")

            # Parse view components
            view_parts = view_id.replace("`", "").split(".")
            if len(view_parts) != 3:
                error_msg = f"Invalid view_id format: {view_id}"
                logger.error(error_msg)
                errors.append(error_msg)
                if raise_on_error:
                    raise AuthorizationError(error_msg)
                return errors

            view_project = view_parts[0]
            view_dataset = view_parts[1]
            view_name = view_parts[2]

            # Extract source tables from SQL
            source_tables = Wrangles.extract_source_tables_from_sql(sql)

            if not source_tables:
                logger.warning(
                    f"No source tables found in SQL for view {view_name}. "
                    "This may cause authorization issues if the view references tables."
                )
                # Don't treat this as an error, but log prominently
                return errors

            # Get unique source datasets
            source_datasets = set()
            for table_ref in source_tables:
                parts = table_ref.split(".")
                if len(parts) == 3:
                    dataset_id = f"{parts[0]}.{parts[1]}"
                    # Don't need to authorize same dataset
                    if dataset_id != f"{view_project}.{view_dataset}":
                        source_datasets.add(dataset_id)

            if not source_datasets:
                logger.info(f"No external datasets to authorize for view {view_name}")
                return errors

            logger.info(
                f"Authorizing view {view_name} for {len(source_datasets)} datasets"
            )

            # Authorize view for each source dataset
            authorization_count = 0
            for dataset_id in source_datasets:
                try:
                    logger.debug(f"  Processing dataset: {dataset_id}")

                    # Get the dataset with retry
                    dataset = None
                    for attempt in range(1, Wrangles.MAX_RETRIES + 1):
                        try:
                            dataset = client.get_dataset(dataset_id, timeout=10)
                            break
                        except NotFound:
                            error_msg = f"Dataset not found: {dataset_id}"
                            logger.error(f"{error_msg}")
                            errors.append(error_msg)
                            break
                        except Timeout:
                            if attempt < Wrangles.MAX_RETRIES:
                                logger.warning("Timeout getting dataset, retrying...")
                                time.sleep(Wrangles.RETRY_DELAY)
                            else:
                                raise

                    if not dataset:
                        continue

                    # Check if view is already authorized
                    access_entries = list(dataset.access_entries)
                    already_authorized = False

                    for entry in access_entries:
                        if (
                            entry.entity_type == "view"
                            and entry.entity_id
                            and entry.entity_id.get("projectId") == view_project
                            and entry.entity_id.get("datasetId") == view_dataset
                            and entry.entity_id.get("tableId") == view_name
                        ):
                            already_authorized = True
                            break

                    if not already_authorized:
                        # Add view to access entries
                        entity_id = {
                            "projectId": view_project,
                            "datasetId": view_dataset,
                            "tableId": view_name,
                        }
                        access_entries.append(
                            bigquery.AccessEntry(None, "view", entity_id)
                        )
                        dataset.access_entries = access_entries

                        # Update dataset with retry
                        for attempt in range(1, Wrangles.MAX_RETRIES + 1):
                            try:
                                client.update_dataset(
                                    dataset, ["access_entries"], timeout=30
                                )
                                authorization_count += 1
                                logger.info(
                                    f"Authorized view {view_name} to access {dataset_id}"
                                )
                                break
                            except Forbidden as e:
                                error_msg = (
                                    f"Permission denied authorizing view "
                                    f"for dataset {dataset_id}: {e}"
                                )
                                logger.error(f"{error_msg}")
                                errors.append(error_msg)
                                break
                            except Timeout:
                                if attempt < Wrangles.MAX_RETRIES:
                                    logger.warning(
                                        "Timeout updating dataset, retrying..."
                                    )
                                    time.sleep(Wrangles.RETRY_DELAY)
                                else:
                                    raise
                    else:
                        logger.debug(
                            f"View {view_name} already authorized for {dataset_id}"
                        )

                except Exception as e:
                    error_msg = (
                        f"Failed to authorize view for dataset {dataset_id}: {e}"
                    )
                    errors.append(error_msg)
                    logger.error(f"{error_msg}")

            if authorization_count > 0:
                logger.info(f"Successfully authorized {authorization_count} datasets")

        except Exception as e:
            error_msg = f"Error in authorize_bq_view: {e}"
            errors.append(error_msg)
            logger.error(f"{error_msg}")

        # If there were errors and raise_on_error is True, raise exception
        if errors and raise_on_error:
            error_summary = "; ".join(errors)
            raise AuthorizationError(
                f"Failed to authorize view {view_id}: {error_summary}"
            )

        return errors

    @staticmethod
    def authorize_views(
        regional_client: bigquery.Client,
        dbobj: dict,
        views: Iterable,
        catalog: Dict[str, dict],
        dataframe_config: Dict[str, Dict[str, Union[str, list]]],
    ) -> List[str]:
        """
        Authorize multiple BigQuery views to access source datasets.

        Args:
            regional_client: BigQuery client for the region
            dbobj: Database object with client_project, client_dataset, source_project
            views: Iterable of view names to authorize
            catalog: Catalog dictionary with frame information
            dataframe_config: Dataframe configuration dictionary

        Returns:
            List of error messages (empty if successful)
        """
        logger.info("Authorizing multiple views to source datasets")
        errors = []

        auth_datasets = {}
        for item in views:
            if "__" not in item:
                table_dataset = "crux_platform"
                table_project = dbobj["source_project"]
                dataset_id = ".".join([table_project, table_dataset])
                if dataset_id not in auth_datasets:
                    auth_datasets[dataset_id] = []
                auth_datasets[dataset_id].append(item)
                continue
            cdu_code = item.split("__")[0]
            frame_info = (
                catalog.get(cdu_code, {}).get("bq_views", {}).get(item, {}).get("frame")
            )
            if frame_info:
                table_dataset = frame_info["bq_dataset"]
                table_project = frame_info.get("bq_project", dbobj["source_project"])
                dataset_id = ".".join([table_project, table_dataset])
                if dataset_id not in auth_datasets:
                    auth_datasets[dataset_id] = []
                auth_datasets[dataset_id].append(item)
                continue
            frame_info = (
                catalog.get(cdu_code, {})
                .get("bq_protects", {})
                .get(item, {})
                .get("frame")
            )
            if not frame_info:
                errors.append(f"View {item} not found in catalog")
                continue
            source_tables = (
                dataframe_config.get(frame_info["dataframe_id"], {})
                .get("bqProtectDDL", {})
                .get("source_tables")
            )
            if not source_tables:
                errors.append(f"Protect view {item} source tables not found")
                continue
            view_id = ".".join([dbobj["client_project"], dbobj["client_dataset"], item])
            # Use raise_on_error=False here since we're collecting errors
            view_errors = Wrangles.authorize_bq_view(
                regional_client, view_id, "", raise_on_error=False
            )
            errors.extend(view_errors)

        for dataset_id, new_views in auth_datasets.items():
            try:
                dataset = regional_client.get_dataset(dataset_id, timeout=10)
            except Exception as e:
                status = f"Get dataset {dataset_id} failed: {e}"
                logger.error(f"{status}")
                errors.append(status)
                continue

            access_entries = dataset.access_entries
            auth_views = [
                x.entity_id["tableId"]
                for x in access_entries
                if x.entity_type == "view"
                and x.entity_id.get("projectId") == dbobj["client_project"]
                and x.entity_id.get("datasetId") == dbobj["client_dataset"]
            ]
            added = 0
            for item in new_views:
                if item in auth_views:
                    continue
                entity_id = {
                    "projectId": dbobj["client_project"],
                    "datasetId": dbobj["client_dataset"],
                    "tableId": item,
                }
                access_entries.append(bigquery.AccessEntry(None, "view", entity_id))
                added += 1
            dataset.access_entries = access_entries
            try:
                regional_client.update_dataset(dataset, ["access_entries"], timeout=30)
            except Exception as e:
                status = f"Auth views to {dataset_id} failed: {e}"
                logger.error(f"{status}")
                errors.append(status)
            else:
                logger.info(
                    f"Dataset: {dataset_id:<40} Views: {len(new_views)} Added: {added}"
                )

        return errors

    @staticmethod
    def backup_bigquery_view_directly(bq_view_id, wrangle_name):
        """
        Backup BigQuery view definition directly from BigQuery.
        Used when view exists in BQ but not in Crux catalog (DIRECT_BQ_UPDATE case).

        Returns: Path to backup file or None if failed
        """
        try:
            logger.debug("Backing up BQ view directly...")
            logger.debug(f"BQ View ID: {bq_view_id}")

            # Create backups directory
            backup_dir = Path.cwd() / "wrangle_backups"
            backup_dir.mkdir(parents=True, exist_ok=True)

            # Get BigQuery client and fetch view definition
            client = Wrangles.get_bigquery_client()

            # Get the view
            view = client.get_table(bq_view_id)

            # Get the view definition (SQL)
            view_definition = (
                view.mview_query
                if hasattr(view, "mview_query") and view.mview_query
                else None
            )
            if not view_definition:
                view_definition = (
                    view.view_query
                    if hasattr(view, "view_query") and view.view_query
                    else None
                )

            if not view_definition:
                logger.warning("No view definition found")
                return None

            # Extract schema info
            schema_info = []
            if view.schema:
                for pos, field in enumerate(view.schema, 1):
                    schema_info.append(
                        {
                            "pos": pos,
                            "field_name": field.name,
                            "field_type": field.field_type,
                        }
                    )

            # Create backup filename
            clean_view_id = (
                bq_view_id.replace(".", "_").replace("`", "").replace(":", "_")
            )
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            backup_filename = f"{clean_view_id}_backup_{timestamp}.sql"
            backup_filepath = backup_dir / backup_filename

            # Prepare backup content
            backup_content = f"""-- ================================================
-- BIGQUERY VIEW BACKUP
-- ================================================
-- View ID: {bq_view_id}
-- Wrangle Name: {wrangle_name}
-- Data Warehouse Type: BQ
-- Backup Timestamp: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
-- ================================================
-- EXISTING VIEW DEFINITION
-- ================================================

{view_definition}

-- ================================================
-- SCHEMA INFORMATION
-- ================================================
"""

            # Add schema
            if schema_info:
                backup_content += "-- Column Details:\n"
                for col in schema_info:
                    backup_content += (
                        f"-- Position: {col.get('pos')}, "
                        f"Name: {col.get('field_name')}, "
                        f"Type: {col.get('field_type')}\n"
                    )

            backup_content += "\n-- ================================================\n"

            # Write backup
            with open(backup_filepath, "w", encoding="utf-8") as f:
                f.write(backup_content)

            full_path = str(backup_filepath.resolve())
            logger.info(f"Backup created: {full_path}")
            return full_path

        except Exception as err:
            logger.error(f"Error during BQ backup: {err}")
            return None

    @staticmethod
    def backup_wrangle_from_catalog(cdu_id, wrangle_name, dw_type, catalog_json, token):
        """
        Backup existing wrangle view definition from catalog data.
        Extracts the current DDL and creates a timestamped backup file in ./wrangle_backups/

        Returns: Path to backup file or None if failed
        """
        try:
            logger.info(f"Creating backup for wrangle: {wrangle_name}")

            # Create backups directory
            backup_dir = Path.cwd() / "wrangle_backups"
            backup_dir.mkdir(parents=True, exist_ok=True)

            # Get dataframe ID
            dataframe_id = catalog_json[0].get("id")

            if not dataframe_id or dataframe_id == "DIRECT_BQ_UPDATE":
                logger.warning("Invalid dataframe_id for catalog backup")
                return None

            # Fetch config to get DDL
            config_url = (
                f"https://api.cruxinformatics.com/v2/ops/mapping/"
                f"dataframe-configs/{dataframe_id}?limit=100&offset=0"
            )

            response = requests.get(
                config_url,
                headers={
                    "Accept": "application/json",
                    "Authorization": f"Bearer {token}",
                },
                timeout=Wrangles.TIMEOUT,
            )
            response.raise_for_status()
            config_json = response.json()

            existing_ddl = None
            existing_definition = {}

            for config in config_json:
                if config.get("toId") == "wrangle":
                    attributes = config.get("attributes", {})
                    if dw_type == "SF":
                        existing_ddl = (
                            attributes.get("manual_ddl", {})
                            .get("snowflake", {})
                            .get("ddl")
                        )
                        existing_definition = attributes.get("manual_ddl", {}).get(
                            "snowflake", {}
                        )
                        if existing_ddl:
                            break
                    elif dw_type == "BQ":
                        existing_ddl = (
                            attributes.get("manual_ddl", {})
                            .get("bigquery", {})
                            .get("ddl")
                        )
                        existing_definition = attributes.get("manual_ddl", {}).get(
                            "bigquery", {}
                        )
                        if existing_ddl:
                            break
                elif config.get("toId") == "bqDDL" and dw_type == "BQ":
                    attributes = config.get("attributes", {})
                    existing_ddl = attributes.get("bqDDL")
                    if existing_ddl:
                        existing_definition = {"ddl": existing_ddl}
                        break

            if not existing_ddl:
                logger.warning("No DDL found in catalog")
                return None

            # Generate backup file
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            backup_filename = f"{wrangle_name}_backup_{timestamp}.sql"
            backup_filepath = backup_dir / backup_filename

            # Prepare backup content
            backup_content = f"""-- ================================================
-- WRANGLE BACKUP
-- ================================================
-- Wrangle Name: {wrangle_name}
-- CDU ID: {cdu_id}
-- Data Warehouse Type: {dw_type}
-- Backup Timestamp: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
-- ================================================
-- EXISTING VIEW DEFINITION
-- ================================================

{existing_ddl}

-- ================================================
-- SCHEMA INFORMATION
-- ================================================
"""

            # Add schema
            schema_info = existing_definition.get("schema", [])
            if schema_info:
                backup_content += "-- Column Details:\n"
                for col in schema_info:
                    backup_content += (
                        f"-- Position: {col.get('pos')}, "
                        f"Name: {col.get('field_name')}, "
                        f"Type: {col.get('field_type')}\n"
                    )

            # Add source tables
            source_tables = existing_definition.get("source_tables", [])
            if source_tables:
                backup_content += "\n-- Source Tables:\n"
                for table in source_tables:
                    backup_content += f"-- {table}\n"

            backup_content += "\n-- ================================================\n"

            # Write backup
            with open(backup_filepath, "w", encoding="utf-8") as f:
                f.write(backup_content)

            full_path = str(backup_filepath.resolve())
            logger.info(f"Backup created: {full_path}")
            return full_path

        except Exception as err:
            logger.error(f"Error during backup: {err}")
            return None

    @staticmethod
    def publish_bq_view_uat(
        client, cdu_id, wrangle_name, sql, legacy_id, promote=False
    ):
        """
        Publish BigQuery view to UAT or PROD with CRITICAL authorization.

        Creates dataset and view, then AUTHORIZES it to access source tables.
        Authorization is MANDATORY - without it, users will get Access Denied errors.

        Args:
            client: BigQuery client
            cdu_id: CDU identifier
            wrangle_name: Name of the wrangle
            sql: SQL query
            legacy_id: Legacy ID for view naming
            promote: If True, create in PROD; if False, create in UAT

        Returns:
            Tuple: (view_id, final_sql, errors)

        Raises:
            AuthorizationError: If authorization fails
        """
        errors = []
        final_sql = sql

        # Dataset naming - follows legacy pattern
        dataset_name_uat = f"wrangle_{cdu_id.lower()}_uat"
        dataset_name_prod = f"wrangle_{cdu_id.lower()}"
        view_name = f"{wrangle_name.upper()}__{legacy_id}"

        # Determine which dataset to use
        if promote:
            dataset_name = dataset_name_prod
            status = "PROD"
        else:
            dataset_name = dataset_name_uat
            status = "UAT"

        logger.info(f"Publishing view to {status}: {view_name}")

        # Create dataset if it doesn't exist
        project = "crux-data-prd"
        dataset_id = f"{project}.{dataset_name}"
        dataset = bigquery.Dataset(dataset_id)
        dataset.location = "US"
        dataset.labels = {
            "status": status.lower(),
            "wrangle": wrangle_name.lower().replace("_", "-"),
        }

        try:
            dataset = client.create_dataset(dataset, timeout=30)
            logger.debug(f"Created dataset: {dataset_id}")
        except Conflict:
            logger.debug(f"Dataset already exists: {dataset_id}")
        except Exception as e:
            error_msg = f"Failed to create dataset {dataset_id}: {e}"
            errors.append(error_msg)
            logger.error(f"{error_msg}")
            return None, final_sql, errors

        # Create or replace view
        bq_view_id = f"{project}.{dataset_name}.{view_name}"
        view_ddl = f"CREATE OR REPLACE VIEW `{bq_view_id}` AS {sql}"

        try:
            query_job = client.query(view_ddl)
            query_job.result()
            logger.info(f"Created view in {status}: {bq_view_id}")
        except Exception as e1:
            # Try with crux-data-prd substitution (data-lake -> data-prd)
            logger.warning(
                "View creation failed, trying with crux-data-prd substitution..."
            )
            try:
                sql_fixed = sql.replace("crux-data-lake.", "crux-data-prd.")
                view_ddl = f"CREATE OR REPLACE VIEW `{bq_view_id}` AS {sql_fixed}"
                query_job = client.query(view_ddl)
                query_job.result()
                final_sql = sql_fixed
                logger.info(
                    f"Created view in {status} (with crux-data-prd substitution): {bq_view_id}"
                )
            except Exception as e2:
                error_msg = (
                    f"Failed to create view {bq_view_id}: {e1}; "
                    f"Also failed with substitution: {e2}"
                )
                errors.append(error_msg)
                logger.error(f"{error_msg}")
                return None, final_sql, errors

        # ============================================================================
        # CRITICAL: Authorize the view to access source datasets
        # This MUST succeed or users will get "Access Denied" errors
        # ============================================================================
        logger.info(
            f"CRITICAL: Authorizing view {view_name} to access source datasets..."
        )
        try:
            auth_errors = Wrangles.authorize_bq_view(
                client, bq_view_id, final_sql, raise_on_error=True
            )
            if auth_errors:
                # This should not happen since raise_on_error=True
                error_msg = f"Authorization completed with warnings: {auth_errors}"
                logger.warning(f"{error_msg}")
        except AuthorizationError as e:
            error_msg = f"CRITICAL: Authorization failed for view {bq_view_id}: {e}"
            errors.append(error_msg)
            logger.error(f"{error_msg}")
            logger.error(
                "Users will get 'Access Denied' errors when querying this view!"
            )
            # Delete the view since it's not properly authorized
            try:
                client.delete_table(bq_view_id)
                logger.info(f"Deleted improperly authorized view: {bq_view_id}")
            except Exception as del_err:
                logger.error(f"Failed to delete view: {del_err}")
            return None, final_sql, errors

        logger.info(f"View successfully published and authorized in {status}")
        return bq_view_id, final_sql, errors

    @staticmethod
    def promote_bq_wrangle_to_prod(client, cdu_id, wrangle_name, legacy_id):
        """
        Promote wrangle from UAT to PROD dataset with CRITICAL authorization.

        Similar to Snowflake promote flow. This:
        1. Copies view definition from UAT to PROD
        2. AUTHORIZES the PROD view (CRITICAL)
        3. Optionally deletes UAT view or keeps for rollback
        4. Updates status metadata

        Args:
            client: BigQuery client
            cdu_id: CDU identifier
            wrangle_name: Wrangle name
            legacy_id: Legacy ID for view naming

        Returns:
            Tuple: (prod_view_id, errors)

        Raises:
            AuthorizationError: If authorization fails
        """
        errors = []
        view_name = f"{wrangle_name.upper()}__{legacy_id}"
        dataset_name_uat = f"wrangle_{cdu_id.lower()}_uat"
        dataset_name_prod = f"wrangle_{cdu_id.lower()}"

        bq_view_uat = f"crux-data-prd.{dataset_name_uat}.{view_name}"
        bq_view_prod = f"crux-data-prd.{dataset_name_prod}.{view_name}"

        logger.info(f"Promoting wrangle from UAT to PROD: {view_name}")

        try:
            # Get the UAT view definition
            uat_view = client.get_table(bq_view_uat)
            view_query = uat_view.view_query

            # Create PROD dataset if needed
            prod_dataset = bigquery.Dataset(f"crux-data-prd.{dataset_name_prod}")
            prod_dataset.location = "US"
            try:
                client.create_dataset(prod_dataset, timeout=30)
                logger.debug(f"Created PROD dataset: {dataset_name_prod}")
            except Conflict:
                logger.debug(f"PROD dataset already exists: {dataset_name_prod}")

            # Create view in PROD
            prod_view_ddl = f"CREATE OR REPLACE VIEW `{bq_view_prod}` AS {view_query}"
            query_job = client.query(prod_view_ddl)
            query_job.result()

            logger.info(f"Promoted {view_name} to PROD: {bq_view_prod}")

            # ============================================================================
            # CRITICAL: Authorize the PROD view
            # ============================================================================
            logger.info("CRITICAL: Authorizing PROD view to access source datasets...")
            try:
                auth_errors = Wrangles.authorize_bq_view(
                    client, bq_view_prod, view_query, raise_on_error=True
                )
                if auth_errors:
                    logger.warning(
                        f"Authorization warnings during promotion: {auth_errors}"
                    )
            except AuthorizationError as e:
                error_msg = f"CRITICAL: Failed to authorize PROD view: {e}"
                errors.append(error_msg)
                logger.error(f"{error_msg}")
                logger.error("PROD view exists but is NOT properly authorized!")
                logger.error("Users will get 'Access Denied' errors!")
                return None, errors

            # Delete UAT view (optional - keep for rollback if needed)
            try:
                client.delete_table(bq_view_uat, timeout=30)
                logger.info(f"Deleted UAT view: {bq_view_uat}")
            except Exception as e:
                logger.warning(f"Could not delete UAT view (keeping for rollback): {e}")

            logger.info("Promotion completed successfully with proper authorization")
            return bq_view_prod, errors

        except Exception as e:
            error_msg = f"Failed to promote wrangle to PROD: {e}"
            errors.append(error_msg)
            logger.error(f"{error_msg}")
            return None, errors

    def modify_wrangles(self, cdu_id, dw_type, sql, wrangle_name, legacy_id):
        """Create or update wrangles with support for both Snowflake and BigQuery.

        Always creates/updates in PROD environment (not UAT).

        Args:
            cdu_id: The CDU identifier
            dw_type: Data warehouse type (SF/SNOWFLAKE or BQ/BIGQUERY)
            sql: SQL query/file path
            wrangle_name: Name of the wrangle
            legacy_id: Legacy ID (MANDATORY) - used in view naming pattern
        """
        # Validate inputs
        try:
            self.validate_inputs(
                cdu_id=cdu_id,
                dw_type=dw_type,
                sql=sql,
                wrangle_name=wrangle_name,
                legacy_id=legacy_id,
            )
        except ValidationError as e:
            logger.error(f"Validation error: {e}")
            raise typer.Exit(1)

        # Normalize dw_type
        dw_type = dw_type.upper()
        if dw_type in ["SF", "SNOWFLAKE"]:
            dw_type_normalized = "SF"
        elif dw_type in ["BQ", "BIGQUERY"]:
            dw_type_normalized = "BQ"
        else:
            logger.error(f"Unsupported data warehouse type: {dw_type}")
            return

        profile = get_current_profile()
        token = set_api_token(console, profile)
        if not token:
            raise typer.Exit(1)

        catalog_json = []
        found_bq_view_id = None

        # Strategy 1: Exact name match
        catalog_url = (
            f"{self.API_BASE_URL}/"
            f"dataframe-catalog?filter=name.eq.{wrangle_name},"
            f"type.eq.WRANGLE,outFrame-cduId.eq.{cdu_id}"
            f"&limit=100&offset=0"
        )

        try:
            response = requests.get(
                catalog_url,
                headers={
                    "Accept": "application/json",
                    "Authorization": f"Bearer {token}",
                },
                timeout=self.TIMEOUT,
            )
            response.raise_for_status()
            catalog_json = response.json()
        except Exception:
            pass

        # Strategy 2: Case-insensitive search
        if not catalog_json:
            catalog_url = (
                f"{self.API_BASE_URL}/"
                f"dataframe-catalog?filter=name.ilike.%{wrangle_name.lower()}%,"
                f"type.eq.WRANGLE,outFrame-cduId.eq.{cdu_id}"
                f"&limit=100&offset=0"
            )
            try:
                response = requests.get(
                    catalog_url,
                    headers={
                        "Accept": "application/json",
                        "Authorization": f"Bearer {token}",
                    },
                    timeout=self.TIMEOUT,
                )
                response.raise_for_status()
                catalog_json = response.json()
            except Exception:
                pass

        # Strategy 3: Check BigQuery directly for BQ wrangle
        if not catalog_json and dw_type_normalized == "BQ":
            try:
                client = self.get_bigquery_client()
                dataset_name_prod = f"wrangle_{cdu_id.lower()}"
                dataset_ref = client.dataset(dataset_name_prod, project="crux-data-prd")
                tables = client.list_tables(dataset_ref)
                for table in tables:
                    if wrangle_name.upper() in table.table_id.upper():
                        found_bq_view_id = (
                            f"crux-data-prd.{dataset_name_prod}.{table.table_id}"
                        )
                        break

                if found_bq_view_id:
                    all_wrangles_url = (
                        f"{self.API_BASE_URL}/"
                        f"dataframe-catalog?filter=type.eq.WRANGLE,"
                        f"outFrame-cduId.eq.{cdu_id}"
                        f"&limit=1000&offset=0"
                    )
                    try:
                        response = requests.get(
                            all_wrangles_url,
                            headers={
                                "Accept": "application/json",
                                "Authorization": f"Bearer {token}",
                            },
                            timeout=self.TIMEOUT,
                        )
                        response.raise_for_status()
                        all_wrangles = response.json()

                        for wrangle in all_wrangles:
                            wrangle_name_lower = wrangle.get("name", "").lower()
                            if (
                                wrangle_name.lower() in wrangle_name_lower
                                or wrangle_name_lower.replace("_", "").replace("-", "")
                                == wrangle_name.lower()
                                .replace("_", "")
                                .replace("-", "")
                            ):
                                catalog_json = [wrangle]
                                break

                        if not catalog_json:
                            catalog_json = [
                                {
                                    "id": "DIRECT_BQ_UPDATE",
                                    "name": wrangle_name,
                                    "_bigquery_view_id": found_bq_view_id,
                                }
                            ]
                    except Exception:
                        pass
            except Exception:
                pass

        if catalog_json:
            logger.info(f"Updating existing wrangle: {wrangle_name}")
            # Backup existing wrangle definition before updating
            logger.info("Creating backup of current definition...")

            # Check if this is a DIRECT_BQ_UPDATE (view exists in BQ but not in Crux catalog)
            if catalog_json[0].get("id") == "DIRECT_BQ_UPDATE":
                # Backup directly from BigQuery
                backup_path = Wrangles.backup_bigquery_view_directly(
                    found_bq_view_id, wrangle_name
                )
            else:
                # Backup from Crux catalog
                backup_path = Wrangles.backup_wrangle_from_catalog(
                    cdu_id, wrangle_name, dw_type_normalized, catalog_json, token
                )

            if backup_path:
                logger.info(f"Backup created at: {backup_path}")
            else:
                logger.warning(
                    "Could not create backup, proceeding with modification..."
                )

            if catalog_json[0].get("id") == "DIRECT_BQ_UPDATE":
                self.update_bq_view_directly(
                    found_bq_view_id, sql, token, cdu_id, wrangle_name
                )
            else:
                Wrangles.update_wrangle(
                    cdu_id,
                    catalog_json,
                    sql,
                    token,
                    dw_type_normalized,
                    wrangle_name,
                    legacy_id,
                )
        else:
            logger.info(f"Creating new wrangle: {wrangle_name}")
            Wrangles.create_wrangle(
                cdu_id, sql, token, dw_type_normalized, wrangle_name, legacy_id
            )

    @staticmethod
    def check_dataframe_config_exists(dataframe_id, token):
        """Check if dataframe config already exists"""
        config_url = (
            f"https://api.cruxinformatics.com/v2/ops/mapping/"
            f"dataframe-configs/{dataframe_id}?limit=100&offset=0"
        )
        try:
            response = requests.get(
                config_url,
                headers={"Authorization": f"Bearer {token}"},
                timeout=Wrangles.TIMEOUT,
            )
            if response.status_code == 200:
                configs = response.json()
                return len(configs) > 0
            elif response.status_code == 404:
                return False
        except Exception:
            pass
        return False

    @staticmethod
    def schema_wrangles(cdu_id, wrangle_name):
        """Print wrangle schema for an existing query"""
        profile = get_current_profile()
        token = set_api_token(console, profile)
        if not token:
            raise typer.Exit(1)

        dataframe_url = (
            f"https://api.cruxinformatics.com/v2/ops/dataframes/"
            f"by-cdu/{cdu_id}/type/Wrangle?limit=1000"
        )
        try:
            response = requests.get(
                dataframe_url,
                headers={
                    "Accept": "application/json",
                    "Authorization": f"Bearer {token}",
                },
                timeout=Wrangles.TIMEOUT,
            )
            response.raise_for_status()
        except HTTPError as http_err:
            logger.error(f"HTTP ERROR: {http_err}")
            raise
        except Exception as err:
            logger.error(f"ERROR: {err}")
            raise

        dataframe_json = response.json()

        dataframe_id = None
        for dataframe in dataframe_json:
            if dataframe["name"] == wrangle_name:
                dataframe_id = dataframe["id"]
                break

        if not dataframe_id:
            logger.error(f"Wrangle not found: {wrangle_name}")
            raise typer.Exit(1)

        config_url = (
            f"https://api.cruxinformatics.com/v2/ops/mapping/"
            f"dataframe-configs/{dataframe_id}?limit=100&offset=0"
        )

        try:
            response = requests.get(
                config_url,
                headers={
                    "Accept": "application/json",
                    "Authorization": f"Bearer {token}",
                },
                timeout=Wrangles.TIMEOUT,
            )
            response.raise_for_status()
        except HTTPError as http_err:
            logger.error(f"HTTP ERROR: {http_err}")
            raise
        except Exception as err:
            logger.error(f"ERROR: {err}")
            raise

        config_json = response.json()

        schema = []
        for _, item in enumerate(config_json):
            if "manual_ddl" in item["attributes"]:
                attributes = item.get("attributes", {})
                manual_ddl = attributes.get("manual_ddl", {})
                if "bigquery" in manual_ddl:
                    dw_type = manual_ddl.get("bigquery", {})
                else:
                    dw_type = manual_ddl.get("snowflake", {})
                schema = dw_type.get("schema", [])
                break

        if not schema:
            logger.warning("No schema found")
            return

        logger.info("Wrangle Schema:")
        logger.info("-" * 80)
        for field in schema:
            pos = field.get("pos")
            name = field.get("field_name")
            ftype = field.get("field_type")
            logger.info(f"  {pos}. {name}: {ftype}")
        logger.info("-" * 80)

    @staticmethod
    def update_wrangle(
        cdu_id, catalog_json, sql, token, dw_type, wrangle_name, legacy_id
    ):
        """Update existing wrangle for Snowflake or BigQuery in PROD.

        Args:
            cdu_id: The CDU identifier
            catalog_json: Catalog data from API
            sql: SQL query string
            token: API authentication token
            dw_type: Data warehouse type (SF or BQ)
            wrangle_name: Name of the wrangle
            legacy_id: User-provided legacy ID for view naming
        """
        logger.info(f"Updating wrangle: {wrangle_name}")

        # Extract dataframe_id from catalog_json
        dataframe_id = catalog_json[0].get("id")

        if not dataframe_id:
            raise ValidationError("Missing dataframe ID from catalog JSON.")

        config_id = dataframe_id
        config_url = (
            f"https://api.cruxinformatics.com/v2/ops/mapping/"
            f"dataframe-configs/{config_id}?limit=100&offset=0"
        )

        try:
            response = requests.get(
                config_url,
                headers={
                    "Accept": "application/json",
                    "Authorization": f"Bearer {token}",
                },
                timeout=Wrangles.TIMEOUT,
            )
            response.raise_for_status()
        except HTTPError as http_err:
            logger.error(f"HTTP ERROR: {http_err}")
            raise
        except Exception as err:
            logger.error(f"ERROR: {err}")
            raise

        config_json = response.json()
        frame_id = sf_wrangle_name = bq_wrangle_name = None

        for item in config_json:
            if item.get("toId") == "wrangle":
                attributes = item.get("attributes", {})

                # Try to get frame_id/domain_id from attributes
                frame_id = attributes.get("domain_id") or attributes.get("frame_id")

                # If still not found, check in nested attributes structure
                if not frame_id:
                    nested_attrs = attributes.get("attributes", {})
                    frame_id = nested_attrs.get("domain_id") or nested_attrs.get(
                        "frame_id"
                    )

                if dw_type == "SF":
                    sf_wrangle_name = (
                        attributes.get("manual_ddl", {})
                        .get("snowflake", {})
                        .get("table_id", {})
                    )
                elif dw_type == "BQ":
                    bq_wrangle_name = (
                        attributes.get("manual_ddl", {})
                        .get("bigquery", {})
                        .get("table_id", {})
                    )
                break

        # Use dataframe_id as fallback if frame_id not found
        if not frame_id:
            logger.warning(
                "Could not extract frame_id from config, using dataframe_id as fallback"
            )
            frame_id = dataframe_id

        if not all([dataframe_id, frame_id]):
            raise ValidationError("Missing necessary identifiers from config JSON.")

        if dw_type == "SF":
            if not sf_wrangle_name:
                raise ValidationError("Missing Snowflake table identifier.")
            conn = get_snowflake_connection_instance()
            cur = conn.cursor(DictCursor)

            try:
                wrangle_dict = Wrangles.profile_sf_ddl(
                    sql, dataframe_id, frame_id, cur, sf_wrangle_name, "UPD"
                )
                errors = Wrangles.put_dataframe_config(
                    dataframe_id, wrangle_dict, token
                )
                if errors:
                    logger.error(f"Failed to update dataframe: {errors}")
                    return

                Wrangles.publish_sf_view(
                    wrangle_dict, sf_wrangle_name, cdu_id, cur, dataframe_id, "UPD"
                )
                errors = Wrangles.put_dataframe_mapping(
                    dataframe_id, wrangle_dict, token, dw_type
                )
                if errors:
                    logger.error(f"Failed to map dataframe: {errors}")
                    return
            finally:
                cur.close()
                conn.close()
            logger.info("Wrangle has been updated in Snowflake (PROD)")

        elif dw_type == "BQ":
            client = Wrangles.get_bigquery_client()
            try:
                if not bq_wrangle_name or wrangle_name not in bq_wrangle_name:
                    # Try to find existing view in PROD
                    dataset_name_prod = f"wrangle_{cdu_id.lower()}"
                    dataset_ref = client.dataset(
                        dataset_name_prod, project="crux-data-prd"
                    )

                    try:
                        tables = client.list_tables(dataset_ref)
                        found_view = None

                        for table in tables:
                            if wrangle_name.upper() in table.table_id.upper():
                                found_view = f"crux-data-prd.{dataset_name_prod}.{table.table_id}"
                                bq_wrangle_name = found_view
                                break

                        if not found_view:
                            # Create new view in PROD
                            view_name = f"{wrangle_name.upper()}__{legacy_id}"
                            bq_wrangle_name = (
                                f"crux-data-prd.{dataset_name_prod}.{view_name}"
                            )
                            logger.info(
                                f"Creating new BigQuery view in PROD: {bq_wrangle_name}"
                            )
                    except Exception as e:
                        logger.error(f"Failed to search for existing view: {e}")
                        raise

                # Update in PROD (always use promote=True for PROD environment)
                logger.info("Updating wrangle in BigQuery PROD...")
                try:
                    (
                        bq_wrangle_name_prod,
                        final_sql,
                        pub_errors,
                    ) = Wrangles.publish_bq_view_uat(
                        client,
                        cdu_id,
                        wrangle_name,
                        sql,
                        legacy_id,
                        promote=True,  # Update in PROD
                    )
                except AuthorizationError as e:
                    logger.error(f"CRITICAL: Authorization failed: {e}")
                    logger.error("View was NOT updated due to authorization failure")
                    return

                if pub_errors:
                    for err in pub_errors:
                        logger.error(f"ERROR: {err}")
                    return

                if not bq_wrangle_name_prod:
                    logger.error("Failed to update view")
                    return

                # Update bqDDL config
                wrangle_bq_dict = {
                    "ofId": dataframe_id,
                    "toId": "bqDDL",
                    "attributes": {"bqDDL": final_sql},
                }

                errors = Wrangles.put_dataframe_config(
                    dataframe_id, wrangle_bq_dict, token
                )
                if errors:
                    logger.error(f"Failed to update BigQuery DDL config: {errors}")
                    return

                # Re-authorize after config update to ensure consistency
                logger.info("Re-authorizing view after config update...")
                try:
                    auth_errors = Wrangles.authorize_bq_view(
                        client, bq_wrangle_name_prod, final_sql, raise_on_error=True
                    )
                    print(auth_errors)
                except AuthorizationError as e:
                    logger.error(f"CRITICAL: Re-authorization failed: {e}")
                    logger.error("View exists but may not be properly authorized!")

                logger.info("Wrangle has been updated in BigQuery (PROD)")
                logger.info(f"  - View: {bq_wrangle_name_prod}")

            except AuthorizationError as e:
                logger.error(f"Authorization error: {e}")
                logger.error("Update aborted due to authorization failure")
                raise
            except Exception as e:
                logger.error(f"Failed to update BigQuery wrangle: {e}")
                raise

    @staticmethod
    def create_wrangle(cdu_id, sql, token, dw_type, wrangle_name, legacy_id):
        """Create new wrangle for Snowflake or BigQuery in PROD environment.

        Args:
            cdu_id: The CDU identifier
            sql: SQL query string
            token: API authentication token
            dw_type: Data warehouse type (SF or BQ)
            wrangle_name: Name of the wrangle
            legacy_id: User-provided legacy ID for view naming
        """
        logger.info(f"Creating new wrangle: {wrangle_name}")

        dsids = Wrangles.get_dataset_id(cdu_id, token)
        dslist = []
        dataframe_id = ""
        frame_id = ""

        if not dsids:
            logger.error(f"ERROR: No datasets found for CDU {cdu_id}")
            return

        for d in dsids:
            dataset_id, _ = d.split("|", 1)
            dslist.append({"datasetid": dataset_id, "legacyid": legacy_id})

        frames = Wrangles.get_frames(dslist, token, wrangle_name)

        if not frames:
            logger.error("ERROR: No frames found for dataset")
            return

        for i in frames:
            frame_id = i["frameId"]

            dataframe = {
                "name": i["name"].lower(),
                "inFrameId": i["id"],
                "outFrameId": i["id"],
                "type": "WRANGLE",
                "mapping": [
                    {
                        "operation": "ASIS",
                        "outName": "Dev_In_Progress",
                        "outType": "STRING",
                        "description": "Development In Progress",
                    }
                ],
            }

            dataframe_id, errors = Wrangles.post_dataframe(dataframe, token)

            if errors:
                logger.error(f"ERROR: Failed to create dataframe: {errors}")
                return

            errors = Wrangles.update_dataframe_status(dataframe_id, token)
            if errors:
                logger.error(f"ERROR: Failed to update dataframe status: {errors}")
                return

        if not dataframe_id:
            logger.error("ERROR: Failed to create dataframe")
            return

        if dw_type == "SF":
            conn = get_snowflake_connection_instance()
            cur = conn.cursor(DictCursor)

            try:
                full_wrangle_name = "--".join(
                    [cdu_id.upper(), wrangle_name.upper(), legacy_id]
                )
                sf_wrangle_name = f'CX_DATA_LAKE.CRUX_WRANGLE."{full_wrangle_name}"'

                wrangle_dict = Wrangles.profile_sf_ddl(
                    sql, dataframe_id, frame_id, cur, sf_wrangle_name, "NEW"
                )
                errors = Wrangles.post_dataframe_config(wrangle_dict, token)
                if errors:
                    logger.error(f"ERROR: Failed to create dataframe config: {errors}")
                    return

                wrangle_sf_dict = Wrangles.publish_sf_view(
                    wrangle_dict, sf_wrangle_name, cdu_id, cur, dataframe_id, "NEW"
                )
                errors = Wrangles.post_dataframe_config(wrangle_sf_dict, token)
                if errors:
                    logger.error(
                        f"ERROR: Failed to update dataframe after publishing view: {errors}"
                    )
                    return
                drop_ddl = 'DROP VIEW CX_DATA_LAKE.CRUX_WRANGLE_UAT."CXTEST--PROFILE_VIEW--profile"'
                try:
                    cur.execute(drop_ddl)
                except snowflake.connector.errors.ProgrammingError as e:
                    logger.warning(f"Drop view {drop_ddl} failed: [{e.errno}]")
            finally:
                cur.close()
                conn.close()
                logger.info("Wrangle has been created in Snowflake (PROD)")

        elif dw_type == "BQ":
            client = Wrangles.get_bigquery_client()

            try:
                # Create view directly in PROD (not UAT)
                logger.info("Publishing wrangle to BigQuery PROD...")
                try:
                    (
                        bq_wrangle_name_prod,
                        final_sql,
                        pub_errors,
                    ) = Wrangles.publish_bq_view_uat(
                        client,
                        cdu_id,
                        wrangle_name,
                        sql,
                        legacy_id,
                        promote=True,  # Create in PROD directly
                    )
                except AuthorizationError as e:
                    logger.error(f"ERROR: CRITICAL: Authorization failed: {e}")
                    logger.error(
                        "ERROR: View was NOT created due to authorization failure"
                    )
                    return

                if pub_errors:
                    for err in pub_errors:
                        logger.error(f"ERROR: {err}")
                    return

                if not bq_wrangle_name_prod:
                    logger.error("ERROR: Failed to create wrangle view in PROD")
                    return

                logger.info(f"Wrangle published to PROD: {bq_wrangle_name_prod}")

                # Create bqDDL config
                wrangle_bq_dict = {
                    "ofId": dataframe_id,
                    "toId": "bqDDL",
                    "attributes": {"bqDDL": final_sql},
                }

                errors = Wrangles.post_dataframe_config(wrangle_bq_dict, token)
                if errors:
                    logger.error(
                        f"ERROR: Failed to create wrangle bqDDL config: {errors}"
                    )
                    return

                logger.info("Wrangle has been created in BigQuery (PROD)")
                logger.info(f"  - View: {bq_wrangle_name_prod}")
                logger.info("  - Status: PROD")
                logger.info("  - Authorization: Complete")

            except AuthorizationError as e:
                logger.error(f"ERROR: Authorization error: {e}")
                logger.error("ERROR: Creation aborted due to authorization failure")
                raise
            except Exception as e:
                logger.error(f"ERROR: Failed to create BigQuery wrangle: {e}")
                raise

    @staticmethod
    def profile_sf_ddl(sql, dataframe_id, frame_id, cur, full_wrangle_name, upd_mode):
        """Profile and validate Snowflake DDL"""
        profile = {
            "obj_type": "SQLprofile",
            "platform": "snowflake",
            "source_tables": [],
            "errors": [],
            "schema": [],
            "ddl": sql,
        }
        errors = profile["errors"]
        test_dataframe: Dict[str, Any] = {
            "dataframe_id": "test",
            "dataframe_type": "TEST",
            "name": "profile_view",
            "in_frame_id": None,
            "frame_id": None,
            "version": 1,
            "status": "GENERATED",
            "org_code": "TEST",
            "external_id": "CXOTEST",
            "cdu_id": "CxTEST",
            "cdu_code": "cxTEST",
            "dataset_id": "test",
            "dataset_legacy_id": "profile",
            "schema": [],
            "manual_ddl": {"snowflake": {"status": "TEST", "ddl": sql}},
        }
        Wrangles.add_table_view_names(test_dataframe)
        errs, _ = Wrangles.publish_uat_view(test_dataframe, cur)
        errors += errs
        if errors:
            return profile
        table_id = test_dataframe["manual_ddl"]["snowflake"]["table_id"]
        parts = table_id.split(".")
        table_database = parts[0]
        table_schema = parts[1]
        table_name = parts[2].strip('"')
        query_ddl = (
            "SELECT ordinal_position as pos, column_name as field_name, "
            "replace(replace(replace(data_type, 'TIMESTAMP_NTZ', 'DATETIME'), "
            "'TEXT', 'STRING'),'NUMBER', 'NUMERIC') as field_type "
            f"from {table_database}.INFORMATION_SCHEMA.COLUMNS "
            f"where table_catalog = '{table_database}' "
            f"AND table_schema = '{table_schema}' AND table_name = '{table_name}' "
            "ORDER BY pos"
        )
        mapping = []
        try:
            cur.execute(query_ddl)
        except snowflake.connector.errors.ProgrammingError as e:
            errors.append(f"Failed to get column info: {e.msg}")
            return profile
        for data in cur:
            field = {
                "outPos": data["POS"],
                "outName": data["FIELD_NAME"],
                "outType": data["FIELD_TYPE"],
            }
            mapping.append(field)
        if not mapping:
            errors.append("No columns defined in DDL")
        if errors:
            return profile
        errs = Wrangles.add_schema_to_dataframe(profile, mapping)
        errors += errs
        Wrangles.add_source_tables_to_dataframe(profile, cur)

        wrangle_schema = []
        for item in profile["schema"]:
            wrangle_schema.append(
                {
                    "pos": item["pos"],
                    "field_name": item["field_name"],
                    "field_type": item["field_type"],
                }
            )

        if upd_mode == "UPD":
            wrangle_dict = {
                "obj_type": "Wrangle",
                "domain_id": frame_id,
                "dataframe_id": dataframe_id,
                "schema": [Wrangles.DEV_IN_PROGRESS_FIELD],
                "manual_ddl": {
                    "snowflake": {
                        "status": "Prod",
                        "ddl": profile["ddl"],
                        "schema": wrangle_schema,
                        "source_tables": profile["source_tables"],
                        "table_id": full_wrangle_name,
                    }
                },
                "autogen_ddl": {},
            }
        else:
            wrangle_dict = {
                "ofId": dataframe_id,
                "toId": "wrangle",
                "attributes": {
                    "obj_type": "Wrangle",
                    "domain_id": frame_id,
                    "dataframe_id": dataframe_id,
                    "schema": [Wrangles.DEV_IN_PROGRESS_FIELD],
                    "manual_ddl": {
                        "snowflake": {
                            "status": "Prod",
                            "ddl": profile["ddl"],
                            "schema": wrangle_schema,
                            "source_tables": profile["source_tables"],
                            "table_id": full_wrangle_name,
                        }
                    },
                    "autogen_ddl": {},
                },
            }

        return wrangle_dict

    @staticmethod
    def add_table_view_names(frame: Dict[str, Any]):
        """Add Snowflake and BigQuery table/view names to frame"""
        sf_region = Wrangles.SNOWFLAKE_CONFIG
        cdu_code = frame["cdu_code"]
        frame_name = frame["name"]
        legacy_id = frame["dataset_legacy_id"]
        bq_table_name = (
            frame_name.upper().replace("-", "_").replace(".", "_")
            + "__"
            + legacy_id.replace("-", "_")
        )
        bq_view_name = "__".join([cdu_code, bq_table_name])
        sf_database = sf_region["database"]
        sf_table_name = "--".join([frame_name.upper(), legacy_id])
        sf_view_name = "--".join([cdu_code.upper(), sf_table_name])
        bq_status = (
            frame.get("manual_ddl", {})
            .get("bigquery", frame.get("autogen_ddl", {}).get("bigquery", {}))
            .get("status", "Prod")
        )
        sf_status = (
            frame.get("manual_ddl", {})
            .get("snowflake", frame.get("autogen_ddl", {}).get("snowflake", {}))
            .get("status", "Prod")
        )
        dataframe_type = frame["dataframe_type"]
        if dataframe_type == "WRANGLE":
            bq_project_type = (
                "wrangle_project" if bq_status == "Prod" else "wrangle_uat-project"
            )
            bq_dataset = "_".join(["wrangle", cdu_code])
            sf_schema = (
                sf_region["wrangle_view_schema"]
                if sf_status == "Prod"
                else sf_region["wrangle_uat_view_schema"]
            )
            sf_table_id = sf_view_id = f'{sf_database}.{sf_schema}."{sf_view_name}"'
        elif dataframe_type == "TEST":
            bq_project_type = "test_uat-project"
            bq_dataset = "_".join(["test", cdu_code])
            sf_schema = sf_region["wrangle_uat_view_schema"]
            sf_table_id = sf_view_id = f'{sf_database}.{sf_schema}."{sf_view_name}"'
        else:
            bq_project_type = "project"
            bq_dataset = "_".join(
                ["dataframe", cdu_code, frame["frame_id"].replace("-", "_")]
            )
            sf_schema = sf_region["table_schema"]
            sf_table_id = f'{sf_database}.{sf_schema}."{sf_table_name}"'
            sf_view_schema = sf_region["curated_view_schema"]
            sf_view_id = f'{sf_database}.{sf_view_schema}."{sf_view_name}"'

        frame.update(
            {
                "bq_project_type": bq_project_type,
                "bq_dataset": bq_dataset,
                "bq_table_name": bq_table_name,
                "bq_view_name": bq_view_name,
                "bq_table_id": ".".join([bq_dataset, bq_table_name]),
                "sf_schema": sf_schema,
                "sf_table_name": sf_table_name,
                "sf_view_name": sf_view_name,
                "sf_table_id": sf_table_id,
                "sf_view_id": sf_view_id,
            }
        )

    @staticmethod
    def add_schema_to_dataframe(
        dataframe: Dict[str, Union[str, list]],
        mapping: list,
        add_description: bool = False,
    ):
        """Add schema information to dataframe"""
        errors = []
        field_desc = {}

        dataframe["schema"].clear()
        for pos, field in enumerate(mapping, 1):
            field_name = field["outName"]
            entry = {
                "pos": pos,
                "field_id": field.get("outId"),
                "field_name": field_name,
                "field_type": field["outType"],
                "field_format": None,
                "cross_reference": None,
                "description": field_desc.get(field_name),
                "sample": None,
            }
            dataframe["schema"].append(entry)
        return errors

    @staticmethod
    def add_source_tables_to_dataframe(profile: Dict[str, Any], cur):
        """Extract source tables from Snowflake DDL"""
        explain_ddl = f"explain {profile['ddl']}"
        try:
            cur.execute(explain_ddl)
        except snowflake.connector.errors.ProgrammingError:
            return
        source_tables = set()
        for row in cur:
            if row["operation"] not in ["TableScan", "SecureView"]:
                continue
            source_tables.add(row["objects"])
        profile["source_tables"] = list(source_tables)

    @staticmethod
    def add_source_tables_to_bq_dataframe(profile: Dict[str, Any], client, sql):
        """Extract source tables from BigQuery DDL"""
        profile["source_tables"] = []

    @staticmethod
    def publish_uat_view(dataframe: Dict[str, Any], cur, promote=False, upd_prod=None):
        """Publish Snowflake UAT view"""
        CRUX_DATE_FIELD = Wrangles.CRUX_DATE_FIELD
        sf_region = Wrangles.SNOWFLAKE_CONFIG
        errors = []
        template_ddl = ""
        sf_ddl = dataframe.get("manual_ddl", {}).get(
            "snowflake", dataframe.get("autogen_ddl", {}).get("snowflake", {})
        )
        if not sf_ddl:
            errors.append("View DDL not found")
            return errors, template_ddl
        else:
            view_schema = (
                sf_region["wrangle_view_schema"]
                if promote
                else sf_region["wrangle_uat_view_schema"]
            )
        cdu_num = dataframe["sf_view_name"].split("--")[0][2:]
        full_view_name = (
            f"{sf_region['database']}.{view_schema}.\"{dataframe['sf_view_name']}\""
        )
        create = f"CREATE OR REPLACE SECURE VIEW {full_view_name} AS"
        if not promote:
            predicate = "'Temporal Entitlements' = 'Temporal Entitlements'"
        else:
            has_sdt = [
                i for i in dataframe["schema"] if i["field_name"] == CRUX_DATE_FIELD
            ]
            entitlement_date = (
                f'"{CRUX_DATE_FIELD}"' if has_sdt else "to_varchar(current_date)"
            )
            predicate = (
                f"{entitlement_date} < " + '(select distinct "SVC_BLOCK_DT" from'
                ' CX_DATA_LAKE.PRIVATE."CRUX_SERVICE_RESTRICTIONS" '
                + 'where "SVC_ACCT" = current_account() and '
                f"\"CDU_ID\"= '{cdu_num}' and \"STATUS\"='ACTIVE') "
                + f"AND (({entitlement_date} >= "
                + '(select distinct "R1_SDT" from'
                ' CX_DATA_LAKE.PRIVATE."CRUX_SERVICE_RESTRICTIONS" '
                + 'where "SVC_ACCT" = current_account() and '
                f"\"CDU_ID\"= '{cdu_num}' and \"STATUS\"='ACTIVE') "
                + f"AND {entitlement_date} <="
                + '(select distinct "R1_EDT" from  '
                'CX_DATA_LAKE.PRIVATE."CRUX_SERVICE_RESTRICTIONS" '
                + 'where "SVC_ACCT" = current_account() and '
                "\"CDU_ID\"= '{cdu_num}' and \"STATUS\"='ACTIVE')) "
                + f"OR ({entitlement_date} >= "
                + '(select distinct "R2_SDT" from '
                ' CX_DATA_LAKE.PRIVATE."CRUX_SERVICE_RESTRICTIONS" '
                + 'where "SVC_ACCT" = current_account() and '
                f"\"CDU_ID\"= '{cdu_num}' and \"STATUS\"='ACTIVE') "
                + f"AND {entitlement_date} <= "
                + '(select distinct "R2_EDT" from '
                ' CX_DATA_LAKE.PRIVATE."CRUX_SERVICE_RESTRICTIONS"'
                + 'where "SVC_ACCT" = current_account() and '
                f"\"CDU_ID\"= '{cdu_num}' and \"STATUS\"='ACTIVE')))"
            )
        where = f"WHERE {predicate}"
        view_ddl = (
            "\n".join([create, "SELECT * from (", sf_ddl["ddl"], ")", where]) + ";"
        )
        if promote is False and sf_ddl["status"] == "Prod":
            logger.info("Bypass create view in UAT")
        else:
            try:
                cur.execute(view_ddl)
            except snowflake.connector.errors.ProgrammingError as e:
                logger.error(f"Failed to create view: [{e.errno}] {e.msg}")
                logger.error(f"   DDL: {view_ddl}")
                errors.append(
                    f"Failed to create view: DDL[{view_ddl}] [{e.errno}] {e.msg}"
                )
                return errors, template_ddl
            template_ddl = view_ddl
            sf_ddl["table_id"] = full_view_name
            sf_ddl["status"] = "UAT"

        return errors, template_ddl

    @staticmethod
    @retry_on_failure
    def get_dataset_id(cdu_id, token):
        """Get dataset ID for CDU with retry logic"""
        errors = []
        dsids = set()

        # Use v2 endpoint with proper CDU ID capitalization: Cx... (capital C, lowercase x)
        cdu_id_formatted = (
            cdu_id[0] + cdu_id[1].lower() + cdu_id[2:] if len(cdu_id) >= 2 else cdu_id
        )

        get_url = (
            f"https://api.cruxinformatics.com/v2/ops/cdumaps/{cdu_id_formatted}/datasets"
            f"?offset=0&limit=10000"
        )

        try:
            response = requests.get(
                get_url,
                headers={"Authorization": f"Bearer {token}"},
                timeout=Wrangles.TIMEOUT,
            )
            response.raise_for_status()
        except HTTPError as http_err:
            logger.error(f"HTTP error occurred: {http_err}")
            errors.append(str(http_err))
            return dsids
        except Exception as err:
            logger.error(f"Other error occurred: {err}")
            errors.append(str(err))
            return dsids

        response_json = response.json()

        # Handle both array response and object with items property
        items = (
            response_json
            if isinstance(response_json, list)
            else response_json.get("items", [])
        )

        for item in items:
            legacy_id = item.get("legacyId")
            id = item.get("id")
            if id and legacy_id:
                dsids.add(f"{id}|{legacy_id}")

        if not dsids:
            logger.warning(f"No dataset found for CDU {cdu_id_formatted}")

        return dsids

    @staticmethod
    @retry_on_failure
    def get_frames(datasets, token, wrangle_name):
        """Get frames from datasets with retry logic"""
        frames = []

        for ds in datasets:
            dataset_id = ds["datasetid"]
            legacy_id = ds["legacyid"]

            get_url = (
                f"https://api.cruxinformatics.com/v2/ops/frames"
                f"?filter=datasetId.eq.{dataset_id}&offset=0&limit=100"
                f"&filterbyOrg=false"
            )

            try:
                response = requests.get(
                    get_url,
                    headers={"Authorization": f"Bearer {token}"},
                    timeout=Wrangles.TIMEOUT,
                )
                response.raise_for_status()
                response_json = response.json()

                if isinstance(response_json, dict):
                    items = response_json.get("items", [])
                elif isinstance(response_json, list):
                    items = response_json
                else:
                    items = []

            except Exception:
                continue

            if items:
                frame = items[0]
                frames.append(
                    {
                        "id": frame.get("id"),
                        "name": frame.get("name"),
                        "frameId": frame.get("id"),
                        "datasetId": dataset_id,
                        "legacyid": legacy_id,
                    }
                )

        return frames

    @staticmethod
    @retry_on_failure
    def post_dataframe(dataframe, token):
        """Post dataframe with retry logic"""
        errors = []
        post_url = "https://api.cruxinformatics.com/v2/ops/dataframes"
        json_frames = json.dumps(dataframe)

        try:
            response = requests.post(
                post_url,
                data=json_frames,
                headers={
                    "Authorization": f"Bearer {token}",
                    "Content-type": "application/json",
                },
                timeout=Wrangles.TIMEOUT,
            )
            response.raise_for_status()
            response_json = response.json()
            dataframeid = response_json["id"]

        except HTTPError as http_err:
            if "already exists" in response.text:
                match = re.search(
                    r"Dataframe \[([^\]]+)\] already exists", response.text
                )
                if match:
                    existing_id = match.group(1)
                    return existing_id, []
                else:
                    errors.append("Dataframe already exists but could not extract ID")
            else:
                errors.append(str(http_err))
            return None, errors
        except Exception as err:
            errors.append(str(err))
            return None, errors

        return dataframeid, errors

    @staticmethod
    @retry_on_failure
    def post_dataframe_config(dataframe, token):
        """Post dataframe config with retry logic"""
        errors = []
        post_url = "https://api.cruxinformatics.com/v2/ops/mapping/dataframe-configs"
        json_frames = json.dumps(dataframe)

        try:
            response = requests.post(
                post_url,
                data=json_frames,
                headers={
                    "Authorization": f"Bearer {token}",
                    "Content-type": "application/json",
                },
                timeout=Wrangles.TIMEOUT,
            )
            response.raise_for_status()

        except HTTPError as http_err:
            if response.status_code == 409:
                dataframe_id = dataframe.get("ofId") or dataframe.get(
                    "attributes", {}
                ).get("dataframe_id")
                if dataframe_id:
                    errors = Wrangles.put_dataframe_config(
                        dataframe_id, dataframe, token
                    )
                    if not errors:
                        return []
                else:
                    errors.append("Cannot update config: missing dataframe_id")
            else:
                errors.append(str(http_err))

        except Exception as err:
            errors.append(str(err))

        return errors

    def validate_sql(
        self,
        dw_type: str,
        sql_file: str,
        cdu_id: str = None,
        wrangle_name: str = None,
        legacy_id: str = None,
    ):
        """
        Validate SQL file - check syntax, row count, and column names.
        If valid and CDU parameters provided, create a view in BigQuery/Snowflake UAT.

        Args:
            dw_type: Data warehouse type ('BQ' for BigQuery, 'SF' for Snowflake)
            sql_file: Path to the SQL file to validate
            cdu_id: (Optional) CDU identifier for creating view in UAT
            wrangle_name: (Optional) Wrangle name for creating view in UAT
            legacy_id: (Optional) Legacy ID for creating view in UAT
        """

        # Read the SQL file
        try:
            with open(sql_file, "r") as f:
                sql_query = f.read().strip()
            logger.info(f"SQL file read successfully: {sql_file}")
        except FileNotFoundError:
            logger.error(f"SQL file not found: {sql_file}")
            raise typer.Exit(1)
        except Exception as e:
            logger.error(f"Error reading SQL file: {e}")
            raise typer.Exit(1)

        # Validate based on data warehouse type
        if dw_type.upper() == "BQ":
            self._validate_bigquery_sql(sql_query, cdu_id, wrangle_name, legacy_id)
        elif dw_type.upper() == "SF":
            self._validate_snowflake_sql(sql_query, cdu_id, wrangle_name, legacy_id)
        else:
            logger.error(f"Invalid data warehouse type: {dw_type}")
            raise typer.Exit(1)

    def _validate_bigquery_sql(
        self,
        sql_query: str,
        cdu_id: str = None,
        wrangle_name: str = None,
        legacy_id: str = None,
    ):
        """Validate SQL syntax and metadata for BigQuery. Optionally create UAT view."""
        try:
            logger.info("Validating BigQuery SQL...")

            client = self.get_bigquery_client()

            # 1. Check SQL syntax using dry_run
            logger.info("  • Checking SQL syntax...")
            job_config = bigquery.QueryJobConfig(dry_run=True, use_query_cache=False)
            try:
                query_job = client.query(sql_query, job_config=job_config)
                logger.info("  SQL syntax is valid")
            except Exception as e:
                logger.error(f"  SQL syntax error: {e}")
                raise

            # 2. Execute query to get metadata
            logger.info("  • Executing query to gather metadata...")
            try:
                query_job = client.query(sql_query)
                results = query_job.result()

                # Get row count
                row_count = results.total_rows
                logger.info(f"  Row count: {row_count:,}")

                # Get column information
                column_info = results.schema
                logger.info(f"  Column count: {len(column_info)}")

                logger.info("\nColumn Details:")
                logger.info("-" * 80)

                for i, field in enumerate(column_info, 1):
                    col_name = field.name
                    col_type = field.field_type
                    col_mode = field.mode if field.mode else "NULLABLE"
                    col_description = field.description if field.description else "N/A"

                    logger.info(f"  {i}. {col_name}")
                    logger.info(f"     Type: {col_type} | Mode: {col_mode}")
                    logger.info(f"     Description: {col_description}")

                logger.info("-" * 80)
                logger.info("\nValidation completed successfully!\n")

                # Create view in UAT if optional parameters are provided
                if cdu_id and wrangle_name and legacy_id:
                    logger.info("Creating view in BigQuery UAT...")
                    try:
                        bq_view_id, final_sql, errors = Wrangles.publish_bq_view_uat(
                            client,
                            cdu_id,
                            wrangle_name,
                            sql_query,
                            legacy_id,
                            promote=False,  # Create in UAT
                        )

                        if errors:
                            for err in errors:
                                logger.error(f"ERROR: {err}")
                        else:
                            logger.info(f"View created in UAT: {bq_view_id}")
                            logger.info(
                                "  - Ready for testing before PROD deployment\n"
                            )
                    except AuthorizationError as e:
                        logger.error(f"Authorization failed: {e}")
                        logger.error("UAT view was not created")

            except Exception as e:
                logger.error(f"  Error executing query: {e}")
                raise

        except Exception as e:
            logger.error(f"BigQuery validation failed: {e}")
            raise typer.Exit(1)

    def _validate_snowflake_sql(
        self,
        sql_query: str,
        cdu_id: str = None,
        wrangle_name: str = None,
        legacy_id: str = None,
    ):
        """Validate SQL syntax and metadata for Snowflake. Optionally create UAT view."""
        try:
            logger.info("Validating Snowflake SQL...")

            # Get Snowflake connection
            conn = get_snowflake_connection_instance()
            cur = conn.cursor(DictCursor)

            # 1. Check SQL syntax using EXPLAIN
            logger.info("  • Checking SQL syntax...")
            try:
                explain_query = f"EXPLAIN {sql_query}"
                cur.execute(explain_query)
                logger.info("  SQL syntax is valid")
            except Exception as e:
                logger.error(f"  SQL syntax error: {e}")
                raise

            # 2. Execute query to get metadata
            logger.info("  • Executing query to gather metadata...")
            try:
                cur.execute(sql_query)

                # Get row count
                row_count = cur.rowcount
                logger.info(f"  Row count: {row_count:,}")

                # Get column information
                columns = [desc[0] for desc in cur.description]
                logger.info(f"  Column count: {len(columns)}")

                logger.info("\nColumn Details:")
                logger.info("-" * 80)

                for i, col_name in enumerate(columns, 1):
                    logger.info(f"  {i}. {col_name}")

                logger.info("-" * 80)
                logger.info("\nValidation completed successfully!\n")

                # Create view in UAT if optional parameters are provided
                if cdu_id and wrangle_name and legacy_id:
                    logger.info("Creating view in Snowflake UAT...")
                    logger.info("  Snowflake UAT view creation - future feature")

                cur.close()
                conn.close()

            except Exception as e:
                logger.error(f"  Error executing query: {e}")
                raise

        except Exception as e:
            logger.error(f"Snowflake validation failed: {e}")
            raise typer.Exit(1)

    @staticmethod
    @retry_on_failure
    def put_dataframe_config(dataframe_id, dataframe, token):
        """Put dataframe config with retry logic"""
        errors = []
        put_url = (
            "https://api.cruxinformatics.com/v2/ops/"
            f"mapping/dataframe-configs/{dataframe_id}/to/wrangle/attr"
        )
        json_frames = json.dumps(dataframe)

        try:
            response = requests.put(
                put_url,
                data=json_frames,
                headers={
                    "Authorization": f"Bearer {token}",
                    "Content-type": "application/json",
                },
                timeout=Wrangles.TIMEOUT,
            )
            response.raise_for_status()
        except HTTPError as http_err:
            errors.append(str(http_err))
        except Exception as err:
            errors.append(str(err))

        return errors

    @staticmethod
    @retry_on_failure
    def put_dataframe_mapping(dataframe_id, dataframe, token, dw_type):
        """Put dataframe mapping for Snowflake or BigQuery with retry logic"""
        errors = []

        if dw_type == "SF":
            ddl = dataframe.get("manual_ddl", {}).get("snowflake", {}).get("ddl", [])
            map_dict = {"sfDDL": ddl}
            put_url = (
                "https://api.cruxinformatics.com/v2/ops"
                f"/mapping/dataframe-configs/{dataframe_id}/to/sfDDL/attr"
            )
            json_frames = json.dumps(map_dict)

            try:
                response = requests.put(
                    put_url,
                    data=json_frames,
                    headers={
                        "Authorization": f"Bearer {token}",
                        "Content-type": "application/json",
                    },
                    timeout=Wrangles.TIMEOUT,
                )
                response.raise_for_status()
            except HTTPError as http_err:
                errors.append(str(http_err))
            except Exception as err:
                errors.append(str(err))

        put_url = (
            "https://api.cruxinformatics.com/v2/ops"
            f"/mapping/dataframe-configs/{dataframe_id}/to/wrangle/attr"
        )
        json_frames = json.dumps(dataframe)

        try:
            response = requests.put(
                put_url,
                data=json_frames,
                headers={
                    "Authorization": f"Bearer {token}",
                    "Content-type": "application/json",
                },
                timeout=Wrangles.TIMEOUT,
            )
            response.raise_for_status()
        except HTTPError as http_err:
            errors.append(str(http_err))
        except Exception as err:
            errors.append(str(err))

        return errors

    @staticmethod
    @retry_on_failure
    def update_dataframe_status(dataframe, token):
        """Update dataframe status with retry logic"""
        errors = []
        put_url = (
            f"https://api.cruxinformatics.com/v2/ops/dataframes/{dataframe}/"
            f"status/LIVE"
        )
        try:
            response = requests.put(
                put_url,
                headers={
                    "Authorization": f"Bearer {token}",
                    "Content-type": "application/json",
                },
                timeout=Wrangles.TIMEOUT,
            )
            response.raise_for_status()
        except HTTPError as http_err:
            errors.append(str(http_err))
        except Exception as err:
            errors.append(str(err))
        return errors

    @staticmethod
    def publish_sf_view(
        wrangle_dict, sf_wrangle_name, cdu_id, cur, dataframe_id, upd_mode
    ):
        """Publish Snowflake view"""
        if not cur:
            raise ValidationError("No Snowflake cursor provided.")

        if upd_mode == "UPD":
            ddl = wrangle_dict.get("manual_ddl", {}).get("snowflake", {}).get("ddl")
        else:
            ddl = (
                wrangle_dict.get("attributes", {})
                .get("manual_ddl", {})
                .get("snowflake", {})
                .get("ddl")
            )

        if not ddl:
            logger.error("Could not create wrangle DDL")
            return

        create = f"CREATE OR REPLACE SECURE VIEW {sf_wrangle_name} AS"
        entitlement_date = "to_varchar(current_date)"

        predicate = (
            f"{entitlement_date} < " + '(select distinct "SVC_BLOCK_DT" '
            "from CX_DATA_LAKE.PRIVATE.CRUX_SERVICE_RESTRICTIONS "
            + 'where "SVC_ACCT" = current_account() and '
            f"\"CDU_ID\"= '{cdu_id}' and \"STATUS\"='ACTIVE') "
            + f"AND (({entitlement_date} >= "
            + '(select distinct "R1_SDT" '
            "from CX_DATA_LAKE.PRIVATE.CRUX_SERVICE_RESTRICTIONS "
            + 'where "SVC_ACCT" = current_account() and '
            f"\"CDU_ID\"= '{cdu_id}' and \"STATUS\"='ACTIVE') "
            + f"AND {entitlement_date} <="
            + '(select distinct "R1_EDT" '
            "from CX_DATA_LAKE.PRIVATE.CRUX_SERVICE_RESTRICTIONS "
            + 'where "SVC_ACCT" = current_account() and '
            f"\"CDU_ID\"= '{cdu_id}' and \"STATUS\"='ACTIVE')) "
            + f"OR ({entitlement_date} >= "
            + '(select distinct "R2_SDT" '
            "from CX_DATA_LAKE.PRIVATE.CRUX_SERVICE_RESTRICTIONS "
            + 'where "SVC_ACCT" = current_account() and '
            f"\"CDU_ID\"= '{cdu_id}' and \"STATUS\"='ACTIVE') "
            + f"AND {entitlement_date} <= "
            + '(select distinct "R2_EDT" '
            "from CX_DATA_LAKE.PRIVATE.CRUX_SERVICE_RESTRICTIONS "
            + 'where "SVC_ACCT" = current_account() and '
            f"\"CDU_ID\"= '{cdu_id}' and \"STATUS\"='ACTIVE')))"
        )
        where = f"WHERE {predicate}"
        view_ddl = "\n".join([create, "SELECT * from (", ddl, ")", where]) + ";"
        try:
            cur.execute(view_ddl)
        except snowflake.connector.errors.ProgrammingError as e:
            logger.error(f"Failed to create Snowflake view: {e.msg}")
            return

        wrangle_sf_dict = {
            "ofId": dataframe_id,
            "toId": "sfDDL",
            "attributes": {"sfDDL": view_ddl},
        }
        return wrangle_sf_dict

    @staticmethod
    def get_bigquery_client():
        """Initialize BigQuery client"""
        if not BIGQUERY_AVAILABLE:
            raise ImportError(
                "Google BigQuery client library is not installed. "
                "Install with: pip install google-cloud-bigquery"
            )

        try:
            client = bigquery.Client()
            return client
        except Exception as e:
            logger.error(f"Failed to initialize BigQuery client: {e}")
            raise

    def update_bq_view_directly(self, bq_view_id, sql, token, cdu_id, wrangle_name):
        """Update BigQuery view directly when no dataframe config exists"""
        logger.info(f"Updating BigQuery view directly: {bq_view_id}")
        client = self.get_bigquery_client()

        try:
            view_ddl = f"CREATE OR REPLACE VIEW `{bq_view_id}` AS {sql}"

            try:
                query_job = client.query(view_ddl)
                query_job.result()
            except Exception:
                logger.warning("Trying with crux-data-prd substitution...")
                sql_fixed = sql.replace("crux-data-lake.", "crux-data-prd.")
                view_ddl = f"CREATE OR REPLACE VIEW `{bq_view_id}` AS {sql_fixed}"
                query_job = client.query(view_ddl)
                query_job.result()
                sql = sql_fixed

            logger.info(f"View updated: {bq_view_id}")

            logger.info("CRITICAL: Authorizing view to access source datasets...")
            try:
                auth_errors = Wrangles.authorize_bq_view(
                    client, bq_view_id, sql, raise_on_error=True
                )
                print(auth_errors)
            except AuthorizationError as e:
                logger.error(f"CRITICAL: Authorization failed: {e}")
                logger.error("View exists but is NOT properly authorized!")
                logger.error("Users will get 'Access Denied' errors!")
                return

            logger.info("Wrangle has been updated in BigQuery (PROD)")

        except Exception as e:
            logger.error(f"Failed to update BigQuery view: {e}")
