"""Tool preset resolver - pure logic for resolving tool preset names.

This module provides pure functions for resolving tool preset names to
structured tool configurations. No I/O - all operations are in-memory lookups.

Contracts:
    - resolve_tool_presets: Resolve preset names to ResolvedTool list
    - parse_comma_separated: Parse comma-separated names like "fs,shell"

>>> from typing import cast
>>> presets = {
...     "fs": ToolPresetInput(command="mcp-filesystem", args=["--root", "/tmp"]),
...     "shell": ToolPresetInput(command="mcp-shell", env={"SHELL": "/bin/bash"}),
... }
>>> result = resolve_tool_presets(["fs", "shell"], presets)
>>> len(result)
2
>>> result[0]["command"]
'mcp-filesystem'
>>> result[1]["command"]
'mcp-shell'
"""

# @invar:allow file_size: Single resolver module, pure logic

from typing import NotRequired, TypedDict

import re

import deal
from deal import post, pre


class ResolvedTool(TypedDict):
    """Resolved tool configuration ready for execution.

    A structured representation of a tool preset after resolution,
    containing all necessary fields to launch the tool process.

    Attributes:
        command: The command to execute (e.g., "mcp-filesystem").
        args: List of command-line arguments.
        env: Environment variables for the process.
        sandbox: Whether sandbox is enabled for this tool (default False).
        sandbox_allow: List of sandbox capabilities (e.g., ["fs:/tmp", "net"]).

    Examples:
        >>> tool: ResolvedTool = {"command": "mcp-filesystem", "args": [], "env": {}}
        >>> tool["command"]
        'mcp-filesystem'
        >>> tool["args"]
        []
        >>> tool.get("sandbox", False)
        False

        >>> tool2: ResolvedTool = {
        ...     "command": "mcp-docker",
        ...     "args": ["--privileged"],
        ...     "env": {"DOCKER_HOST": "unix:///var/run/docker.sock"},
        ... }
        >>> tool2["env"]["DOCKER_HOST"]
        'unix:///var/run/docker.sock'

        >>> tool3: ResolvedTool = {
        ...     "command": "mcp-filesystem",
        ...     "args": ["--root", "/tmp"],
        ...     "env": {},
        ...     "sandbox": True,
        ...     "sandbox_allow": ["fs:/tmp"],
        ... }
        >>> tool3["sandbox"]
        True
        >>> tool3["sandbox_allow"]
        ['fs:/tmp']
    """

    command: str
    args: list[str]
    env: dict[str, str]
    sandbox: bool
    sandbox_allow: list[str]


class ToolPresetInput(TypedDict):
    """Input format for tool preset resolver.

    This TypedDict represents the input format for resolve_tool_presets.
    It's a pure-data representation without I/O dependencies (no Pydantic).

    Converted from ToolPresetConfig (BaseModel) in the shell layer by
    extracting only the fields needed for resolution.

    TypedDict allows optional fields via NotRequired.
    Required: command. Optional: args, env, sandbox, sandbox_allow.

    Attributes:
        command: The command to execute (required).
        args: Optional list of command-line arguments (default []).
        env: Optional environment variables (default {}).
        sandbox: Whether sandbox is enabled (default False).
        sandbox_allow: List of sandbox capabilities (default []).

    Examples:
        >>> cfg = ToolPresetInput(command="mcp-filesystem")
        >>> cfg["command"]
        'mcp-filesystem'
        >>> cfg.get("args", [])
        []

        >>> cfg2 = ToolPresetInput(
        ...     command="mcp-shell",
        ...     args=["--verbose"],
        ...     env={"DEBUG": "1"},
        ... )
        >>> cfg2["env"]["DEBUG"]
        '1'

        >>> cfg3 = ToolPresetInput(
        ...     command="mcp-filesystem",
        ...     args=["--root", "/tmp"],
        ...     env={},
        ...     sandbox=True,
        ...     sandbox_allow=["fs:/tmp"],
        ... )
        >>> cfg3.get("sandbox", False)
        True
    """

    command: str
    args: NotRequired[list[str]]
    env: NotRequired[dict[str, str]]
    sandbox: NotRequired[bool]
    sandbox_allow: NotRequired[list[str]]


