#!/usr/bin/env python3
r"""
Advanced dotenv loader with variable expansion, imports, conditions, typing and validation.

Features:
- ${VAR} or $VAR: reference to a variable defined in the .env file
- $PWD, $HOME, etc.: system environment variables
- ${VAR:-default}: default value if variable is not defined
- Single or double quoted values
- Comments with #
- Escaping with \$

Directives (compatible with other loaders as they start with #):
- #@import file.env [options]: import another file
  Options: prefix=PREFIX_ override=true mode=ifexist
- #@def VAR [options]: define variable constraints
  Options: type=str required=false default=value validate=^regex$ doc="description"
- #@if, #@ifdef, #@ifndef, #@else, #@endif: conditions
- #@mode [options]: change processing modes
  Options: override=true/false strict=true/false
"""

import json
import os
import re
import shlex
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple

__version__ = "0.1.0"


class DotenvError(Exception):
    """Base error for dotenv loader."""

    pass


class ValidationError(DotenvError):
    """Validation error."""

    pass


class RequiredVariableError(DotenvError):
    """Required variable missing."""

    pass


class TypeCastError(DotenvError):
    """Type conversion error."""

    pass


class VarDef:
    """Variable definition with constraints."""

    def __init__(
        self,
        name: str,
        var_type: str = "str",
        required: bool = False,
        default: Optional[str] = None,
        validate: Optional[str] = None,
        doc: Optional[str] = None,
        secret: bool = False,
        lazy: bool = False,
    ):
        self.name = name
        self.type = var_type
        self.required = required
        self.default = default
        self.validate = validate
        self.doc = doc
        self.secret = secret
        self.lazy = lazy


