"""Emission of original model symbols (Sets, Aliases, Parameters).

This module emits GAMS declarations for the original model symbols using
actual IR fields (Finding #3 from final review).

Key principles:
- Use SetDef.members (not .elements)
- Use ParameterDef.domain and .values (not invented fields)
- Use AliasDef.target and .universe
- Scalars have empty domain () and values[()] = value
- Multi-dimensional parameter keys formatted as GAMS syntax: ("i1", "j2") → "i1.j2"
- Emit computed parameter assignments as GAMS statements (Sprint 17 Day 4)
"""

import re

from src.emit.expr_to_gams import expr_to_gams
from src.ir.constants import PREDEFINED_GAMS_CONSTANTS
from src.ir.model_ir import ModelIR
from src.ir.symbols import SetDef

# Regex pattern for valid GAMS set element identifiers
# Allows: letters, digits, underscores, hyphens, dots (for tuples like a.b), plus signs
# Must start with letter or digit
# Note: Dots are allowed because they represent tuple notation in GAMS (e.g., upper.egypt).
# Plus signs are allowed because GAMS uses them in composite names (e.g., food+agr, pulp+paper).
# These characters cannot break out of the /.../ block - that requires / or ; which are blocked.
_VALID_SET_ELEMENT_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-\.+]*$")


def _sanitize_set_element(element: str) -> str:
    """Sanitize a set element name for safe GAMS emission.

    Validates that the element contains only safe characters to prevent
    DSL injection attacks. Elements that could break out of the /.../ block
    or inject GAMS statements are rejected.

    Args:
        element: Set element identifier

    Returns:
        The element if valid, or a safely quoted version

    Raises:
        ValueError: If the element contains characters that cannot be safely emitted
    """
    # Check for obviously dangerous characters that could break GAMS syntax
    # These characters could allow escaping the /.../ block or injecting statements
    dangerous_chars = {"/", ";", "*", "$", '"', "'", "(", ")", "[", "]", "=", "<", ">"}

    if any(c in element for c in dangerous_chars):
        raise ValueError(
            f"Set element '{element}' contains unsafe characters that could cause "
            f"GAMS injection. Dangerous characters: {dangerous_chars & set(element)}"
        )

    # Validate against safe pattern
    if not _VALID_SET_ELEMENT_PATTERN.match(element):
        raise ValueError(
            f"Set element '{element}' contains invalid characters. "
            f"Set elements must start with a letter or digit and contain only "
            f"letters, digits, underscores, hyphens, dots, and plus signs."
        )

    return element


def _format_set_declaration(set_name: str, set_def: "SetDef") -> str:
    """Format a single set declaration line.

    Args:
        set_name: Name of the set
        set_def: SetDef object with members and domain

    Returns:
        Formatted set declaration line (without leading spaces)
    """
    # Format set declaration with optional domain (subset relationship)
    # E.g., cg(genchar) for subset cg of genchar
    if set_def.domain:
        domain_str = ",".join(set_def.domain)
        set_decl = f"{set_name}({domain_str})"
    else:
        set_decl = set_name

    # Use SetDef.members
    # Members are stored as a list of strings in SetDef
    # Sanitize each member to prevent DSL injection attacks
    if set_def.members:
        sanitized_members = [_sanitize_set_element(m) for m in set_def.members]
        members = ", ".join(sanitized_members)
        return f"{set_decl} /{members}/"
    else:
        # Empty set or universe (or subset with inherited members)
        return set_decl