# Note: @pre contracts check input types and non-empty strings
# @post contracts check output structure and properties
# @deal.raises(ValueError) indicates ValueError is intentionally raised for unknown presets
@pre(
    lambda names, presets, variables=None: (
        isinstance(names, list)
        and all(isinstance(n, str) and len(n) > 0 for n in names)
        and isinstance(presets, dict)
        and (variables is None or isinstance(variables, dict))
        and (
            variables is None
            or all(
                isinstance(k, str) and isinstance(v, str) for k, v in variables.items()
            )
        )
    )
)
@post(lambda result: isinstance(result, list))
@post(lambda result: all(isinstance(r, dict) for r in result))
@post(lambda result: all("command" in r for r in result))
@deal.raises(ValueError)
def resolve_tool_presets(
    names: list[str],
    presets: dict[str, ToolPresetInput],
    variables: dict[str, str] | None = None,
) -> list[ResolvedTool]:
    """Resolve tool preset names to structured tool configurations.

    Takes a list of preset names and a presets dictionary, returns a list
    of ResolvedTool dictionaries with command, args, and env fields.

    Supports comma-separated names in a single string:
        ["fs,shell", "docker"] -> resolves fs, shell, and docker

    When variables dict is provided and non-empty, variable substitution is
    applied to args and sandbox_allow fields using $VAR syntax:
        - "$NAME" in args/sandbox_allow replaced with variable value
        - Unknown variables pass through unchanged
        - None or empty variables dict skips substitution (backward compatible)

    Args:
        names: List of preset names to resolve. Each name can be a single
            preset name or a comma-separated list of preset names.
        presets: Dictionary mapping preset names to ToolPresetInput.
        variables: Optional dictionary of variable values for substitution.
            If None or empty, no substitution is performed.

    Returns:
        List of ResolvedTool dictionaries with resolved configurations.

    Raises:
        ValueError: If a preset name is not found in the presets dictionary.
            The error message includes the unknown name(s) and available
            preset names.

    Examples:
        >>> presets = {
        ...     "fs": ToolPresetInput(command="mcp-filesystem", args=["--root", "/tmp"]),
        ...     "shell": ToolPresetInput(command="mcp-shell"),
        ... }
        >>> result = resolve_tool_presets(["fs"], presets)
        >>> len(result)
        1
        >>> result[0]["command"]
        'mcp-filesystem'
        >>> result[0]["args"]
        ['--root', '/tmp']
        >>> result[0]["sandbox"]
        False
        >>> result[0]["sandbox_allow"]
        []

        >>> # Multiple names
        >>> result2 = resolve_tool_presets(["fs", "shell"], presets)
        >>> len(result2)
        2
        >>> result2[0]["command"]
        'mcp-filesystem'
        >>> result2[1]["command"]
        'mcp-shell'

        >>> # Comma-separated names
        >>> result3 = resolve_tool_presets(["fs,shell"], presets)
        >>> len(result3)
        2
        >>> [r["command"] for r in result3]
        ['mcp-filesystem', 'mcp-shell']

        >>> # Unknown preset raises ValueError
        >>> resolve_tool_presets(["unknown"], presets)  # doctest: +IGNORE_EXCEPTION_DETAIL
        Traceback (most recent call last):
        ...
        ValueError: ...

        >>> # Empty input returns empty list
        >>> resolve_tool_presets([], presets)
        []

    >>> # With all fields
    >>> presets_full = {
    ...     "docker": ToolPresetInput(
    ...         command="mcp-docker",
    ...         args=["--privileged"],
    ...         env={"DOCKER_HOST": "unix:///var/run/docker.sock"},
    ...     ),
    ... }
    >>> result4 = resolve_tool_presets(["docker"], presets_full)
    >>> result4[0]["env"]["DOCKER_HOST"]
    'unix:///var/run/docker.sock'

    >>> # Preset with missing optional fields uses defaults
    >>> presets_min = {"basic": ToolPresetInput(command="mcp-basic")}
    >>> result5 = resolve_tool_presets(["basic"], presets_min)
    >>> result5[0]["command"]
    'mcp-basic'
    >>> result5[0]["args"]
    []
    >>> result5[0]["env"]
    {}
    >>> result5[0]["sandbox"]
    False
    >>> result5[0]["sandbox_allow"]
    []

    >>> # Variable substitution in args
    >>> presets_var = {
    ...     "fs": ToolPresetInput(command="mcp-filesystem", args=["--root", "$HOME"]),
    ... }
    >>> result6 = resolve_tool_presets(["fs"], presets_var, {"HOME": "/Users/test"})
    >>> result6[0]["args"]
    ['--root', '/Users/test']

    >>> # Variable substitution in sandbox_allow
    >>> presets_sandbox = {
    ...     "fs": ToolPresetInput(
    ...         command="mcp-filesystem",
    ...         sandbox=True,
    ...         sandbox_allow=["fs:$DATA_DIR"],
    ...     ),
    ... }
    >>> result7 = resolve_tool_presets(["fs"], presets_sandbox, {"DATA_DIR": "/tmp/data"})
    >>> result7[0]["sandbox_allow"]
    ['fs:/tmp/data']

    >>> # variables=None passes through unchanged (backward compatibility)
    >>> presets_back = {
    ...     "fs": ToolPresetInput(command="mcp-filesystem", args=["--root", "$UNKNOWN"]),
    ... }
    >>> result8 = resolve_tool_presets(["fs"], presets_back, None)
    >>> result8[0]["args"]
    ['--root', '$UNKNOWN']

    >>> # Empty variables dict passes through unchanged
    >>> result9 = resolve_tool_presets(["fs"], presets_back, {})
    >>> result9[0]["args"]
    ['--root', '$UNKNOWN']
    """
    # Expand comma-separated names
    expanded_names: list[str] = []
    for name in names:
        # Handle comma-separated form: "fs,shell" -> ["fs", "shell"]
        if "," in name:
            parts = [p.strip() for p in name.split(",")]
            expanded_names.extend(parts)
        else:
            expanded_names.append(name)

    # Resolve each name
    result: list[ResolvedTool] = []
    for name in expanded_names:
        if name not in presets:
            available = ", ".join(sorted(presets.keys()))
            raise ValueError(
                f"Unknown tool preset: '{name}'. Available presets: {available}"
            )

        preset = presets[name]

        # Build ResolvedTool with defaults for missing fields
        resolved: ResolvedTool = {
            "command": preset["command"],
            "args": preset.get("args", []),
            "env": preset.get("env", {}),
            "sandbox": preset.get("sandbox", False),
            "sandbox_allow": preset.get("sandbox_allow", []),
        }

        # Apply variable substitution if variables dict provided and non-empty
        if variables:
            resolved["args"] = [
                substitute_variables(arg, variables) for arg in resolved["args"]
            ]
            resolved["sandbox_allow"] = [
                substitute_variables(allow, variables)
                for allow in resolved["sandbox_allow"]
            ]

        result.append(resolved)

    return result


