from dataclasses import dataclass
from functools import cached_property
import re
import shlex
import subprocess
import sys

from dyngle.error import DyngleError
from dyngle.model.context import Context
from dyngle.model.template import Template


class Operation:
    """A named operation defined in configuration. Can be called from a Dyngle
    command (i.e. `dyngle run`) or as a sub-operation."""

    local_constants = {}

    def __init__(self, dyngleverse, definition: dict | list, key: str):
        """
        definition: Either a dict containing steps and local
        expressions/values, or a list containing only steps
        """
        self.dyngleverse = dyngleverse
        if isinstance(definition, list):
            steps_def = definition
            local_constants = Context()
            self.description = None
            self.return_key = None
        elif isinstance(definition, dict):
            steps_def = definition.get("steps") or []
            local_constants = dyngleverse.parse_constants(definition)
            self.description = definition.get("description")
            self.return_key = definition.get("return")
        self.constants = dyngleverse.global_constants | local_constants
        self.sequence = Sequence(dyngleverse, self, steps_def)

    def run(self, data: Context, args: list, display="steps"):
        self.sequence.run(data, args, display=display)
        
        # Return the specified value if return_key is set
        if self.return_key:
            full_context = self.constants | data
            return full_context.resolve(self.return_key, str_only=False)
        return None


class Sequence:
    """We allow for the possibility that a sequence of steps might run at other
    levels than the operation itself, for example in a conditional block."""

    def __init__(self, dyngleverse, operation: Operation, steps_def: list):
        self.steps = [
            Step.parse_def(dyngleverse, operation.constants, d)
            for d in steps_def
        ]

    def run(self, data: Context, args: list, display="steps"):
        for step in self.steps:
            step.run(data, args, display=display)


class Step:

    @staticmethod
    def parse_def(dyngleverse, constants: Context, definition: dict | str):
        for step_type in [CommandStep, SubOperationStep]:
            if step_type.fits(definition):
                return step_type(dyngleverse, constants, definition)
        raise DyngleError(f"Unknown step definition\n{definition}")


# Ideally these would be subclasses in a ClassFamily (or use an ABC)


class CommandStep:
    """Executes a system command with optional input/output operators.

    Supports the following operators:
    - `->` (input): Passes a value from the namespace to stdin
    - `=>` (output): Captures stdout to live data

    The step creates a namespace by merging:
    1. Operation's constants (declared values/expressions) - lowest precedence
    2. Shared live data (mutable across operations) - middle precedence
    3. Current args - highest precedence

    Template resolution happens in this namespace, but output assignments
    go directly to the shared live data to persist across operations.
    """

    PATTERN = re.compile(
        r"^\s*(?:([\w.-]+)\s+->\s+)?(.+?)(?:\s+=>\s+([\w.-]+))?\s*$"
    )

    @classmethod
    def fits(cls, definition: dict | str):
        return isinstance(definition, str)

    def __init__(self, dyngleverse, constants: Context, definition: str):
        self.constants = constants
        self.markup = definition
        if match := self.PATTERN.match(definition):
            self.input, command_text, self.output = match.groups()
            command_template = shlex.split(command_text.strip())
            self.command_template = command_template
        else:
            raise DyngleError(f"Invalid step markup {{markup}}")

    def display_step(self):
        """Display the step definition."""
        print(self.markup, file=sys.stderr)

    def run(self, data: Context, args: list, display="steps"):
        if display == "steps":
            self.display_step()
        
        full_context = self.constants | data | {"args": args}
        command = [
            Template(word).render(full_context).strip()
            for word in self.command_template
        ]
        pipes = {}
        if self.input:
            pipes["input"] = full_context.resolve(self.input)
        else:
            pipes["input"] = ''
        if self.output:
            pipes["stdout"] = subprocess.PIPE
        else:
            pipes["stdout"] = subprocess.DEVNULL
        result = subprocess.run(command, text=True, **pipes)
        if result.returncode != 0:
            raise DyngleError(
                f"Step failed with code {result.returncode}: {self.markup}"
            )
        if self.output:
            data[self.output] = result.stdout.rstrip()


class SubOperationStep:
    """Calls another operation as a sub-step.

    Sub-operations maintain proper scoping:
    - Each operation has its own constants (declared values/expressions)
    - Constants are locally scoped and do not leak between operations
    - Live data (set via =>) is shared and persists across operations
    - Args are locally scoped to each operation

    The sub-operation receives the same shared live data object, allowing
    it to read data set by the parent and persist changes back to the parent.
    
    Supports optional input: and output: attributes:
    - input: passes a specific value from context instead of full data
    - output: captures the sub-operation's return value to parent's data
    """

    @classmethod
    def fits(cls, definition: dict | str):
        return isinstance(definition, dict) and "sub" in definition

    def __init__(self, dyngleverse, declarations: Context, definition: dict):
        self.dyngleverse = dyngleverse
        self.declarations = declarations
        self.operation_key = definition["sub"]
        self.args_template = definition.get("args") or ""
        self.input_key = definition.get("input")
        self.output_key = definition.get("output")

    def run(self, data: Context, args: list, display="steps"):
        full_context = self.declarations | data | {"args": args}
        operation = self.dyngleverse.operations.get(self.operation_key)
        if not operation:
            raise DyngleError(f"Unknown operation {self.operation_key}")
        sub_args = [
            Template(word).render(full_context).strip()
            for word in self.args_template
        ]
        
        # Prepare the data context for the sub-operation
        if self.input_key:
            # Resolve the input value - must be a dict
            input_value = full_context.resolve(self.input_key, str_only=False)
            if not isinstance(input_value, dict):
                raise DyngleError(
                    f"input: attribute must resolve to a dict, got {type(input_value).__name__}"
                )
            # Create new context with the dict's keys and values
            sub_data = Context(input_value)
        else:
            # Pass the full shared data context
            sub_data = data
        
        # Run the sub-operation and capture return value
        result = operation.run(sub_data, sub_args, display=display)
        
        # Store the return value in parent's data if output is specified
        if self.output_key:
            data[self.output_key] = result
