"""
Parameter Mapping System for EMDS Workflows

Handles mapping of QGIS layer attributes to NetWeaver model variables,
including type conversion and validation.

Author: EMDS QGIS Port POC
Date: 2025-11-07
"""

from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Tuple
import pandas as pd
from enum import Enum
import re


class VariableType(Enum):
    """NetWeaver variable data types"""
    DOUBLE = "double"
    STRING = "string"
    INTEGER = "integer"
    BOOLEAN = "boolean"
    UNKNOWN = "unknown"


@dataclass
class VariableMapping:
    """Maps a source column to a target model variable"""
    source_column: str          # QGIS layer attribute column name
    target_variable: str        # NetWeaver model variable name
    variable_type: VariableType = VariableType.DOUBLE
    required: bool = True
    type_conversion: bool = True  # Auto-convert types if possible
    default_value: Optional[Any] = None

    def __repr__(self) -> str:
        return f"'{self.source_column}' → '{self.target_variable}' ({self.variable_type.value})"


@dataclass
class ParameterMappingConfig:
    """Configuration for mapping input data to model variables"""
    workflow_step_id: str
    model_name: str
    mappings: List[VariableMapping] = field(default_factory=list)
    description: str = ""
    created_date: str = ""

    def add_mapping(self, source_col: str, target_var: str,
                   var_type: VariableType = VariableType.DOUBLE,
                   required: bool = True) -> None:
        """Add a parameter mapping"""
        mapping = VariableMapping(
            source_column=source_col,
            target_variable=target_var,
            variable_type=var_type,
            required=required
        )
        self.mappings.append(mapping)

    def get_required_variables(self) -> List[str]:
        """Get list of required target variables"""
        return [m.target_variable for m in self.mappings if m.required]

    def get_optional_variables(self) -> List[str]:
        """Get list of optional target variables"""
        return [m.target_variable for m in self.mappings if not m.required]


class ParameterMapper:
    """Maps QGIS layer data to NetWeaver model parameters"""

    def __init__(self, mapping_config: ParameterMappingConfig):
        """
        Initialize mapper with mapping configuration

        Args:
            mapping_config: ParameterMappingConfig with variable mappings
        """
        self.config = mapping_config
        self.errors: List[str] = []
        self.warnings: List[str] = []

    def validate_mappings(self, available_columns: List[str]) -> bool:
        """
        Validate that all required source columns exist

        Args:
            available_columns: List of column names in input data

        Returns:
            True if valid, False otherwise
        """
        self.errors.clear()
        self.warnings.clear()

        # Check required mappings
        for mapping in self.config.mappings:
            if mapping.required and mapping.source_column not in available_columns:
                self.errors.append(
                    f"Required column '{mapping.source_column}' not found in layer. "
                    f"Available: {', '.join(available_columns)}"
                )

        # Check for unmapped required variables
        available_source_cols = set(m.source_column for m in self.config.mappings)
        unmapped_cols = set(available_columns) - available_source_cols
        if unmapped_cols and self.config.mappings:
            self.warnings.append(
                f"Layer columns not mapped to model: {', '.join(unmapped_cols)}"
            )

        return len(self.errors) == 0

    def map_dataframe(self, input_df: pd.DataFrame) -> Tuple[pd.DataFrame, bool]:
        """
        Map input DataFrame columns to model variables

        Args:
            input_df: Input DataFrame with layer data

        Returns:
            Tuple of (mapped_dataframe, success)
        """
        self.errors.clear()
        self.warnings.clear()

        # Validate mappings exist
        if not self.validate_mappings(input_df.columns.tolist()):
            return None, False

        # Create output DataFrame with mapped columns
        output_df = pd.DataFrame()

        for mapping in self.config.mappings:
            if mapping.source_column not in input_df.columns:
                if mapping.required:
                    self.errors.append(
                        f"Required column '{mapping.source_column}' not found"
                    )
                continue

            try:
                # Get source data
                source_data = input_df[mapping.source_column].copy()

                # Type conversion if needed
                if mapping.type_conversion:
                    source_data = self._convert_type(
                        source_data,
                        mapping.variable_type,
                        mapping.target_variable
                    )

                # Handle missing values
                if source_data.isna().any():
                    null_count = source_data.isna().sum()
                    self.warnings.append(
                        f"Column '{mapping.source_column}': {null_count} null values "
                        f"mapped to {mapping.target_variable}"
                    )

                # Add to output
                output_df[mapping.target_variable] = source_data

            except Exception as e:
                self.errors.append(
                    f"Error mapping '{mapping.source_column}' to '{mapping.target_variable}': {str(e)}"
                )

        # Add row ID for tracking
        if len(output_df) > 0:
            output_df.insert(0, 'RowID', range(len(output_df)))

        success = len(self.errors) == 0
        return output_df if success else None, success

    def _convert_type(self, series: pd.Series, target_type: VariableType,
                     var_name: str) -> pd.Series:
        """
        Convert Series to target type

        Args:
            series: Input Series
            target_type: Target data type
            var_name: Variable name for error messages

        Returns:
            Converted Series
        """
        if target_type == VariableType.DOUBLE:
            return pd.to_numeric(series, errors='coerce')
        elif target_type == VariableType.INTEGER:
            return pd.to_numeric(series, errors='coerce').astype('Int64', errors='ignore')
        elif target_type == VariableType.BOOLEAN:
            return series.astype(bool, errors='ignore')
        elif target_type == VariableType.STRING:
            return series.astype(str)
        else:
            return series

    def get_errors(self) -> List[str]:
        """Get validation/mapping errors"""
        return self.errors.copy()

    def get_warnings(self) -> List[str]:
        """Get validation/mapping warnings"""
        return self.warnings.copy()

    def has_errors(self) -> bool:
        """Check if there are any errors"""
        return len(self.errors) > 0

    def get_mapping_summary(self) -> str:
        """Get human-readable summary of mappings"""
        lines = [f"Parameter Mapping: {self.config.model_name}"]
        lines.append(f"Workflow: {self.config.workflow_step_id}")
        lines.append(f"Total mappings: {len(self.config.mappings)}")
        lines.append("")

        for mapping in self.config.mappings:
            status = "REQUIRED" if mapping.required else "optional"
            lines.append(f"  {mapping.source_column:30} → {mapping.target_variable:30} [{status}]")

        return "\n".join(lines)