class DotenvLoader:
    """Loads and parses .env files with variable expansion."""

    # Pattern for variables: ${VAR}, $VAR, or ${VAR:-default}
    VAR_PATTERN = re.compile(
        r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?::-([^}]*))?\}"
        r"|"
        r"\$([a-zA-Z_][a-zA-Z0-9_]*)"
    )

    # Pattern for directives
    DIRECTIVE_PATTERN = re.compile(r"^#@(\S+)(?:\s+(.*))?$")

    # Pattern for options key=value or key="value with spaces"
    OPTION_PATTERN = re.compile(r'(\w+)=(?:"([^"]+)"|(\S+))')

    # Pattern for valid variable names
    VAR_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")

    # Pattern for condition operators (longer operators first for correct matching)
    _CONDITION_OP = re.compile(r"(==|!=|>=|<=|>|<)")

    # Maximum length for validation regex patterns (ReDoS protection)
    MAX_VALIDATE_PATTERN_LENGTH = 500

    # Pattern to detect common ReDoS-vulnerable regex (nested quantifiers)
    _REDOS_DETECT = re.compile(r"\([^)]*[+*][^)]*\)[+*?]")

    # Sentinel for escaped dollar signs during variable expansion
    _ESCAPED_DOLLAR = "\x00\x01DOLLAR\x01\x00"

    # Valid type names for #@def type= option
    VALID_TYPES = ("int", "float", "bool", "str", "json", "list")

    # Patterns for auto-detecting secret variables (case-insensitive)
    SECRET_PATTERNS = [
        "password",
        "passwd",
        "pwd",
        "secret",
        "token",
        "key",
        "api_key",
        "apikey",
        "access_key",
        "auth",
        "credential",
        "private",
    ]

    def __init__(self, compile_vars: Optional[Dict[str, str]] = None):
        self.values: Dict[str, str] = {}
        self.typed_values: Dict[str, Any] = {}
        self._imported_files: Set[str] = set()
        self._base_dir: Path = Path.cwd()

        # Variable definitions
        self._definitions: Dict[str, VarDef] = {}

        # Processing modes (controlled by #@mode directive)
        self._strict_mode: bool = False
        self._override_mode: bool = False

        # Condition state
        self._condition_stack: List[Tuple[bool, bool]] = []

        # Verbose logging
        self._verbose: int = 0

        # Compilation variables (used for directives resolution only)
        self._compile_vars: Dict[str, str] = compile_vars or {}

        # Lazy variable support for fuse command
        self._defined_vars: Set[str] = set()  # Variables defined in files
        self._raw_values: Dict[str, str] = {}  # Raw values before expansion

    def _is_secret(self, var_name: str) -> bool:
        """Check if a variable is a secret (explicit or auto-detected)."""
        # Check explicit marking in definition
        if var_name in self._definitions and self._definitions[var_name].secret:
            return True

        # Auto-detect based on variable name patterns
        var_name_lower = var_name.lower()
        return any(pattern in var_name_lower for pattern in self.SECRET_PATTERNS)

    @staticmethod
    def _parse_bool(value: str, location: str = "") -> bool:
        """Parse a boolean option value.

        Returns True for 'true', '1', 'yes'.
        Returns False for 'false', '0', 'no'.
        Raises DotenvError for other values.
        """
        v = value.lower()
        if v in ("true", "1", "yes"):
            return True
        if v in ("false", "0", "no"):
            return False
        raise DotenvError(
            f"{location}: invalid boolean value '{value}' (use true/false)"
        )

    def _log(self, level: int, tag: str, message: str) -> None:
        """Log a message to stderr if verbose level is sufficient."""
        if self._verbose >= level:
            print(f"[{tag}] {message}", file=sys.stderr)

    def _mask_value(self, var_name: str, value: str) -> str:
        """Mask the value if it's a secret."""
        if self._is_secret(var_name):
            return "***REDACTED***"
        return value

    def _should_keep_lazy(self, var_name: str) -> bool:
        """Determine if a variable reference should remain lazy (unexpanded) during fuse.

        A variable is kept lazy if:
        1. It has explicit lazy=true in its #@def definition, OR
        2. It's not defined in any .env-fuse file AND not a compile var (auto-detection)

        A variable is NOT kept lazy if:
        1. It has explicit lazy=false in its definition
        2. It's defined in the .env-fuse files
        3. It's a compile variable (--var)
        """
        # Explicitly lazy=true via #@def
        if var_name in self._definitions and self._definitions[var_name].lazy:
            return True

        # Explicitly defined (either in file or via #@def without lazy=true)
        # → resolve normally
        if var_name in self._defined_vars:
            return False

        # Compile variables are always resolved
        if var_name in self._compile_vars:
            return False

        # Auto-detection: variable not defined anywhere → keep lazy
        return True

    def load(
        self,
        filepath: str = ".env",
        override: bool = False,
        apply_types: bool = True,
        verbose: int = 0,
    ) -> Dict[str, Any]:
        """
        Load a .env file and return the variables.

        Args:
            filepath: Path to the .env file
            override: If True, overwrite existing environment variables
            apply_types: If True, apply type conversions. Variables with a
                ``#@def type=`` use that type; others are auto-cast (ints,
                floats, bools detected automatically). Pass False to keep
                all values as strings.
            verbose: Verbosity level (0=off, 1=names, 2=values with masked secrets)

        Returns:
            Dictionary of loaded variables (typed if apply_types=True, strings otherwise)
        """
        path = Path(filepath).resolve()
        if not path.exists():
            raise FileNotFoundError(f"File not found: {filepath}")

        # Reset state
        self.values = {}
        self.typed_values = {}
        self._imported_files = set()
        self._base_dir = path.parent
        self._verbose = verbose
        self._definitions = {}
        self._strict_mode = False
        self._override_mode = False
        self._condition_stack = []
        self._defined_vars = set()
        self._raw_values = {}

        # Load file
        self._log(1, "LOAD", str(filepath))
        self._load_file(path)

        # Check for unclosed conditions
        if self._condition_stack:
            raise DotenvError("Unclosed conditional block (#@endif missing)")

        # Apply defaults from definitions
        self._apply_defaults()

        # Check required variables
        self._check_required()

        # Validate variables
        self._validate_all()

        # Apply types
        if apply_types:
            self._apply_types()

        # Apply to environment and log with override information
        for key, value in self.values.items():
            if override or key not in os.environ:
                os.environ[key] = value
            if self._verbose >= 1:
                secret_marker = " (secret)" if self._is_secret(key) else ""
                if self._verbose >= 2:
                    masked_value = self._mask_value(key, value)
                    self._log(2, "VAR", f"{key} = {masked_value}{secret_marker}")
                else:
                    self._log(1, "VAR", f"{key}{secret_marker}")

        # Summary
        if self._verbose >= 1:
            count = len(self.values)
            files_count = len(self._imported_files) + 1
            self._log(
                1, "SUCCESS", f"Loaded {count} variables from {files_count} file(s)"
            )

        return self.typed_values if apply_types else self.values.copy()

    def get_docs(self) -> Dict[str, str]:
        """Return variable documentation."""
        return {name: defn.doc for name, defn in self._definitions.items() if defn.doc}

    def generate_template(self) -> str:
        """Generate a template .env file from definitions."""
        lines = ["# Environment variables template", "# Generated by dotenv-loader", ""]

        for name, defn in self._definitions.items():
            # Add documentation as comment
            if defn.doc:
                lines.append(f"# {defn.doc}")

            # Add type and constraints info
            info_parts = []
            if defn.type != "str":
                info_parts.append(f"type: {defn.type}")
            if defn.required:
                info_parts.append("required")
            if defn.validate:
                info_parts.append(f"validate: {defn.validate}")

            if info_parts:
                lines.append(f"# [{', '.join(info_parts)}]")

            # Add variable with default or placeholder
            if defn.default is not None:
                lines.append(f"{name}={defn.default}")
            else:
                lines.append(f"{name}=")

            lines.append("")

        # Add variables without definitions
        for name, value in self.values.items():
            if name not in self._definitions:
                lines.append(f"{name}={value}")

        return "\n".join(lines)

    def _compile_lines(self) -> List[str]:
        """Generate compiled .env lines with lazy variable preservation.

        Re-expands raw values with for_compile=True to preserve lazy ${VAR}
        references while resolving defined variables.
        """
        lines = []
        for key in self.values.keys():
            raw_value = self._raw_values.get(key, self.values[key])
            compiled_value = self._expand_variables(raw_value, for_compile=True)
            escaped_value = compiled_value.replace("\\", "\\\\").replace('"', '\\"')
            lines.append(f'{key}="{escaped_value}"')
        return lines

    def compile_to_file(self, output_path: str, strip_comments: bool = False) -> None:
        """
        Compile loaded variables to a standard .env file.

        This writes all variables in self.values to a .env file without
        any directives (#@import, #@if, etc.), creating a "flattened"
        version compatible with all dotenv loaders.

        Variables with lazy=true or referencing undefined external variables
        will keep their ${VAR} references intact for runtime resolution.

        Args:
            output_path: Path to the output .env file
            strip_comments: If True, don't include any comments

        Note: Compile variables (--var) are NOT written to the output file.
        """
        lines = []

        if not strip_comments:
            lines.append("# Generated by dotenv-fusion fuse")
            lines.append(
                "# This file is auto-generated - edit the source .env-fuse file instead"
            )
            lines.append("")

        lines.extend(self._compile_lines())

        with open(output_path, "w") as f:
            f.write("\n".join(lines))
            f.write("\n")

        self._log(1, "FUSE", f"Compiled to {output_path}")

    def _load_file(
        self,
        path: Path,
        line_prefix: str = "",
        var_prefix: str = "",
        override: bool = False,
    ) -> None:
        """Load a .env file (called recursively for imports)."""
        resolved = path.resolve()

        # Check for circular imports
        if str(resolved) in self._imported_files:
            return

        self._imported_files.add(str(resolved))

        if not resolved.exists():
            raise FileNotFoundError(f"Imported file not found: {path}")

        with open(resolved, "r", encoding="utf-8") as f:
            for line_num, line in enumerate(f, 1):
                location = f"{line_prefix}{resolved.name}:{line_num}"
                self._parse_line(line, location, resolved.parent, var_prefix, override)

    def _is_active(self) -> bool:
        """Check if current code is in an active conditional block."""
        if not self._condition_stack:
            return True
        return self._condition_stack[-1][0]

    def _parse_line(
        self,
        line: str,
        location: str,
        current_dir: Path,
        var_prefix: str = "",
        override: bool = False,
    ) -> None:
        """Parse a line from the .env file."""
        line = line.strip()

        if not line:
            return

        # Check if it's a directive #@xxx
        directive_match = self.DIRECTIVE_PATTERN.match(line)
        if directive_match:
            directive = directive_match.group(1)
            args = directive_match.group(2) or ""
            self._handle_directive(
                directive, args.strip(), location, current_dir, var_prefix, override
            )
            return

        # Ignore normal comments
        if line.startswith("#"):
            return

        # Ignore if in an inactive conditional block
        if not self._is_active():
            return

        # Look for = separator
        if "=" not in line:
            return

        # Split key and value
        key, _, value = line.partition("=")
        key = key.strip()
        value = value.strip()

        # Validate variable name
        if not self.VAR_NAME_PATTERN.match(key):
            raise ValueError(f"{location}: invalid variable name '{key}'")

        # Apply prefix if present
        if var_prefix:
            key = var_prefix + key

        # Track that this variable is defined in files (for lazy detection)
        self._defined_vars.add(key)

        # Parse raw value (after quote processing, before expansion)
        raw_value = self._parse_value(value, location, expand=False)

        # Parse the fully expanded value
        expanded_value = self._parse_value(value, location, expand=True)

        # Store: override mode (from #@mode or import option) or first-wins
        if self._override_mode or override or key not in self.values:
            self.values[key] = expanded_value
            self._raw_values[key] = raw_value

    def _parse_options(self, options_str: str) -> Dict[str, str]:
        """Parse options key=value or key=\"value with spaces\"."""
        options = {}
        for match in self.OPTION_PATTERN.finditer(options_str):
            key = match.group(1).lower()
            value = match.group(2) if match.group(2) is not None else match.group(3)
            options[key] = value
        return options

    def _handle_directive(
        self,
        directive: str,
        args: str,
        location: str,
        current_dir: Path,
        var_prefix: str = "",
        override: bool = False,
    ) -> None:
        """Handle #@xxx directives."""

        # Condition directives (always processed)
        if directive == "if":
            self._handle_if(args, location)
            return
        elif directive == "ifdef":
            self._handle_ifdef(args, location, negate=False)
            return
        elif directive == "ifndef":
            self._handle_ifdef(args, location, negate=True)
            return
        elif directive == "else":
            self._handle_else(location)
            return
        elif directive == "endif":
            self._handle_endif(location)
            return

        # Other directives only executed if active
        if not self._is_active():
            return

        if directive == "import":
            self._handle_import(args, location, current_dir)
        elif directive == "def":
            self._handle_def(args, location, var_prefix)
        elif directive == "mode":
            self._handle_mode(args, location)
        else:
            # Unknown directive: silently ignore (future compatibility)
            pass

    def _handle_mode(self, args: str, location: str) -> None:
        """Handle #@mode override=true/false strict=true/false."""
        options = self._parse_options(args)

        if "override" in options:
            self._override_mode = self._parse_bool(options["override"], location)
            self._log(1, "MODE", f"override={self._override_mode}")

        if "strict" in options:
            self._strict_mode = self._parse_bool(options["strict"], location)
            self._log(1, "MODE", f"strict={self._strict_mode}")

    # --- Import handling ---

    def _handle_import(self, args: str, location: str, current_dir: Path) -> None:
        """Handle #@import with key=value options."""
        args = args.strip()

        # Parse path (first argument)
        match = re.match(r'^(?:"([^"]+)"|\'([^\']+)\'|(\S+))\s*(.*)?$', args)
        if not match:
            raise DotenvError(f"{location}: invalid import syntax")

        import_path = match.group(1) or match.group(2) or match.group(3)
        options_str = match.group(4) or ""

        # Parse options
        options = self._parse_options(options_str)

        prefix = options.get("prefix", "")
        override = self._parse_bool(options.get("override", "false"), location)
        mode = options.get("mode", "required").lower()
        if_exist = mode == "ifexist"

        # Expand variables in path
        import_path = self._expand_variables(import_path)

        # Resolve path
        path = Path(import_path)
        if not path.is_absolute():
            path = current_dir / path

        try:
            # Log import
            prefix_info = f" (with prefix {prefix})" if prefix else ""
            self._log(1, "IMPORT", f"{path}{prefix_info}")
            self._load_file(
                path, f"{location} -> ", var_prefix=prefix, override=override
            )
        except FileNotFoundError as e:
            if not if_exist:
                raise FileNotFoundError(f"{location}: {e}")

    # --- Definition handling ---

    def _handle_def(self, args: str, location: str, var_prefix: str = "") -> None:
        """Handle #@def VAR [options]."""
        args = args.strip()

        # First word = variable name
        match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*(.*)?$", args)
        if not match:
            raise DotenvError(
                f"{location}: invalid #@def syntax (usage: #@def VAR [options])"
            )

        var_name = match.group(1)
        options_str = match.group(2) or ""

        # Apply prefix
        if var_prefix:
            var_name = var_prefix + var_name

        # Parse options
        options = self._parse_options(options_str)

        var_type = options.get("type", "str").lower()
        if var_type not in self.VALID_TYPES:
            raise DotenvError(
                f"{location}: unknown type '{var_type}' (valid: {', '.join(self.VALID_TYPES)})"
            )

        required = self._parse_bool(options.get("required", "false"), location)
        default = options.get("default")
        validate = options.get("validate")
        doc = options.get("doc")
        secret = self._parse_bool(options.get("secret", "false"), location)
        lazy = self._parse_bool(options.get("lazy", "false"), location)

        # Validate regex if present
        if validate:
            # ReDoS protection: limit pattern length
            if len(validate) > self.MAX_VALIDATE_PATTERN_LENGTH:
                raise DotenvError(
                    f"{location}: validate pattern too long "
                    f"({len(validate)} chars, max {self.MAX_VALIDATE_PATTERN_LENGTH})"
                )
            # ReDoS protection: detect nested quantifiers
            if self._REDOS_DETECT.search(validate):
                raise DotenvError(
                    f"{location}: validate pattern rejected "
                    f"(nested quantifiers detected, potential ReDoS)"
                )
            try:
                re.compile(validate)
            except re.error as e:
                raise DotenvError(f"{location}: invalid regex '{validate}': {e}")

        # Store definition
        self._definitions[var_name] = VarDef(
            name=var_name,
            var_type=var_type,
            required=required,
            default=default,
            validate=validate,
            doc=doc,
            secret=secret,
            lazy=lazy,
        )

    def _apply_defaults(self) -> None:
        """Apply default values from definitions."""
        for name, defn in self._definitions.items():
            if defn.default is not None and name not in self.values:
                self.values[name] = defn.default

    def _check_required(self) -> None:
        """Check that all required variables are defined."""
        missing = []
        for name, defn in self._definitions.items():
            if defn.required and name not in self.values and name not in os.environ:
                missing.append(name)

        if missing:
            raise RequiredVariableError(
                f"Required variables missing: {', '.join(missing)}"
            )

    def _validate_all(self) -> None:
        """Validate all variables with their patterns."""
        for name, defn in self._definitions.items():
            if defn.validate and name in self.values:
                value = self.values[name]
                if not re.match(defn.validate, value):
                    raise ValidationError(
                        f"Variable '{name}' invalid: '{value}' does not match '{defn.validate}'"
                    )

    def _apply_types(self) -> None:
        """Apply type conversions to variables."""
        for key, value in self.values.items():
            defn = self._definitions.get(key)

            if defn is None:
                # Auto-detection
                self.typed_values[key] = self._auto_cast(value)
            else:
                try:
                    self.typed_values[key] = self._cast_value(value, defn.type)
                except (ValueError, json.JSONDecodeError) as e:
                    raise TypeCastError(
                        f"Cannot convert {key}='{value}' to {defn.type}: {e}"
                    )

    def _cast_value(self, value: str, type_name: str) -> Any:
        """Convert a value to the specified type."""
        if type_name == "int":
            return int(value)
        elif type_name == "float":
            return float(value)
        elif type_name == "bool":
            return value.lower() in ("true", "1", "yes", "on")
        elif type_name == "json":
            return json.loads(value)
        elif type_name == "list":
            sep = ";" if ";" in value else ","
            return [item.strip() for item in value.split(sep) if item.strip()]
        else:
            return value

    def _auto_cast(self, value: str) -> Any:
        """Attempt to automatically detect the type."""
        # Booleans
        if value.lower() in ("true", "false"):
            return value.lower() == "true"

        # Integers
        if re.match(r"^-?\d+$", value):
            try:
                return int(value)
            except ValueError:
                pass

        # Floats
        if re.match(r"^-?\d+\.\d+$", value):
            try:
                return float(value)
            except ValueError:
                pass

        # Default: string
        return value

    # --- Condition handling ---

    def _handle_if(self, args: str, location: str) -> None:
        """Handle #@if ${VAR} == value or #@if ${VAR}."""
        # Don't evaluate if parent is inactive
        if not self._is_active():
            self._condition_stack.append((False, False))
            return

        # Expand variables
        expanded = self._expand_variables(args.strip())

        # Evaluate
        result = self._eval_condition(expanded, location)
        self._condition_stack.append((result, result))

    def _eval_condition(self, expr: str, location: str) -> bool:
        """Evaluate a conditional expression."""
        expr = expr.strip()

        # Find the first (leftmost) operator in the expression
        op_match = self._CONDITION_OP.search(expr)
        if op_match:
            op = op_match.group(1)
            left = expr[: op_match.start()].strip()
            right = expr[op_match.end() :].strip()

            # Try numeric comparison
            try:
                left_num = float(left)
                right_num = float(right)
                ops = {"==": left_num == right_num, "!=": left_num != right_num,
                       ">=": left_num >= right_num, "<=": left_num <= right_num,
                       ">": left_num > right_num, "<": left_num < right_num}
                return ops[op]
            except ValueError:
                pass

            # String comparison
            ops = {"==": left == right, "!=": left != right,
                   ">=": left >= right, "<=": left <= right,
                   ">": left > right, "<": left < right}
            return ops[op]

        # Simple value: truthy if not empty and not "false"/"0"
        return bool(expr) and expr.lower() not in ("false", "0", "no", "off")

    def _handle_ifdef(self, args: str, location: str, negate: bool = False) -> None:
        """Handle #@ifdef VAR and #@ifndef VAR."""
        if not self._is_active():
            self._condition_stack.append((False, False))
            return

        var_name = args.strip()
        is_defined = var_name in self.values or var_name in os.environ

        result = not is_defined if negate else is_defined
        self._condition_stack.append((result, result))

    def _handle_else(self, location: str) -> None:
        """Handle #@else."""
        if not self._condition_stack:
            raise DotenvError(f"{location}: #@else without matching #@if")

        is_active, has_matched = self._condition_stack.pop()

        # Check if parent is active
        parent_active = self._is_active() if self._condition_stack else True

        # else is active if: parent active AND no branch has matched yet
        new_active = parent_active and not has_matched
        self._condition_stack.append((new_active, True))

    def _handle_endif(self, location: str) -> None:
        """Handle #@endif."""
        if not self._condition_stack:
            raise DotenvError(f"{location}: #@endif without matching #@if")
        self._condition_stack.pop()

    # --- Value parsing ---

    def _parse_value(self, value: str, location: str, expand: bool = True) -> str:
        """Parse value handling quotes and optionally expansion.

        Args:
            value: Raw value from .env file
            location: File location for error messages
            expand: If True, expand variables. If False, return raw value after quote processing.
        """
        if not value:
            return ""

        # Double quotes: variable expansion (if expand=True)
        if value.startswith('"') and value.endswith('"') and len(value) >= 2:
            value = value[1:-1]
            value = self._unescape(value, double_quoted=True)
            if expand:
                return self._expand_variables(value, location)
            return value

        # Single quotes: literal value (no expansion)
        if value.startswith("'") and value.endswith("'") and len(value) >= 2:
            return value[1:-1]

        # No quotes: variable expansion, remove inline comments
        if "#" in value:
            result = []
            i = 0
            while i < len(value):
                if value[i] == "\\" and i + 1 < len(value):
                    result.append(value[i : i + 2])
                    i += 2
                elif value[i] == "#":
                    break
                else:
                    result.append(value[i])
                    i += 1
            value = "".join(result).rstrip()

        value = self._unescape(value, double_quoted=False)
        if expand:
            return self._expand_variables(value, location)
        return value

    def _unescape(self, value: str, double_quoted: bool) -> str:
        """Handle escape sequences."""
        if double_quoted:
            value = value.replace("\\n", "\n")
            value = value.replace("\\t", "\t")
            value = value.replace('\\"', '"')

        value = value.replace("\\$", self._ESCAPED_DOLLAR)
        value = value.replace("\\\\", "\\")

        return value

    def _expand_variables(
        self, value: str, location: str = "", for_compile: bool = False
    ) -> str:
        """Replace variable references with their values.

        Args:
            value: String containing variable references
            location: File location for error messages
            for_compile: If True, preserve lazy variable references (for fuse command)
        """

        def replacer(match):
            if match.group(1) is not None:
                var_name = match.group(1)
                default = match.group(2)
            else:
                var_name = match.group(3)
                default = None

            # For compile mode: check if this variable should remain lazy
            if for_compile and self._should_keep_lazy(var_name):
                return match.group(0)  # Return original ${VAR} or $VAR

            # 1. Prioritize compile_vars (highest priority)
            if var_name in self._compile_vars:
                return self._compile_vars[var_name]

            # 2. Look in parsed values
            if var_name in self.values:
                return self.values[var_name]

            # 3. Look in environment
            env_value = os.environ.get(var_name)
            if env_value is not None:
                return env_value

            # 4. Default value
            if default is not None:
                return default

            # Strict mode: error if variable not found
            if self._strict_mode:
                raise DotenvError(
                    f"{location}: undefined variable '${var_name}' (strict mode)"
                )

            return ""

        result = self.VAR_PATTERN.sub(replacer, value)
        result = result.replace(self._ESCAPED_DOLLAR, "$")

        return result