def _compute_set_alias_phases(
    model_ir: ModelIR,
) -> tuple[dict[str, int], dict[str, int]]:
    """Compute emission phases for sets and aliases based on dependencies.

    This function assigns phases to sets and aliases to ensure correct declaration
    order. A set must be declared before it can be referenced. Uses an iterative,
    dependency-aware algorithm that assigns phases based on actual dependencies.

    Emission order for each phase p:
    1. Phase p sets
    2. Phase p aliases (targeting phase p sets)

    The algorithm iteratively assigns phases such that:
    - An alias can be assigned to phase p if its target set is in a phase <= p.
    - A set can be assigned to phase p if all its domain indices refer only to
      sets/aliases already assigned to phases < p (for aliases) or <= p (for sets).

    Args:
        model_ir: Model IR containing set and alias definitions

    Returns:
        Tuple of (set_phases, alias_phases) as dicts mapping lowercase names to phase numbers.
    """
    if not model_ir.sets:
        return {}, {}

    # Map: alias_name (lower) -> target_set_name (lower)
    alias_targets: dict[str, str] = {}
    if model_ir.aliases:
        for alias_name, alias_def in model_ir.aliases.items():
            alias_targets[alias_name.lower()] = alias_def.target.lower()

    # Build a map of set name (lowercase) -> domain indices (lowercase)
    set_domains: dict[str, set[str]] = {}
    for set_name, set_def in model_ir.sets.items():
        set_domains[set_name.lower()] = {idx.lower() for idx in set_def.domain}

    all_set_names = set(set_domains.keys())
    alias_names_lower = set(alias_targets.keys())

    # Validate that all alias targets refer to existing sets or other aliases.
    # Without this check, an alias targeting a non-existent symbol would remain
    # unassigned and trigger a misleading "Circular dependency" error.
    all_known_symbols = all_set_names | alias_names_lower
    for alias_name_lower, target_lower in alias_targets.items():
        if target_lower not in all_known_symbols:
            raise ValueError(
                f"Alias '{alias_name_lower}' targets non-existent symbol '{target_lower}'. "
                f"Expected a set or another alias."
            )

    # Phase 1 sets: Sets with no alias dependencies (directly or transitively)
    # First, identify sets that directly depend on aliases
    sets_with_alias_deps: set[str] = set()
    for set_name_lower, domain_indices in set_domains.items():
        if domain_indices & alias_names_lower:
            sets_with_alias_deps.add(set_name_lower)

    # Compute transitive closure: sets depending on alias-dependent sets
    changed = True
    while changed:
        changed = False
        for set_name_lower, domain_indices in set_domains.items():
            if set_name_lower not in sets_with_alias_deps:
                if domain_indices & sets_with_alias_deps:
                    sets_with_alias_deps.add(set_name_lower)
                    changed = True

    phase1_sets = all_set_names - sets_with_alias_deps

    # Initialize phase tracking for iterative assignment
    set_phases: dict[str, int] = dict.fromkeys(phase1_sets, 1)
    alias_phases: dict[str, int] = {}

    # Phase 1 aliases: aliases targeting phase 1 sets
    for alias_name_lower, target_lower in alias_targets.items():
        if target_lower in phase1_sets:
            alias_phases[alias_name_lower] = 1

    unassigned_sets = all_set_names - phase1_sets
    unassigned_aliases = alias_names_lower - set(alias_phases.keys())

    # Iterative, dependency-aware phase assignment
    # Upper bound: each symbol can require at most one additional phase
    max_phases = len(all_set_names) + len(alias_names_lower) + 1
    phase = 2
    while (unassigned_sets or unassigned_aliases) and phase <= max_phases:
        progressed = True
        while progressed:
            progressed = False

            # Assign aliases whose target is already in a phase <= current phase.
            # The target may be a set OR another alias (alias chains).
            for alias_name_lower in list(unassigned_aliases):
                target_lower = alias_targets[alias_name_lower]
                target_phase = set_phases.get(target_lower)
                if target_phase is None:
                    # Target might be another alias (alias chain)
                    target_phase = alias_phases.get(target_lower)
                if target_phase is not None and target_phase <= phase:
                    alias_phases[alias_name_lower] = phase
                    unassigned_aliases.remove(alias_name_lower)
                    progressed = True

            # Assign sets whose domain indices only reference resolved deps
            for set_name_lower in list(unassigned_sets):
                domain_indices = set_domains.get(set_name_lower, set())

                def _index_resolved(idx: str, current_phase: int = phase) -> bool:
                    # Resolution order: check sets first, then aliases.
                    # GAMS enforces that set and alias names are unique within a
                    # model, so an index cannot match both set_phases and
                    # alias_targets simultaneously.
                    if idx in set_phases:
                        return set_phases[idx] <= current_phase
                    # If index is an alias, it must be in a STRICTLY EARLIER phase
                    # because aliases in phase p are emitted AFTER phase p sets,
                    # so sets depending on phase p aliases must be in phase p+1 or later
                    if idx in alias_targets:
                        alias_phase = alias_phases.get(idx)
                        return alias_phase is not None and alias_phase < current_phase
                    # Unrecognized indices are conservatively treated as resolved
                    # (they may be predefined GAMS sets or external references)
                    return True

                if all(_index_resolved(idx) for idx in domain_indices):
                    set_phases[set_name_lower] = phase
                    unassigned_sets.remove(set_name_lower)
                    progressed = True

        phase += 1

    # If any symbols remain unassigned, there's a circular dependency
    if unassigned_sets or unassigned_aliases:
        raise ValueError(
            f"Circular dependency detected in sets/aliases; "
            f"cannot safely order GAMS emission. "
            f"Unassigned sets: {unassigned_sets}, aliases: {unassigned_aliases}"
        )

    return set_phases, alias_phases