@post(lambda result: isinstance(result, list))
@post(lambda result: all(isinstance(n, str) for n in result))
@post(lambda result: all(len(n) > 0 for n in result))
def parse_comma_separated(names_str: str) -> list[str]:
    """Parse a comma-separated string of preset names.

    Splits a string like "fs,shell,docker" into ["fs", "shell", "docker"],
    stripping whitespace from each name.

    Args:
        names_str: Comma-separated string of preset names.

    Returns:
        List of individual preset names.

    Examples:
        >>> parse_comma_separated("fs,shell")
        ['fs', 'shell']
        >>> parse_comma_separated("fs, shell, docker")
        ['fs', 'shell', 'docker']
        >>> parse_comma_separated("single")
        ['single']
        >>> parse_comma_separated("")
        []
        >>> parse_comma_separated("  fs  ,  shell  ")
        ['fs', 'shell']
    """
    if not names_str.strip():
        return []

    parts = names_str.split(",")
    return [p.strip() for p in parts if p.strip()]


@pre(lambda text, variables: isinstance(text, str))
@pre(lambda text, variables: isinstance(variables, dict))
@pre(
    lambda text, variables: all(
        isinstance(k, str) and isinstance(v, str) for k, v in variables.items()
    )
)
@post(lambda result: isinstance(result, str))
def substitute_variables(text: str, variables: dict[str, str]) -> str:
    """Substitute variable placeholders in text with their values.

    Replaces $NAME patterns with corresponding values from the variables
    dictionary. Unknown variables are passed through unchanged. Empty
    variables dictionary returns text unchanged.

    The pattern matches $NAME where NAME is a sequence of alphanumeric
    characters and underscores (starting with a letter or underscore).

    Args:
        text: The text containing variable placeholders.
        variables: Dictionary mapping variable names to their values.

    Returns:
        Text with all recognized variable placeholders replaced.

    Examples:
        >>> substitute_variables("Hello $NAME", {"NAME": "World"})
        'Hello World'
        >>> substitute_variables("$FOO and $BAR", {"FOO": "X", "BAR": "Y"})
        'X and Y'
        >>> substitute_variables("No variables here", {})
        'No variables here'
        >>> substitute_variables("Hello $UNKNOWN", {"OTHER": "Value"})
        'Hello $UNKNOWN'
        >>> substitute_variables("$NAME $NAME", {"NAME": "Echo"})
        'Echo Echo'
        >>> substitute_variables("Mixed $CASE and $Camel", {"case": "lower", "Camel": "camel"})
        'Mixed $CASE and camel'
        >>> substitute_variables("Leading $UNDER_", {"UNDER_": "score"})
        'Leading score'
        >>> substitute_variables("Trailing $NUM123", {"NUM123": "456"})
        'Trailing 456'
        >>> substitute_variables("Multiple $$escapes", {})
        'Multiple $$escapes'
        >>> substitute_variables("", {"KEY": "value"})
        ''
    """
    # Pattern: $ followed by identifier (letter/underscore + alphanumeric/underscore)
    pattern = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")

    def replacer(match: re.Match) -> str:
        var_name = match.group(1)
        return variables.get(var_name, match.group(0))  # type: ignore[return-value]

    return pattern.sub(replacer, text)