# --- Utility functions ---


def load_dotenv(
    filepath: str = ".env", override: bool = False, apply_types: bool = True
) -> Dict[str, Any]:
    """
    Load a .env file.

    Args:
        filepath: Path to the .env file
        override: If True, overwrite existing environment variables
        apply_types: If True, apply automatic type conversions

    Returns:
        Dictionary of loaded variables
    """
    loader = DotenvLoader()
    return loader.load(filepath, override, apply_types)


def get_env(key: str, default: Optional[str] = None) -> Optional[str]:
    """Get an environment variable."""
    return os.environ.get(key, default)


def format_shell_exports(values: Dict[str, str], format: str = "bash") -> str:
    """Format values as shell export commands or JSON.

    Security: Uses shlex.quote() for proper shell escaping to prevent command injection.

    Args:
        values: Dictionary of variable names to values
        format: Output format (bash, zsh, fish, json)
    """
    if format == "json":
        return json.dumps(values, indent=2)

    lines = []
    for key, value in values.items():
        if not DotenvLoader.VAR_NAME_PATTERN.match(key):
            continue
        quoted_value = shlex.quote(value)
        if format == "fish":
            lines.append(f"set -gx {key} {quoted_value}")
        else:
            lines.append(f"export {key}={quoted_value}")

    return "\n".join(lines)