def emit_original_sets(
    model_ir: ModelIR,
    precomputed_phases: tuple[dict[str, int], dict[str, int]] | None = None,
) -> list[str]:
    """Emit Sets blocks from original model, split by alias dependencies.

    Uses SetDef.members and SetDef.domain (Finding #3: actual IR fields).
    Sprint 17 Day 5: Now preserves subset relationships by emitting domain.
    Sprint 17 Day 10: Splits sets into phases to handle complex alias
    dependencies (GitHub Issue #621). Supports N phases dynamically.

    Emission order ensures all dependencies are declared before use:
    - Phase 1 sets: Sets with no alias dependencies
    - Phase 1 aliases (emitted by emit_original_aliases)
    - Phase 2 sets: Sets depending on phase 1 aliases
    - Phase 2 aliases (emitted by emit_original_aliases)
    - ... continues for all phases as needed

    Args:
        model_ir: Model IR containing set definitions
        precomputed_phases: Optional pre-computed (set_phases, alias_phases) tuple
            from _compute_set_alias_phases to avoid redundant computation.

    Returns:
        List of GAMS code strings, one per phase, indexed from 0.
        Index 0 = phase 1 sets, index 1 = phase 2 sets, etc.

    Example output for phase 1:
        Sets
            i /i1, i2, i3/
        ;
    """
    if not model_ir.sets:
        return []

    # Compute which sets go in each phase (or use pre-computed result)
    if precomputed_phases is not None:
        set_phases, _ = precomputed_phases
    else:
        set_phases, _ = _compute_set_alias_phases(model_ir)

    if not set_phases:
        return []

    # Determine number of phases (set_phases is guaranteed non-empty by early return above)
    max_phase = max(set_phases.values())

    # Partition sets into lists by phase, preserving original order
    phase_sets: list[list[tuple[str, SetDef]]] = [[] for _ in range(max_phase)]

    for set_name, set_def in model_ir.sets.items():
        set_name_lower = set_name.lower()
        phase = set_phases.get(set_name_lower, 1)
        phase_sets[phase - 1].append((set_name, set_def))

    def build_sets_block(sets_list: list[tuple[str, SetDef]]) -> str:
        if not sets_list:
            return ""
        lines: list[str] = ["Sets"]
        for set_name, set_def in sets_list:
            lines.append(f"    {_format_set_declaration(set_name, set_def)}")
        lines.append(";")
        return "\n".join(lines)

    return [build_sets_block(sets_list) for sets_list in phase_sets]


def emit_original_aliases(
    model_ir: ModelIR,
    precomputed_phases: tuple[dict[str, int], dict[str, int]] | None = None,
) -> list[str]:
    """Emit Alias declarations, split by target set dependencies.

    Uses AliasDef.target and .universe (Finding #3: actual IR fields).

    Sprint 17 Day 10: Splits aliases into phases matching the set phases:
    - Phase p aliases: Aliases targeting phase p sets (emitted after phase p sets)

    Args:
        model_ir: Model IR containing alias definitions
        precomputed_phases: Optional pre-computed (set_phases, alias_phases) tuple
            from _compute_set_alias_phases to avoid redundant computation.

    Returns:
        List of GAMS code strings, one per phase, indexed from 0.
        Index 0 = phase 1 aliases, index 1 = phase 2 aliases, etc.

    Example output:
        Alias(i, ip);
        Alias(j, jp);
    """
    if not model_ir.aliases:
        return []

    # Get phase assignments (or use pre-computed result)
    if precomputed_phases is not None:
        set_phases, alias_phases = precomputed_phases
    else:
        set_phases, alias_phases = _compute_set_alias_phases(model_ir)

    if not alias_phases:
        return []

    # Determine number of phases (use set_phases to include all phases)
    max_phase = max(
        max(set_phases.values()) if set_phases else 0,
        max(alias_phases.values()) if alias_phases else 0,
    )

    # Partition aliases into lists by phase
    phase_aliases: list[list[str]] = [[] for _ in range(max_phase)]

    for alias_name, alias_def in model_ir.aliases.items():
        alias_line = f"Alias({alias_def.target}, {alias_name});"
        alias_name_lower = alias_name.lower()
        phase = alias_phases.get(alias_name_lower, 1)
        phase_aliases[phase - 1].append(alias_line)

    return ["\n".join(aliases) if aliases else "" for aliases in phase_aliases]