__all__ = [
    "ResolvedTool",
    "ToolPresetInput",
    "resolve_tool_presets",
    "parse_comma_separated",
    "substitute_variables",
    "resolve_effective_tool_names",
]


@post(lambda result: isinstance(result, tuple))
@post(lambda result: all(isinstance(name, str) and len(name) > 0 for name in result))
@pre(
    lambda declared_persona_tools, requested_cli_tools, requested_mcp_tools, cli_tools_explicitly_set=False, mcp_tools_explicitly_set=False: (
        all(isinstance(name, str) and len(name) > 0 for name in declared_persona_tools)
        and all(isinstance(name, str) and len(name) > 0 for name in requested_cli_tools)
        and all(isinstance(name, str) and len(name) > 0 for name in requested_mcp_tools)
    )
)
def resolve_effective_tool_names(
    declared_persona_tools: tuple[str, ...],
    requested_cli_tools: tuple[str, ...],
    requested_mcp_tools: tuple[str, ...],
    cli_tools_explicitly_set: bool = False,
    mcp_tools_explicitly_set: bool = False,
) -> tuple[str, ...]:
    """Resolve effective tool names with explicit-clear semantics.

    Spec source:
    docs/proposals/execution-architecture-refactor.md#execution-contract-specific-design-rules/tool-access-semantics

    Rules:
    - When CLI/MCP tool requests are explicitly provided, effective tools derive
      from requested tools only (including explicit empty tuple clear).
    - Otherwise effective tools derive from declared persona tools.

    >>> resolve_effective_tool_names(
    ...     declared_persona_tools=("read_file",),
    ...     requested_cli_tools=(),
    ...     requested_mcp_tools=(),
    ... )
    ('read_file',)
    >>> resolve_effective_tool_names(
    ...     declared_persona_tools=("read_file",),
    ...     requested_cli_tools=("write_file",),
    ...     requested_mcp_tools=(),
    ...     cli_tools_explicitly_set=True,
    ... )
    ('write_file',)
    >>> resolve_effective_tool_names(
    ...     declared_persona_tools=("read_file",),
    ...     requested_cli_tools=(),
    ...     requested_mcp_tools=(),
    ...     cli_tools_explicitly_set=True,
    ... )
    ()
    """
    if cli_tools_explicitly_set or mcp_tools_explicitly_set:
        ordered = dict.fromkeys((*requested_cli_tools, *requested_mcp_tools))
        return tuple(ordered.keys())
    return tuple(dict.fromkeys(declared_persona_tools).keys())