def export_shell(
    filepath: str = ".env",
    format: str = "bash",
    override: bool = False,
    verbose: int = 0,
) -> str:
    """Generate export commands for the shell or JSON output.

    Args:
        filepath: Path to .env file
        format: Output format (bash, zsh, fish, json)
        override: If True, override existing environment variables
        verbose: Verbose level (0=off, 1=names only, 2=values with masked secrets)
    """
    loader = DotenvLoader()
    loader.load(filepath, override=override, apply_types=False, verbose=verbose)
    return format_shell_exports(loader.values, format)


def parse_compile_vars(var_args: Optional[List[str]]) -> Dict[str, str]:
    """
    Parse --var KEY=VALUE arguments into a dictionary.

    Args:
        var_args: List of strings in format "KEY=VALUE"

    Returns:
        Dict[str, str]: Dictionary of compile variables

    Raises:
        ValueError: If a --var argument is not in KEY=VALUE format
    """
    compile_vars = {}
    if var_args:
        for var in var_args:
            if "=" not in var:
                raise ValueError(f"Invalid --var format: {var} (expected KEY=VALUE)")
            key, value = var.split("=", 1)
            compile_vars[key] = value
    return compile_vars


def resolve_env_file(specified_file: Optional[str], walk: int = 0) -> str:
    """
    Resolve which .env file to load with fallback to .env-fuse.

    Priority:
    1. Specified file (if exists)
    2. Specified file with -fuse variant (if original doesn't exist)
    3. .env (if no file specified)
    4. .env-fuse (fallback if .env doesn't exist)
    5. .env-fuse.local (fallback if .env-fuse doesn't exist)

    When walk > 0, after failing in the current directory, search up to
    `walk` parent directories using the same fallback chain.

    Args:
        specified_file: Optional file path specified by user
        walk: Number of parent directories to search (0 = CWD only)

    Returns:
        str: Path to the file to load (absolute when found in a parent)

    Raises:
        FileNotFoundError: If no valid file is found
    """

    def _try_specified(directory: Path) -> Optional[str]:
        """Try specified file (and fuse variant) in a directory."""
        candidate = directory / specified_file
        if candidate.exists():
            return str(candidate)
        if ".env" in specified_file and "-fuse" not in specified_file:
            fuse_variant = directory / specified_file.replace(".env", ".env-fuse")
            if fuse_variant.exists():
                return str(fuse_variant)
        return None

    def _try_defaults(directory: Path) -> Optional[str]:
        """Try default fallback chain in a directory."""
        for name in (".env", ".env-fuse", ".env-fuse.local"):
            if (directory / name).exists():
                return str(directory / name)
        return None

    # 1. Try current directory first
    if specified_file:
        if os.path.exists(specified_file):
            return specified_file
        if ".env" in specified_file and "-fuse" not in specified_file:
            fuse_variant = specified_file.replace(".env", ".env-fuse")
            if os.path.exists(fuse_variant):
                return fuse_variant
    else:
        for name in (".env", ".env-fuse", ".env-fuse.local"):
            if os.path.exists(name):
                return name

    # 2. Walk parent directories if enabled
    if walk > 0:
        current = Path.cwd()
        for _ in range(walk):
            parent = current.parent
            if parent == current:
                break  # Filesystem root reached
            if specified_file:
                result = _try_specified(parent)
            else:
                result = _try_defaults(parent)
            if result:
                return result
            current = parent

    # 3. Error with hint about --walk-up
    if specified_file:
        raise FileNotFoundError(f"File not found: {specified_file}")
    raise FileNotFoundError(
        "No .env or .env-fuse file found (try --walk to search parent directories)"
    )


