"""Task executor - owns all task execution logic.

Tasks never see TaskRunner or execution internals. This module handles:
- Building TaskRunner from config
- Converting task declarations to TaskDefinition
- Orchestrating execution
- Result reporting
"""

from __future__ import annotations

import time
from typing import Any, Callable, Iterable, cast
from datetime import datetime
from dataclasses import dataclass

from hil_testbench.config.task_config import TaskConfig
from hil_testbench.run.command_runner import CommandRunner
from hil_testbench.run.task_logger import LogLevel
from hil_testbench.run.task_outcome import TaskOutcome
from hil_testbench.task.specs import (
    TaskDefinition,
    ParserDefinition,
    SinkDefinition,
    CommandDefinition,
)
from hil_testbench.display.backend import create_backend
from hil_testbench.data_structs.parameters import ParametersSchema
from hil_testbench.data_structs.parameters_utils import validate_event_schema
from hil_testbench.data_processing.pipeline import make_pipeline_callback_factory
from hil_testbench.utils.schema_builder import build_schema


@dataclass
class TaskEntry:
    """Entry for CommandRunner.run with explicit task naming.

    Attributes:
        func: Task function to execute
        host: Optional host string for remote execution
        callback_factory: Optional output callback factory
        cleanup: Optional cleanup callback
        task_name: Explicit task name (overrides function name extraction)
    """

    func: Callable
    host: str | None = None
    callback_factory: Callable | None = None
    cleanup: Callable | None = None
    task_name: str | None = None

    def as_tuple(self) -> tuple:
        """Convert to tuple format for runner.run API."""
        if self.cleanup:
            return (
                self.func,
                self.host,
                self.callback_factory,
                self.cleanup,
                self.task_name,
            )
        elif self.callback_factory:
            return (self.func, self.host, self.callback_factory, self.task_name)
        elif self.host:
            return (self.func, self.host, self.task_name)
        else:
            return (self.func, self.task_name)


