from dataclasses import dataclass
from enum import Enum
from functools import cached_property
import re
import selectors
import shlex
import subprocess
import sys

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


class OperationAccess(str, Enum):
    """Access levels for operations"""
    PUBLIC = "public"
    PRIVATE = "private"


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
            access_str = OperationAccess.PUBLIC.value
        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")
            access_str = definition.get("access", OperationAccess.PUBLIC.value)
        
        # Validate and set access level
        try:
            self.access = OperationAccess(access_str)
        except ValueError:
            valid_values = ", ".join([f"'{a.value}'" for a in OperationAccess])
            raise DyngleError(
                f"Invalid access value '{access_str}' for operation '{key}'. "
                f"Must be one of: {valid_values}"
            )
        
        self.constants = dyngleverse.global_constants | local_constants
        self.sequence = Sequence(dyngleverse, self, steps_def)

    def run(self, data: Context, args: list, display="steps", 
            show_stdout=None):
        # Determine stdout behavior: show if no return key (script-like)
        if show_stdout is None:
            show_stdout = (self.return_key is None)
        
        self.sequence.run(data, args, display=display, 
                         show_stdout=show_stdout)
        
        # 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", 
            show_stdout=True):
        for step in self.steps:
            step.run(data, args, display=display, show_stdout=show_stdout)


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", 
            show_stdout=True):
        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"] = ''

        input = full_context.resolve(self.input) if self.input else ''

        # if self.output:
        #     # Always capture when using => operator
        #     pipes["stdout"] = subprocess.PIPE
        # elif not show_stdout:
        #     # Function mode: suppress stdout
        #     pipes["stdout"] = subprocess.DEVNULL
        # If show_stdout=True and no output capture, don't set stdout
        # to allow subprocess.run to use default behavior (shows output)

        capture_stdout = bool(self.output)
        show_stdout = (not capture_stdout) and show_stdout

        # result = subprocess.run(command, text=True, **pipes)

        returncode, output = run_subprocess(command, input, show_stdout, capture_stdout)

        if returncode != 0:
            raise DyngleError(
                f"Step failed with code {returncode}: {self.markup}"
            )
        if self.output:
            data[self.output] = output.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", 
            show_stdout=True):
        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, inheriting stdout behavior from parent
        result = operation.run(sub_data, sub_args, display=display, 
                             show_stdout=show_stdout)
        
        # Store the return value in parent's data if output is specified
        if self.output_key:
            data[self.output_key] = result


# Like subprocess.run, but ensures output always goes through patches and such

def run_subprocess(command:list, input:str='', show_stdout:bool=True, capture_stdout:bool=False):
    result = ''
    proc = subprocess.Popen(
        command,
        stdout=subprocess.PIPE,
        stdin=subprocess.PIPE,
        stderr=subprocess.PIPE,
        bufsize=0, text=True
    )
    try:
        if input:
            proc.stdin.write(input)
        proc.stdin.close()

        # Set up selector to monitor both stdout and stderr
        sel = selectors.DefaultSelector()
        sel.register(proc.stdout, selectors.EVENT_READ, data='stdout')
        sel.register(proc.stderr, selectors.EVENT_READ, data='stderr')

        # Read from both streams as data becomes available
        while sel.get_map():
            events = sel.select(timeout=0.1)
            for key, _ in events:
                char = key.fileobj.read(1)
                if not char:  # EOF on this stream
                    sel.unregister(key.fileobj)
                    continue

                if key.data == 'stdout':
                    if capture_stdout:
                        result += char
                    if show_stdout:
                        sys.stdout.write(char)
                        sys.stdout.flush()
                elif key.data == 'stderr':
                    sys.stderr.write(char)
                    sys.stderr.flush()

        sel.close()
        proc.wait()
        return proc.returncode, result

    finally:
        proc.stdout.close()
        proc.stderr.close()
        if proc.stdin and not proc.stdin.closed: #pragma: nocover
            proc.stdin.close()