def main():
    """CLI entry point for dotenv-fusion command."""
    import argparse

    parser = argparse.ArgumentParser(
        prog="dotenv-fusion",
        description="Advanced .env loader for Python with variable expansion, imports, conditions, typing and validation",
        epilog="""
Examples:
  # Shell integration (bash/zsh)
  eval "$(dotenv-fusion load)"
  eval "$(dotenv-fusion load -f .env.production)"

  # Shell integration (fish)
  eval (dotenv-fusion load -o fish)

  # List all variables
  dotenv-fusion list

  # Show documentation
  dotenv-fusion docs

  # Generate template
  dotenv-fusion template > .env.example
        """,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    # Subparsers for commands
    subparsers = parser.add_subparsers(dest="command", help="Command to execute")

    # 'load' command - Main shell integration command
    load_parser = subparsers.add_parser(
        "load",
        help="Load .env and export variables for shell",
        description="Load environment variables and generate shell export commands",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    load_parser.add_argument(
        "-f",
        "--file",
        default=None,
        help="File to load (default: auto-detect .env or .env-fuse)",
    )
    load_parser.add_argument(
        "-o",
        "--format",
        choices=["bash", "zsh", "fish", "json"],
        default="bash",
        help="Output format (shell type or json)",
    )
    load_parser.add_argument(
        "--override",
        action="store_true",
        help="Override existing environment variables",
    )
    load_parser.add_argument(
        "--var",
        "-V",
        action="append",
        metavar="KEY=VALUE",
        help="Compilation variable (e.g., --var ENV=prod)",
    )
    load_parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        default=0,
        help="Verbose mode: -v (logic and names), -vv (values with secrets masked)",
    )
    load_parser.add_argument(
        "--walk",
        action="store_true",
        help="Search parent directories if file not found locally (default: 1 level, configure with DOTENV_FUSION_WALK_DEPTH)",
    )
    load_parser.add_argument(
        "--walk-up",
        type=int,
        metavar="N",
        help="Search up to N parent directories (implies --walk)",
    )
    load_parser.add_argument("-d", "--debug", action="store_true", help="Show values")
    load_parser.add_argument(
        "-t", "--typed", action="store_true", help="Show with types"
    )

    # 'list' command - List variable names
    list_parser = subparsers.add_parser(
        "list",
        help="List all variable names",
        description="List all environment variable names defined in .env file",
    )
    list_parser.add_argument(
        "-f", "--file", default=".env", help="File to load (default: .env)"
    )

    # 'docs' command - Show variable documentation
    docs_parser = subparsers.add_parser(
        "docs",
        help="Show variable documentation",
        description="Show documentation for variables defined with #@def directives",
    )
    docs_parser.add_argument(
        "-f", "--file", default=".env", help="File to load (default: .env)"
    )

    # 'template' command - Generate template file
    template_parser = subparsers.add_parser(
        "template",
        help="Generate template .env file",
        description="Generate a template .env file from #@def directives",
    )
    template_parser.add_argument(
        "-f", "--file", default=".env", help="File to load (default: .env)"
    )

    # 'fuse' command - Compile .env-fuse to .env
    fuse_parser = subparsers.add_parser(
        "fuse",
        help="Compile .env-fuse to .env (flatten directives)",
        description="Compile .env-fuse file to standard .env format by resolving all directives",
        epilog="""
Examples:
  # Basic compilation
  dotenv-fusion fuse

  # With compilation variables
  dotenv-fusion fuse --var ENV=prod --var REGION=eu

  # Compile and load in one command
  dotenv-fusion fuse --load --var ENV=prod

  # Custom source and target
  dotenv-fusion fuse -f .env-fuse.prod -o .env.production

  # Output to stdout
  dotenv-fusion fuse --stdout
        """,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    fuse_parser.add_argument(
        "-f",
        "--file",
        default=".env-fuse",
        help="Source file to compile (default: .env-fuse)",
    )
    fuse_parser.add_argument(
        "-o", "--output", help="Target file (default: source without -fuse suffix)"
    )
    fuse_parser.add_argument(
        "--var",
        "-V",
        action="append",
        metavar="KEY=VALUE",
        help="Compilation variable (e.g., --var ENV=prod)",
    )
    fuse_parser.add_argument(
        "--load", action="store_true", help="Load variables after compiling"
    )
    fuse_parser.add_argument(
        "--stdout", action="store_true", help="Print to stdout instead of file"
    )
    fuse_parser.add_argument(
        "--strip-comments",
        action="store_true",
        help="Remove header comments from output",
    )
    fuse_parser.add_argument(
        "--override",
        action="store_true",
        help="Override existing environment variables (with --load)",
    )
    fuse_parser.add_argument(
        "--walk",
        action="store_true",
        help="Search parent directories if file not found locally (default: 1 level, configure with DOTENV_FUSION_WALK_DEPTH)",
    )
    fuse_parser.add_argument(
        "--walk-up",
        type=int,
        metavar="N",
        help="Search up to N parent directories (implies --walk)",
    )
    fuse_parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        default=0,
        help="Verbose mode: -v (logic and names), -vv (values with secrets masked)",
    )

    args = parser.parse_args()

    # Default to showing help if no command specified
    if args.command is None:
        parser.print_help()
        sys.exit(1)

    try:
        if args.command == "load":
            # Compute walk depth
            walk_depth = 0
            if args.walk_up is not None:
                walk_depth = args.walk_up
            elif args.walk:
                walk_depth = int(os.environ.get("DOTENV_FUSION_WALK_DEPTH", "1"))

            # Resolve file with fallback to .env-fuse
            file_to_load = resolve_env_file(args.file, walk=walk_depth)

            # Parse compilation variables
            compile_vars = parse_compile_vars(args.var)

            # Create loader with compile vars
            loader = DotenvLoader(compile_vars=compile_vars)

            # Generate shell export commands
            if args.debug or args.typed:
                values = loader.load(
                    file_to_load, apply_types=args.typed, verbose=args.verbose
                )
                for key, value in values.items():
                    if args.typed:
                        print(f"{key}={value!r} ({type(value).__name__})")
                    else:
                        print(f"{key}={value}")
            else:
                loader.load(
                    file_to_load,
                    override=args.override,
                    apply_types=False,
                    verbose=args.verbose,
                )
                print(format_shell_exports(loader.values, args.format))

        elif args.command == "list":
            # List variable names
            loader = DotenvLoader()
            loader.load(args.file, apply_types=False)
            for key in loader.values:
                print(key)

        elif args.command == "docs":
            # Show variable documentation
            loader = DotenvLoader()
            loader.load(args.file, apply_types=False)
            docs = loader.get_docs()
            if docs:
                for name, doc in docs.items():
                    defn = loader._definitions[name]
                    req = " [required]" if defn.required else ""
                    default = f" (default: {defn.default})" if defn.default else ""
                    print(f"{name} ({defn.type}){req}{default}: {doc}")
            else:
                print("No documentation defined.")

        elif args.command == "template":
            # Generate template
            loader = DotenvLoader()
            loader.load(args.file, apply_types=False)
            print(loader.generate_template())

        elif args.command == "fuse":
            # Compute walk depth
            walk_depth = 0
            if args.walk_up is not None:
                walk_depth = args.walk_up
            elif args.walk:
                walk_depth = int(os.environ.get("DOTENV_FUSION_WALK_DEPTH", "1"))

            # Compile .env-fuse to .env
            source_file = resolve_env_file(args.file, walk=walk_depth)

            # Determine output file
            if args.output:
                output_file = args.output
            elif args.stdout:
                output_file = None  # Will print to stdout
            else:
                # Default: remove -fuse from filename
                if "-fuse" in source_file:
                    output_file = source_file.replace("-fuse", "")
                else:
                    # If source doesn't have -fuse, add .compiled
                    output_file = source_file + ".compiled"

            # Parse compilation variables
            compile_vars = parse_compile_vars(args.var)

            # Create loader with compile vars
            loader = DotenvLoader(compile_vars=compile_vars)
            loader.load(source_file, apply_types=False, verbose=args.verbose)

            # Output
            if args.stdout:
                # Print compiled lines to stdout
                for line in loader._compile_lines():
                    print(line)
            else:
                # Write to file
                loader.compile_to_file(output_file, strip_comments=args.strip_comments)
                if args.verbose >= 1:
                    print(f"✓ Compiled {source_file} → {output_file}", file=sys.stderr)

            # Load if requested
            if args.load:
                # Apply to environment
                for key, value in loader.values.items():
                    if args.override or key not in os.environ:
                        os.environ[key] = value

    except FileNotFoundError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    except (DotenvError, ValueError) as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