class ModelVariableExtractor:
    """Extracts variable information from model metadata"""

    @staticmethod
    def extract_from_model_info(model_info: Dict[str, Any]) -> Tuple[List[str], Dict[str, str]]:
        """
        Extract variable names and metadata from model info

        Args:
            model_info: Model metadata dictionary

        Returns:
            Tuple of (variable_names, variable_metadata)
        """
        variables = model_info.get('Variables', [])

        var_names = [v.get('Name', '') for v in variables if not v.get('Ignore', False)]
        var_metadata = {
            v.get('Name', ''): {
                'type': v.get('Type', 'Unknown'),
                'ignored': v.get('Ignore', False)
            }
            for v in variables
        }

        return var_names, var_metadata

    @staticmethod
    def suggest_mappings(
        layer_columns: List[str],
        model_variables: List[str],
        column_aliases: Optional[Dict[str, List[str]]] = None,
    ) -> List[Tuple[str, str]]:
        """
        Suggest parameter mappings based on column/variable name similarity

        Args:
            layer_columns: Column names from QGIS layer
            model_variables: Variable names from model
            column_aliases: Optional physical-column -> aliases/title/name lookup

        Returns:
            List of suggested (source_column, target_variable) tuples
        """
        suggestions = []
        matched_variables = set()

        alias_lookup = column_aliases or {}

        def normalize(name: str) -> str:
            """Normalize names across ArcGIS truncation, QGIS prefixes, and model aliases."""
            text = str(name or "").strip().lower()
            text = re.sub(r"^(input|target|source)[_\s]+", "", text)
            text = re.sub(r"^[a-z0-9]+[_\s]+", "", text)
            text = re.sub(r"[^a-z0-9]+", "", text)
            return text

        def token_variants(name: str) -> List[str]:
            """Return whole-name and suffix variants for prefixed overlay fields."""
            raw = str(name or "").strip()
            if not raw:
                return []

            variants = [raw]
            parts = [part for part in re.split(r"[_\s]+", raw) if part]
            if len(parts) > 1:
                variants.extend("_".join(parts[index:]) for index in range(1, len(parts)))
                variants.append(parts[-1])

            known_prefix_removed = re.sub(
                r"^(input|target|source)[_\s]+",
                "",
                raw,
                flags=re.IGNORECASE,
            )
            if known_prefix_removed != raw:
                variants.append(known_prefix_removed)

            seen = set()
            unique_variants = []
            for variant in variants:
                key = str(variant).strip().lower()
                if key and key not in seen:
                    seen.add(key)
                    unique_variants.append(str(variant).strip())
            return unique_variants

        def column_tokens(column_name: str) -> List[str]:
            tokens = token_variants(column_name)
            tokens.extend(alias_lookup.get(column_name, []))
            tokens.extend(alias_lookup.get(str(column_name).lower(), []))
            return [token for token in tokens if str(token).strip()]

        normalized_columns: List[Tuple[str, List[str]]] = [
            (col, [normalize(token) for token in column_tokens(col)])
            for col in layer_columns
        ]

        # Exact matches first, including GeoPackage aliases such as
        # input_CANOPYDENS -> canopyDensity.
        for var in model_variables:
            var_norm = normalize(var)
            if not var_norm:
                continue
            for col, col_norms in normalized_columns:
                if var_norm in col_norms:
                    suggestions.append((col, var))
                    matched_variables.add(var)
                    break

        # Fallback to conservative partial matches only for variables not already
        # resolved. This preserves old behavior without letting broad prefix
        # matches override explicit aliases.
        for var in model_variables:
            if var in matched_variables:
                continue
            var_norm = normalize(var)
            if len(var_norm) <= 3:
                continue
            for col, col_norms in normalized_columns:
                if any(
                    len(col_norm) > 3 and (col_norm in var_norm or var_norm in col_norm)
                    for col_norm in col_norms
                ):
                    suggestions.append((col, var))
                    matched_variables.add(var)
                    break

        return suggestions
