import logging
import os
import time
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import TYPE_CHECKING

import yaml

from petsard.config import Config
from petsard.config_base import BaseConfig
from petsard.exceptions import ConfigError
from petsard.status import Status

if TYPE_CHECKING:
    from petsard.metadater.metadata import Schema


@dataclass
class ExecutorConfig(BaseConfig):
    """
    Defines the configuration for the Executor.

    Attr.:
        _logger (logging.Logger): The logger object.
        log_output_type (str): Output destination
            - stdout
            - file
            - both
        log_level (str): Logging level
            - DEBUG
            - INFO
            - WARNING
            - ERROR
            - CRITICAL
        log_dir (str): Directory for storing log files
        log_name (str): Log file name template (can include {timestamp})
    """

    log_output_type: str = "file"
    log_level: str = "INFO"
    log_dir: str = "."
    log_filename: str = "PETsARD_{timestamp}.log"

    def __post_init__(self):
        """
        Post-initialization method to validate the configuration.
        """
        super().__post_init__()
        if self.log_output_type not in ["stdout", "file", "both"]:
            raise ConfigError("Invalid log_output_type {self.log_output_type}")
        if self.log_level not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
            raise ConfigError("Invalid log_level {self.log_level}")


class Executor:
    """
    Represents an executor that runs a series of operators based on a given configuration.
    """

    def __init__(self, config: str):
        """
        Args:
            config (str): Configuration for the executor.
                - If a valid file path: Load from YAML file
                - Otherwise: Try to parse as YAML string

        Attributes:
            executor_config (ExecutorConfig): The configuration for the executor.
            _logger (logging.Logger): The logger object.
            config (Config): The configuration object.
            status (Status): The status of the executor.
            result (dict): The result of the executor.
        """

        # 1. set the default Executor
        self.executor_config = ExecutorConfig()

        # 2. set the default logger
        self._setup_logger()

        # 3. load the configuration
        if not isinstance(config, str):
            raise ConfigError(
                f"Invalid config type: {type(config).__name__}. "
                f"Expected str (file path or YAML string)."
            )

        self._logger.debug(f"Processing configuration: {config[:100]}...")
        yaml_config: dict = self._get_config(config_input=config)

        self.config = Config(config=yaml_config)
        self.sequence = self.config.sequence
        self.status = Status(config=self.config)
        self.result: dict = {}

        # 4. Infer Schema (if Preprocessor is included in config)
        self._infer_pipeline_schemas(yaml_config)

        # Execution state tracking
        # NOTE: This attribute will be removed in v2.0.0 and replaced with run() return value
        self._execution_completed: bool = False

    def _create_formatter(self) -> logging.Formatter:
        """
        Create log formatter with standardized format.

        Returns:
            logging.Formatter: Configured log formatter with custom name handling
        """

        class PETsARDFormatter(logging.Formatter):
            """Custom formatter that removes 'PETsARD.' prefix from logger names."""

            def format(self, record):
                # Remove 'PETsARD.' prefix from logger name for cleaner output
                if record.name.startswith("PETsARD."):
                    record.name = record.name[8:]  # Remove 'PETsARD.' (8 chars)
                return super().format(record)

        return PETsARDFormatter(
            "%(asctime)s - "  # timestamp
            "%(name)-24s - "  # module name (left align, 24 chars: 'PostprocessorAdapter')
            "%(funcName)-30s - "  # function name (left align, 30 chars: '_log_missing_report_warning')
            "%(levelname)-8s - "  # log level (left align, 8 chars: 'CRITICAL')
            "%(message)s"  # message
        )

    def _create_file_handler(self, formatter: logging.Formatter) -> logging.FileHandler:
        """
        Create file handler for logging.

        Args:
            formatter: Log formatter to use

        Returns:
            logging.FileHandler: Configured file handler
        """
        log_dir = self.executor_config.log_dir

        # Create log directory if it doesn't exist
        if log_dir and not os.path.exists(log_dir):
            os.makedirs(log_dir)

        # Create log file
        timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
        log_filename = self.executor_config.log_filename.replace(
            "{timestamp}", timestamp
        )
        log_path = os.path.join(log_dir, log_filename)

        # Create and configure file handler
        file_handler = logging.FileHandler(log_path)
        file_handler.setFormatter(formatter)
        return file_handler

    def _create_console_handler(
        self, formatter: logging.Formatter
    ) -> logging.StreamHandler:
        """
        Create console handler for logging.

        Args:
            formatter: Log formatter to use

        Returns:
            logging.StreamHandler: Configured console handler
        """
        console_handler = logging.StreamHandler()
        console_handler.setFormatter(formatter)
        return console_handler

    def _setup_logger(self, reconfigure=False):
        """
        Setting up the logger based on ExecutorConfig settings.

        Args:
            reconfigure (bool): If True, clear existing handlers before setup
        """
        if reconfigure:
            # If reconfigure, clear existing handlers
            root_logger = logging.getLogger("PETsARD")
            for handler in root_logger.handlers[:]:
                root_logger.removeHandler(handler)
        else:
            # If new configuration, disable the root logger
            logging.getLogger().handlers = []
            root_logger = logging.getLogger("PETsARD")

        # Setup logging level
        root_logger.setLevel(getattr(logging, self.executor_config.log_level.upper()))

        # Create formatter
        formatter = self._create_formatter()

        # Add appropriate handlers based on configuration
        if self.executor_config.log_output_type in ["file", "both"]:
            file_handler = self._create_file_handler(formatter)
            root_logger.addHandler(file_handler)

        if self.executor_config.log_output_type in ["stdout", "both"]:
            console_handler = self._create_console_handler(formatter)
            root_logger.addHandler(console_handler)

        # Setup this logger as a child of root logger
        self._logger = logging.getLogger(f"PETsARD.{self.__class__.__name__}")

    def _get_config(self, config_input: str) -> dict:
        """
        Load configuration from a file path or YAML string.

        Args:
            config_input: Either a file path or YAML string

        Returns:
            dict: Parsed YAML configuration

        Raises:
            ConfigError: If neither file path nor YAML string is valid
        """
        yaml_config: dict = {}

        # First, try as file path
        if os.path.isfile(config_input):
            self._logger.debug(f"Loading configuration from file: {config_input}")
            try:
                with open(config_input) as yaml_file:
                    yaml_config = yaml.safe_load(yaml_file)
                self._logger.debug("Successfully loaded configuration from file")
            except Exception as e:
                raise ConfigError(
                    f"Failed to load YAML file '{config_input}': {str(e)}"
                ) from e
        else:
            # If not a file, try to parse as YAML string
            self._logger.debug(
                "Input is not a file path, attempting to parse as YAML string"
            )
            try:
                yaml_config = yaml.safe_load(config_input)
                if not isinstance(yaml_config, dict):
                    raise ConfigError(
                        f"YAML string parsed to {type(yaml_config).__name__}, expected dict. "
                        f"Parsed value: {yaml_config}"
                    )
                self._logger.debug(
                    "Successfully parsed configuration from YAML string"
                )
            except yaml.YAMLError as e:
                raise ConfigError(
                    f"Failed to parse input as YAML string. "
                    f"Input (first 200 chars): {config_input[:200]}...\n"
                    f"Error: {str(e)}\n"
                    f"Note: Input was treated as YAML string because it's not a valid file path."
                ) from e
            except Exception as e:
                raise ConfigError(
                    f"Unexpected error while parsing YAML string: {str(e)}"
                ) from e

        # Process Executor config if present
        if "Executor" in yaml_config:
            self.executor_config.update(yaml_config["Executor"])
            self._setup_logger(reconfigure=True)
            self._logger.debug("Logger reconfigured with settings from YAML")
            yaml_config.pop("Executor")

        return yaml_config

    def run(self):
        """
        run(): Runs the operators based on the configuration.

        Note: In v2.0.0, this method will return execution status (success/failed)
        instead of None. Use is_execution_completed() to check completion status
        in the current version.
        """
        # Reset execution state
        self._execution_completed = False
        start_time: float = time.time()
        self._logger.info("Starting PETsARD execution workflow")
        while self.config.config.qsize() > 0:
            ops = self.config.config.get()
            module = self.config.module_flow.get()
            expt = self.config.expt_flow.get()

            self._logger.info(f"Executing {module} with {expt}")
            ops.run(ops.set_input(status=self.status))

            self.status.put(module, expt, ops)

            # CRITICAL: Infer Preprocessor Schema immediately after Loader/Splitter execution
            # This ensures that Synthesizer and other downstream modules get the correct Schema
            if (
                module in ["Loader", "Splitter"]
                and "_preprocessor_config" in self.status.inferred_schemas
            ):
                self._logger.info(
                    f"{module} execution completed, starting Preprocessor Schema inference"
                )
                self.infer_preprocessor_schema()

            # collect result
            self._set_result(module)

        elapsed_time: float = time.time() - start_time
        formatted_elapsed_time: str = str(timedelta(seconds=round(elapsed_time)))
        self._logger.info(
            f"Completed PETsARD execution workflow (elapsed: {formatted_elapsed_time})"
        )

        # Mark execution as completed
        self._execution_completed = True

        # TODO: In v2.0.0, return execution status here
        # return "success"  # or "failed" based on execution result

    def _set_result(self, module: str):
        """
        Get the result for a final module.

        Args:
            module (str): The name of the module.

        Returns:
            None. Update in self.result
        """
        if module == self.sequence[-1]:
            self._logger.debug(f"Collecting final results for {module}")
            full_expt = self.status.get_full_expt()
            full_expt_name = "_".join(
                [f"{module}[{expt}]" for module, expt in full_expt.items()]
            )
            self.result[full_expt_name] = self.status.get_result(module=module)

    def get_result(self):
        """
        Returns the result of the executor.
        """
        return self.result

    def get_timing(self):
        """
        Get execution timing records.

        Returns:
            pd.DataFrame: DataFrame containing execution times for all modules
        """
        return self.status.get_timing_report_data()

    def is_execution_completed(self) -> bool:
        """
        Check if the execution workflow has completed.

        Returns:
            bool: True if execution completed, False otherwise

        Note: This method will be deprecated in v2.0.0.
        Use the return value of run() method instead.
        """
        return self._execution_completed

    def _infer_pipeline_schemas(self, yaml_config: dict) -> None:
        """
        Infer Schema for each stage of the pipeline.

        This method is called during Executor initialization to predict
        input and output SchemaMetadata for each module in the pipeline.

        Args:
            yaml_config: YAML configuration dictionary
        """
        # Only infer if Preprocessor is present in config
        if "Preprocessor" not in yaml_config:
            self._logger.debug("No Preprocessor in config, skipping Schema inference")
            return

        self._logger.debug("Starting Schema inference for pipeline stages")

        try:
            # Infer initial Schema from Loader config (if available)
            # Note: The actual Loader Schema will be provided at runtime by Loader
            # This just prepares the inferencer to receive it

            # Extract Preprocessor configuration
            preprocessor_config = yaml_config.get("Preprocessor", {})

            # Store config in status for later use
            self.status.inferred_schemas["_preprocessor_config"] = preprocessor_config

            self._logger.debug(
                f"Schema inference config prepared: will infer {len(preprocessor_config)} processing types after Loader execution"
            )

        except Exception as e:
            self._logger.warning(f"Error during Schema inference: {e}")
            self._logger.debug("Detailed error", exc_info=True)

    def infer_preprocessor_schema(self) -> None:
        """
        Infer Preprocessor output Schema after Loader execution.

        This method should be called after Loader completes to infer
        how Preprocessor will transform the Schema based on actual data Schema.
        """
        if "Loader" not in self.status.metadata:
            self._logger.warning(
                "Loader Schema not yet available, cannot infer Preprocessor Schema"
            )
            return

        if "_preprocessor_config" not in self.status.inferred_schemas:
            self._logger.debug("No Preprocessor config, skipping Schema inference")
            return

        try:
            loader_schema = self.status.get_metadata("Loader")
            preprocessor_config = self.status.inferred_schemas["_preprocessor_config"]

            self._logger.debug("Starting Preprocessor output Schema inference")

            # Use SchemaInferencer to infer
            inferred_schema = self.status.schema_inferencer.infer_preprocessor_output(
                input_schema=loader_schema, processor_config=preprocessor_config
            )

            # Store inference result
            self.status.inferred_schemas["Preprocessor"] = inferred_schema

            self._logger.debug(
                f"Preprocessor Schema inference completed: {len(inferred_schema.attributes)} fields"
            )

            # Log detailed inference information
            inference_history = self.status.schema_inferencer.get_inference_history()
            if inference_history:
                last_inference = inference_history[-1]
                changes_count = len(last_inference.get("changes", []))
                self._logger.debug(
                    f"Detected {changes_count} field type transformations"
                )

        except Exception as e:
            self._logger.error(f"Error inferring Preprocessor Schema: {e}")
            self._logger.debug("Detailed error", exc_info=True)

    def get_inferred_schema(self, module: str) -> "Schema | None":
        """
        Get inferred Schema for specified module.

        Args:
            module: Module name (e.g., 'Preprocessor')

        Returns:
            Inferred Schema, or None if not exists
        """
        return self.status.inferred_schemas.get(module)