def emit_original_parameters(model_ir: ModelIR) -> str:
    """Emit Parameters and Scalars with their data.

    Uses ParameterDef.domain and .values (Finding #3: actual IR fields).
    Scalars have empty domain () and values[()] = value.
    Multi-dimensional keys formatted as GAMS syntax: ("i1", "j2") → "i1.j2".

    Args:
        model_ir: Model IR containing parameter definitions

    Returns:
        GAMS Parameters and Scalars blocks as string

    Example output:
        Parameters
            c(i,j) /i1.j1 2.5, i1.j2 3.0, i2.j1 1.8/
            demand(j) /j1 100, j2 150/
        ;

        Scalars
            discount /0.95/
        ;
    """
    if not model_ir.params:
        return ""

    # Separate scalars (empty domain) from parameters
    scalars = {}
    parameters = {}

    for param_name, param_def in model_ir.params.items():
        # Use ParameterDef.domain to detect scalars (Finding #3)
        if len(param_def.domain) == 0:
            scalars[param_name] = param_def
        else:
            parameters[param_name] = param_def

    lines = []

    # Emit Parameters
    if parameters:
        lines.append("Parameters")
        for param_name, param_def in parameters.items():
            # Use ParameterDef.values (Finding #3)
            # Format tuple keys as GAMS syntax: ("i1", "j2") → "i1.j2"
            if param_def.values:
                data_parts = []
                for key_tuple, value in param_def.values.items():
                    # Convert tuple to GAMS index syntax (Finding #3)
                    key_str = ".".join(key_tuple)
                    data_parts.append(f"{key_str} {value}")

                data_str = ", ".join(data_parts)
                domain_str = ",".join(param_def.domain)
                lines.append(f"    {param_name}({domain_str}) /{data_str}/")
            else:
                # Parameter declared but no data
                domain_str = ",".join(param_def.domain)
                lines.append(f"    {param_name}({domain_str})")
        lines.append(";")

    # Emit Scalars (skip predefined GAMS constants and description-only entries)
    if scalars:
        # Filter out predefined constants and description-only entries
        # Description-only entries occur when the parser stores description strings
        # as scalar names (e.g., 'loss equation constant' instead of a proper identifier).
        # These contain spaces, quotes, or other invalid identifier characters.
        def is_valid_scalar_name(name: str) -> bool:
            """Check if scalar name is a valid GAMS identifier.

            Valid GAMS identifiers:
            - Start with a letter or underscore
            - Contain only letters, digits, and underscores
            Additionally, reject any whitespace, newlines, or GAMS delimiters.
            """
            if not name:
                return False
            # Reject any whitespace (including spaces, tabs, newlines) or quote characters
            if re.search(r"\s", name) or "'" in name or '"' in name:
                return False
            # Explicitly reject GAMS delimiters that could break syntax blocks
            if "/" in name or ";" in name:
                return False
            # Full-pattern check: first char letter or underscore, rest letters/digits/underscores
            if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
                return False
            return True

        user_scalars = {
            name: param_def
            for name, param_def in scalars.items()
            if name not in PREDEFINED_GAMS_CONSTANTS and is_valid_scalar_name(name)
        }
        if user_scalars:
            if lines:  # Add blank line if parameters were emitted
                lines.append("")
            lines.append("Scalars")
            for scalar_name, scalar_def in user_scalars.items():
                # Scalars have values[()] = value (Finding #3)
                value = scalar_def.values.get((), 0.0)
                lines.append(f"    {scalar_name} /{value}/")
            lines.append(";")

    return "\n".join(lines)


def emit_computed_parameter_assignments(model_ir: ModelIR) -> str:
    """Emit computed parameter assignment statements.

    Sprint 17 Day 4: Emit expressions stored in ParameterDef.expressions as
    GAMS assignment statements. These are computed parameters like:
        c(i,j) = f*d(i,j)/1000;
        gplus(c) = gibbs(c) + log(750*.07031);

    Args:
        model_ir: Model IR containing parameter definitions with expressions

    Returns:
        GAMS assignment statements as string

    Example output:
        c(i,j) = f * d(i,j) / 1000;
        gplus(c) = gibbs(c) + log(750 * 0.07031);
    """
    lines: list[str] = []

    for param_name, param_def in model_ir.params.items():
        # Skip predefined constants
        if param_name in PREDEFINED_GAMS_CONSTANTS:
            continue

        # Check if this parameter has expressions (computed values)
        if not param_def.expressions:
            continue

        # Emit each expression assignment
        for key_tuple, expr in param_def.expressions.items():
            # Convert expression to GAMS syntax
            expr_str = expr_to_gams(expr)

            # Format the LHS with indices
            if key_tuple:
                # Indexed parameter: c(i,j) = expr
                index_str = ",".join(key_tuple)
                lines.append(f"{param_name}({index_str}) = {expr_str};")
            else:
                # Scalar parameter: f = expr
                lines.append(f"{param_name} = {expr_str};")

    return "\n".join(lines)