class TaskRunner:
    """Framework runner that owns all task execution logic."""

    def execute(self, task: Any, config: TaskConfig) -> list[Any]:
        """Execute a task using the framework.

        Args:
            task: Task instance (duck-typed, must have commands() method)
            config: Task configuration

        Returns:
            List of task results
        """
        # Validate task has required interface
        self._validate_task_interface(task)

        # Build TaskRunner with all config mapped
        runner = self._build_runner(config)
        task_logger = runner.get_task_logger()

        try:
            # Build task definitions from task declarations
            task_definitions = self._build_task_definitions(task, config)

            # Log task execution plan
            mode = "parallel" if getattr(task, "concurrent", False) else "sequential"
            duration = config.duration or "60"
            for task_def in task_definitions:
                task_start_time = time.time()
                command_names = [command.name for command in task_def.commands]

                # Update display backend that task is starting
                backend = getattr(task_logger, "_display_backend", None)
                if backend and hasattr(backend, "update_task_status"):
                    try:
                        backend.update_task_status(
                            task_def.name, "running", start_time=task_start_time
                        )
                    except Exception:  # pylint: disable=broad-except
                        pass

                # Console-friendly INFO message (no verbose details)
                task_logger.log_task(
                    "task_started",
                    task_def.name,
                    LogLevel.INFO,
                    message=f"Starting {task_def.name} (duration: {duration}s)",
                )
                # Detailed DEBUG info goes to logs (and console only if debug enabled)
                task_logger.log_task(
                    "task_started",
                    task_def.name,
                    LogLevel.DEBUG,
                    message=f"Starting {task_def.name} with {len(command_names)} command(s) ({mode})",
                    command_count=len(command_names),
                    commands=", ".join(command_names),
                    mode=mode,
                )

            # Execute task definitions
            results = self._run_task_definitions(
                runner,
                task_definitions,
                parallel=getattr(task, "concurrent", False),
                password=config.password,
                config=config,
            )

            # Log results
            for r in results:
                status = "SUCCESS" if r.success else "FAILED"

                # Build message with error details for failures
                if r.success:
                    message = f"{r.command_name}: {status} (code={r.return_code})"
                else:
                    error_detail = ""
                    if r.error:
                        error_detail = f" - {type(r.error).__name__}: {str(r.error)}"
                    elif r.stderr:
                        # Show first line of stderr if no exception
                        first_line = r.stderr.split("\n")[0][:100]
                        error_detail = f" - {first_line}"
                    message = f"{r.command_name}: {status} (code={r.return_code}){error_detail}"

                log_data = {
                    "message": message,
                    "command": r.command_name,
                    "task_name": (
                        task_definitions[0].name
                        if task_definitions
                        else getattr(task, "task_name", "task")
                    ),
                    "success": r.success,
                    "return_code": r.return_code,
                }

                # Add error details for failures
                if not r.success:
                    if r.error:
                        log_data["error"] = str(r.error)
                        log_data["error_type"] = type(r.error).__name__
                    if r.stderr:
                        log_data["stderr_preview"] = r.stderr[:500]  # First 500 chars

                task_logger.log_command(
                    "command_result",
                    r.command_name,
                    LogLevel.ERROR if not r.success else LogLevel.INFO,
                    **log_data,
                )

            # Log task completion summary and correlation info
            try:
                cid = runner.get_correlation_id()
                exec_dir = runner.get_execution_dir()
                # Aggregate task result
                total = len(results)
                failed = sum(1 for r in results if not r.success)
                task_name = (
                    task_definitions[0].name
                    if task_definitions
                    else getattr(task, "task_name", "task")
                )

                # Check for parser/pipeline errors from display backend
                backend = getattr(task_logger, "_display_backend", None)
                pipeline_errors = 0
                if backend and task_name in getattr(backend, "_tasks", {}):
                    pipeline_errors = backend._tasks[task_name].error_count

                overall_success = (failed == 0) and (pipeline_errors == 0)

                # Console-friendly INFO summary (with success icon via formatter)
                task_logger.log_task(
                    "task_completed",
                    task_name,
                    LogLevel.INFO,
                    message=f"Task {task_name}: {'SUCCESS' if overall_success else 'FAILED'}",
                    success=overall_success,
                    total_commands=total,
                    failed_commands=failed,
                    pipeline_errors=pipeline_errors,
                )
                # Update display backend with overall task status
                if backend:
                    try:
                        backend.update_task_status(
                            task_name,
                            "completed" if overall_success else "failed",
                        )

                        # Re-check status after backend validation (backend may override to failed)
                        if backend and task_name in getattr(backend, "_tasks", {}):
                            actual_status = backend._tasks[task_name].status
                            if actual_status == "failed" and overall_success:
                                # Backend detected validation failure, update our records
                                overall_success = False
                                failed += 1  # Count as failed

                                task_logger.log_task(
                                    "task_validation_failed",
                                    task_name,
                                    LogLevel.ERROR,
                                    message=f"Task {task_name} FAILED framework validation",
                                    reason="No data produced despite successful command exits",
                                )
                    except Exception:  # pylint: disable=broad-except
                        pass
                # Emit a DEBUG summary that includes correlation and log path (console only in debug)
                task_logger.log_task(
                    "task_completed",
                    task_name,
                    LogLevel.DEBUG,
                    message=f"Correlation ID: {cid} | Logs: {exec_dir}",
                    correlation_id=cid,
                    log_directory=exec_dir,
                )
            except Exception:  # pylint: disable=broad-except
                pass

            return results

        finally:
            task_logger.log_debug("shutdown_starting")

            # Shutdown display backend before runner
            backend = getattr(task_logger, "_display_backend", None)
            if backend:
                try:
                    backend.shutdown()
                except Exception as e:  # pylint: disable=broad-except
                    task_logger.log_error(
                        "display_backend_shutdown_error",
                        error=str(e),
                        error_type=type(e).__name__,
                    )

            runner.shutdown()
            task_logger.log_debug("shutdown_completed")

    def _validate_task_interface(self, task: Any) -> None:
        """Early validation of the task interface to guide authors.

        Requirements:
        - commands attribute must exist and be callable
        """
        if not hasattr(task, "commands") or not callable(getattr(task, "commands")):
            raise TypeError(
                f"{task.__class__.__name__} must define a callable commands(config) method"
            )

    def _build_runner(self, config: TaskConfig) -> CommandRunner:
        """Build CommandRunner from config - task never sees this."""
        runner = CommandRunner(
            verbose=True,
            max_bytes_main=config.max_log_size_main_mb * 1024 * 1024,
            max_bytes_task=config.max_log_size_task_mb * 1024 * 1024,
            max_log_file_count_main=config.run_config.max_log_file_count_main,
            max_log_file_count_task=config.max_log_file_count_task,
            enable_health_logging=config.enable_runner_health,
            health_interval=config.runner_health_interval,
            config=config,
        )

        # Create and initialize display backend early
        task_logger = runner.get_task_logger()
        backend = create_backend(config, task_logger)
        if backend:
            task_logger.set_display_backend(backend)
            backend.initialize()
            backend.start_display()
            task_logger.log_info(
                "display_backend_ready", backend_type=type(backend).__name__
            )

        return runner

    def _build_task_definitions(
        self, task: Any, config: TaskConfig
    ) -> list[TaskDefinition]:
        """Build TaskDefinition from task's declarative attributes.

        Handles:
        - commands() method
        - parser attribute/method
        - metrics attribute/method
        - schema attribute
        - sinks from config
        """
        # Get commands (normalize to CommandDefinition)
        raw_commands = task.commands(config)
        commands = []
        for cmd in raw_commands:
            if hasattr(cmd, "to_command_definition"):
                commands.append(cmd.to_command_definition())
            else:
                commands.append(cmd)

        # Validate normalized commands are CommandDefinition instances
        for c in commands:
            if not isinstance(c, CommandDefinition):
                raise TypeError(
                    f"commands() must return Command/CommandDefinition items; got {type(c).__name__}"
                )

        # Get parser (multiple styles supported)
        parser_def = self._get_parser(task, config)

        # Get schema (supports static dict or dynamic method)
        schema = self._get_schema(task, config)

        # Get sinks from config
        sinks = self._get_sinks(task, config)

        # Build task definition
        task_name = getattr(
            task, "task_name", task.__class__.__name__.lower().replace("task", "")
        )

        task_def = TaskDefinition(
            name=task_name,
            commands=commands,
            parser=parser_def,
            sinks=sinks,
            cleanup=None,
            parameters_schema=schema,
        )

        return [task_def]

    def _get_parser(self, task: Any, config: TaskConfig) -> ParserDefinition | None:
        """Get parser from task - supports multiple styles."""
        # Get schema first (parsers may need it)
        schema = self._get_schema(task, config)

        # Check for parser attribute (class or function)
        if hasattr(task, "parser"):
            parser_attr = task.parser
            if parser_attr is None:
                return None

            # Style 1: Parser class
            if isinstance(parser_attr, type):

                def factory():
                    parser_inst: Any = parser_attr()
                    if hasattr(parser_inst, "set_schema") and schema:
                        getattr(parser_inst, "set_schema")(schema)
                    return parser_inst

                return ParserDefinition(factory=factory)

            # Style 2: Parser function
            if callable(parser_attr):
                # Assume it's a method that returns ParserDefinition
                result = parser_attr(config)
                if isinstance(result, ParserDefinition):
                    # Wrap factory to inject schema
                    original_factory = result.factory

                    def factory_with_schema():
                        parser_inst: Any = original_factory()
                        if hasattr(parser_inst, "set_schema") and schema:
                            getattr(parser_inst, "set_schema")(schema)
                        return parser_inst

                    result.factory = factory_with_schema
                    return result

        return None

    def _get_schema(
        self, task: Any, config: TaskConfig | None = None
    ) -> ParametersSchema | None:
        """Get schema from task - supports static dict or dynamic callable."""
        # Check for schema attribute
        if hasattr(task, "schema"):
            schema_attr = task.schema

            # Callable schema (dynamic - receives config)
            if callable(schema_attr):
                if config is None:
                    raise ValueError(
                        f"Task {task.__class__.__name__} has callable schema but no config provided"
                    )
                schema_result = schema_attr(config)
                if isinstance(schema_result, dict):
                    return build_schema(schema_result)
                elif isinstance(schema_result, ParametersSchema):
                    return schema_result
                else:
                    raise TypeError(
                        f"Callable schema must return dict or ParametersSchema, got {type(schema_result)}"
                    )

            # Static dict schema
            elif isinstance(schema_attr, dict):
                return build_schema(schema_attr)

        # Check for parameters_schema attribute (ParametersSchema - old style)
        if hasattr(task, "parameters_schema"):
            return task.parameters_schema

        return None

    def _get_sinks(self, task: Any, config: TaskConfig) -> SinkDefinition | None:
        """Get sink configuration."""
        # Check task_params for sinks config
        sinks_params = config.task_params.get("sinks", {})
        if sinks_params:
            return SinkDefinition(
                enable_jsonl=sinks_params.get("enable_jsonl", True),
                enable_csv=sinks_params.get("enable_csv", True),
                file_base=sinks_params.get("file_base"),
            )

        # Check for sinks_config() method (old style)
        if hasattr(task, "sinks_config") and callable(task.sinks_config):
            return cast(SinkDefinition | None, task.sinks_config(config))

        return None

    def _command_host(self, command: CommandDefinition) -> str | None:
        """Extract host string from CommandDefinition.

        Returns None for local execution (no SSH), or host string for remote execution.
        """
        if command.host is not None:
            # Local hosts run without SSH (return None to trigger local execution)
            if command.host.local:
                return None
            return command.host.as_string()
        return None

    def _make_logged_command_wrapper(
        self,
        command_func: Callable,
        task_name: str,
        command_name: str,
        runner: CommandRunner,
    ) -> Callable:
        """Wrap command function with start/finish/error logging."""

        def wrapper(ctx: Any) -> Any:
            logger = runner.get_task_logger()
            backend = getattr(logger, "_display_backend", None)
            start_time = datetime.now()

            # Update command status to running
            if backend and hasattr(backend, "update_command_status"):
                try:
                    backend.update_command_status(task_name, command_name, "running")
                except Exception:  # pylint: disable=broad-except
                    pass

            # Note: command_started already logged by _execute_task in command_runner
            try:
                # Execute the actual command function (may return exit code)
                result = command_func(ctx)
                exit_code: int | None = result if isinstance(result, int) else None

                duration = (datetime.now() - start_time).total_seconds()

                if exit_code is not None and exit_code != 0:
                    # Treat non-zero exit code as failure
                    # Update command status to failed
                    if backend and hasattr(backend, "update_command_status"):
                        try:
                            backend.update_command_status(
                                task_name, command_name, "failed"
                            )
                        except Exception:  # pylint: disable=broad-except
                            pass
                    # Note: command_failed already logged by command_runner, just raise
                    raise RuntimeError(
                        f"Command '{command_name}' exited with {exit_code}"
                    )

                # Update command status to completed
                if backend and hasattr(backend, "update_command_status"):
                    try:
                        backend.update_command_status(
                            task_name, command_name, "completed"
                        )
                    except Exception:  # pylint: disable=broad-except
                        pass

                # Note: command_succeeded already logged by command_runner
                return result

            except Exception as e:
                # Update command status to failed
                if backend and hasattr(backend, "update_command_status"):
                    try:
                        backend.update_command_status(task_name, command_name, "failed")
                    except Exception:  # pylint: disable=broad-except
                        pass

                # Note: command_failed already logged by command_runner if it's a RuntimeError from above
                # Only log if it's a different exception type
                if not isinstance(e, RuntimeError) or "exited with" not in str(e):
                    duration = (datetime.now() - start_time).total_seconds()
                    logger.log_command(
                        "command_failed",
                        task_name,
                        LogLevel.ERROR,
                        command=command_name,
                        duration_seconds=f"{duration:.2f}",
                        error=str(e),
                        error_type=type(e).__name__,
                    )
                raise

        return wrapper

    def _run_task_definitions(
        self,
        runner: CommandRunner,
        task_definitions: Iterable[TaskDefinition],
        *,
        parallel: bool = True,
        password: str | None = None,
        config: TaskConfig | None = None,
    ) -> list[Any]:
        """Execute task definitions by translating them to TaskEntry objects."""

        task_entries: list[tuple] = []
        command_validators: dict[str, Any] = {}  # Map command name -> validator

        for task_def in task_definitions:
            # Optionally validate schema for each command/event before pipeline construction
            if task_def.parameters_schema:
                for command in task_def.commands:
                    # Example: validate a dummy event structure for pre-flight check
                    dummy_event = {
                        f.name: None for f in task_def.parameters_schema.fields
                    }
                    valid = validate_event_schema(
                        dummy_event, task_def.parameters_schema, strict=False
                    )
                    if not valid:
                        runner.get_task_logger().log_warning(
                            "schema_validation_warning",
                            task=task_def.name,
                            command=command.name,
                            message=f"Command '{command.name}' in task '{task_def.name}' does not match schema.",
                        )

            parser_factory = task_def.parser.factory if task_def.parser else None
            schema = (
                task_def.parameters_schema
                if hasattr(task_def, "parameters_schema")
                else None
            )
            enable_jsonl = (
                True if (task_def.sinks is None) else task_def.sinks.enable_jsonl
            )
            enable_csv = True if (task_def.sinks is None) else task_def.sinks.enable_csv

            for command in task_def.commands:
                host = self._command_host(command)
                # Get display backend from logger
                task_logger = runner.get_task_logger()
                backend = getattr(task_logger, "_display_backend", None)

                # Store validator for post-execution validation
                if command.validator:
                    task_logger.log_framework(
                        "validator_registered",
                        LogLevel.DEBUG,
                        command_name=command.name,
                        task_name=task_def.name,
                        validator_type=type(command.validator).__name__,
                    )
                    command_validators[command.name] = (
                        command.validator,
                        task_def.name,
                        backend,
                    )
                else:
                    task_logger.log_framework(
                        "no_validator",
                        LogLevel.DEBUG,
                        command_name=command.name,
                        task_name=task_def.name,
                    )

                # Register command with backend (for tree view)
                if backend and hasattr(backend, "register_command"):
                    try:
                        backend.register_command(task_def.name, command.name)
                    except Exception:  # pylint: disable=broad-except
                        pass

                # Extract task-specific display config from TaskConfig
                display_config = None
                if (
                    config
                    and hasattr(config, "display")
                    and isinstance(config.display, dict)
                ):
                    # config.display is already task-specific from YAML tasks.<name>.display
                    display_config = config.display.get("parameters", {})

                cb_factory = make_pipeline_callback_factory(
                    task_name=task_def.name,
                    command_name=command.name,
                    parser_factory=parser_factory,
                    display_backend=backend,
                    enable_jsonl=enable_jsonl,
                    enable_csv=enable_csv,
                    custom_file_base=(
                        task_def.sinks.file_base if task_def.sinks else None
                    ),
                    task_logger=task_logger,
                    parameters_schema=schema,
                    display_config=display_config,
                )

                # Wrap command function with logging
                logged_command_func = self._make_logged_command_wrapper(
                    command.run, task_def.name, command.name, runner
                )

                # Build TaskEntry with explicit command name
                entry = TaskEntry(
                    func=logged_command_func,
                    host=host,
                    callback_factory=cb_factory,
                    task_name=command.name,  # Use command name, not function name
                )
                task_entries.append(entry.as_tuple())

        # Execute all commands
        results = runner.run(
            task_entries,
            parallel=parallel,
            password=password,
            log_output=True,
            sample_lines=0,
            use_pty=False,
        )

        # Post-execution validation
        task_logger = runner.get_task_logger()
        task_logger.log_framework(
            "post_execution_validation_start",
            LogLevel.DEBUG,
            command_count=len(results),
            validator_count=len(command_validators),
        )

        for result in results:
            command_name = result.command_name
            if command_name in command_validators:
                validator, task_name, backend = command_validators[command_name]

                task_logger.log_command(
                    "running_validator",
                    command_name,
                    LogLevel.DEBUG,
                    task_name=task_name,
                    validator_type=type(validator).__name__,
                )

                # Get parameter count from backend
                parameter_count = 0
                if backend and hasattr(backend, "_tasks"):
                    task_data = backend._tasks.get(task_name)
                    if task_data and hasattr(task_data, "commands"):
                        cmd_data = task_data.commands.get(command_name)
                        if cmd_data:
                            parameter_count = getattr(cmd_data, "parameter_count", 0)

                # Run validator
                try:
                    success, error_msg = validator.validate(
                        exit_code=result.return_code,
                        stdout=result.stdout or "",
                        stderr=result.stderr or "",
                        duration=result.duration,
                        parameter_count=parameter_count,
                    )

                    task_logger.log_command(
                        "validator_result",
                        command_name,
                        LogLevel.DEBUG,
                        task_name=task_name,
                        success=success,
                        error_msg=error_msg,
                    )

                    if not success:
                        # Override result success status
                        result.success = False

                        # Add error to backend
                        if backend and hasattr(backend, "update_command_status"):
                            backend.update_command_status(
                                task_name, command_name, "failed"
                            )
                        if backend and hasattr(backend, "_tasks"):
                            task_data = backend._tasks.get(task_name)
                            if task_data and hasattr(task_data, "commands"):
                                cmd_data = task_data.commands.get(command_name)
                                if cmd_data and hasattr(cmd_data, "errors"):
                                    cmd_data.errors.append(
                                        error_msg or "Validation failed"
                                    )

                        # Log validation failure
                        task_logger.log_command(
                            "command_validation_failed",
                            command_name,
                            LogLevel.ERROR,
                            task_name=task_name,
                            reason=error_msg or "Validation failed",
                        )

                except Exception as e:  # pylint: disable=broad-except
                    task_logger.log_command(
                        "validator_error",
                        command_name,
                        LogLevel.ERROR,
                        task_name=task_name,
                        error=str(e),
                        error_type=type(e).__name__,
                    )

        return results

    def execute_with_summary(self, task: Any, config: TaskConfig) -> TaskOutcome:
        """Execute task and return aggregated TaskOutcome summary.

        Backward-compatible helper that wraps execute() and derives a
        TaskOutcome dataclass (see task_outcome.py). Does not alter logging
        behavior.
        """
        start = datetime.utcnow()
        command_results = self.execute(task, config)
        end = datetime.utcnow()
        total = len(command_results)
        failed = sum(1 for r in command_results if not r.success)
        success = failed == 0
        task_name = getattr(
            task, "task_name", task.__class__.__name__.lower().replace("task", "")
        )

        # Note: CommandRunner correlation/log directory not exposed; fields remain None
        correlation_id = None
        log_directory = None

        outcome = TaskOutcome(
            task_name=task_name,
            command_results=command_results,  # type: ignore[arg-type]
            success=success,
            total_commands=total,
            failed_commands=failed,
            start_time=start,
            end_time=end,
            duration_seconds=(end - start).total_seconds(),
            correlation_id=correlation_id,
            log_directory=log_directory,
        )
        return outcome
