import os
import re
import shutil
import subprocess
import logging
from typing import List, Optional, Dict, Any, Union

from .constants import ALL_POSSIBLE_SCENARIOS, ALL_POSSIBLE_YEARS, DEFAULT_GCMS


# --- Logging Configuration ---
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)

class MorphingWorkflow:
    """Manages the entire EPW morphing process in a controlled, step-by-step manner.

    This class is designed to be used as a state machine, guiding the user through
    a safe and transparent workflow:
    1. `map_categories()`: Analyze input filenames.
    2. `preview_rename_plan()`: Review the expected output.
    3. `set_morphing_config()`: Define and validate all execution parameters.
    4. `execute_morphing()`: Run the final, time-consuming computation.

    This structure provides clear feedback and validation at each stage, preventing
    common errors before the main process begins.

    Attributes:
        inputs (Dict[str, Any]): A dictionary that stores all user-provided
            configuration from every step of the workflow. It serves as the
            central "memory" for the instance.
        epw_categories (Dict[str, Dict[str, str]]): A dictionary mapping the
            absolute path of each *successfully and completely* categorized EPW
            file to a dictionary of its categories.
            e.g., {'/path/to/sevilla_uhi-1.epw': {'city': 'seville', 'uhi': 'type_1'}}
            This attribute is populated by the `map_categories` method.
        incomplete_epw_categories (Dict[str, Dict[str, str]]): Similar to
            `epw_categories`, but stores files that were mapped but are missing
            one or more expected categories based on the `keyword_mapping` rules.
            This is primarily used for user feedback and is populated by the
            `map_categories` method.
        epws_to_be_morphed (List[str]): The definitive list of absolute EPW file
            paths that will be processed when `execute_morphing()` is called.
            This list is determined by the `run_incomplete_files` flag in the
            `set_morphing_config` step.
        rename_plan (Dict[str, Dict[str, str]]): A detailed mapping that outlines
            the renaming and moving operations. The keys are the source EPW
            paths, and the values are another dictionary where keys are the
            filenames generated by FWG (e.g., 'ssp126_2050.epw') and values
            are their final, absolute destination paths. This is populated by
            the `preview_rename_plan` method.
        is_config_valid (bool): A boolean flag that is set to `True` only if all
            parameters provided in `set_morphing_config` pass the internal
            validation checks. The `execute_morphing` method will not run if
            this flag is `False`.
    """
    def __init__(self):
        self.inputs: Dict[str, Any] = {}
        self.epw_categories: Dict[str, Dict[str, str]] = {}
        self.incomplete_epw_categories: Dict[str, Dict[str, str]] = {}
        self.epws_to_be_morphed: List[str] = []
        self.rename_plan: Dict[str, Dict[str, str]] = {}
        self.is_config_valid: bool = False

    def map_categories(self,
                       epw_files: List[str],
                       input_filename_pattern: Optional[str] = None,
                       keyword_mapping: Optional[Dict[str, Dict[str, Union[str, List[str]]]]] = None):
        """STEP 1: Identifies and maps categories for each EPW file.

        This method populates the `self.epw_categories` and
        `self.incomplete_epw_categories` attributes by analyzing each filename.
        It supports two primary modes of operation:

        1.  **Pattern Extraction with Normalization:**
            When `input_filename_pattern` is provided, it is used as a regular
            expression to extract "raw" values from structured filenames. The
            pattern MUST use named capture groups (e.g., `(?P<city>...)`).
            If `keyword_mapping` is also provided, it is then used as a
            translation dictionary to normalize the extracted raw values into
            their final, clean form.

        2.  **Keyword-Only Search:**
            When `input_filename_pattern` is `None` and `keyword_mapping` is
            provided, the method searches the entire filename for any of the
            keywords defined in the mapping. This is ideal for unstructured
            or irregularly named files. In this mode, it also checks if all
            defined categories were found for each file.

        Args:
            epw_files (List[str]): A list of absolute or relative paths to the
                EPW files that need to be processed.
            input_filename_pattern (Optional[str], optional): A Python regex
                string with named capture groups. Defaults to None.
                Example: `r'(?P<city>.*?)_(?P<uhi_type>.*)'`
            keyword_mapping (Optional[Dict[str, Dict[str, Union[str, List[str]]]]], optional):
                A dictionary of rules for keyword searching or normalization.
                The innermost value can be a single string for one keyword or a
                list of strings for multiple keywords. Defaults to None.
                Structure: `{category: {final_value: 'keyword' or ['keyword1', 'keyword2']}}`

        Raises:
            ValueError: If neither of `input_filename_pattern` and
                `keyword_mapping` are provided.
        """
        logging.info("--- Step 1: Mapping categories from filenames ---")

        # Ensure that at least one mapping strategy is provided.
        if not input_filename_pattern and not keyword_mapping:
            raise ValueError("You must provide at least one mapping method: 'input_filename_pattern' or 'keyword_mapping'.")

        # Store the provided arguments in the instance's state.
        self.inputs['epw_files'] = epw_files

        # Reset state attributes to ensure the method is idempotent.
        self.epw_categories = {}
        self.incomplete_epw_categories = {}

        # Loop through each provided EPW file path.
        for epw_path in epw_files:
            # Check if the file actually exists before processing.
            if not os.path.exists(epw_path):
                logging.warning(f"EPW file not found, skipping: {epw_path}")
                continue

            # This dictionary will hold the final categories for the current file.
            file_categories = {}
            # Get the filename without the extension (e.g., 'sevilla_uhi-tipo-1').
            epw_base_name = os.path.splitext(os.path.basename(epw_path))[0]

            if input_filename_pattern:
                # --- Mode 1: Pattern Extraction followed by Normalization ---

                # Attempt to match the regex pattern against the filename.
                match = re.search(input_filename_pattern, epw_base_name)
                if not match:
                    logging.warning(f"Pattern did not match '{epw_base_name}'. Skipping.")
                    continue

                # Extract the raw values from the named capture groups.
                raw_values = match.groupdict()
                normalized_values = {}

                # Iterate through the extracted {category: raw_value} pairs.
                for category, raw_value in raw_values.items():
                    # Skip optional groups in the regex that did not match anything.
                    if raw_value is None:
                        continue

                    # Start with the raw value as the default.
                    final_value = raw_value

                    # If a mapping dictionary is provided, try to normalize the raw value.
                    if keyword_mapping and category in keyword_mapping:
                        # Look for the raw value in the keyword lists.
                        for mapped_val, keywords_or_str in keyword_mapping[category].items():
                            # --- FLEXIBILITY LOGIC ---
                            # Normalize a single string to a list with one item for consistent processing.
                            keywords_list = [keywords_or_str] if isinstance(keywords_or_str, str) else keywords_or_str
                            if raw_value.lower() in [k.lower() for k in keywords_list]:
                                final_value = mapped_val  # Replace with the clean value.
                                break
                    normalized_values[category] = final_value
                file_categories = normalized_values

            elif keyword_mapping:
                # --- Mode 2: Keyword-only search (no pattern) ---

                # Use the full, lowercase filename for searching.
                epw_name_lower = os.path.basename(epw_path).lower()
                # Iterate through the user-defined mapping rules.
                for category, rules in keyword_mapping.items():
                    for final_value, keywords_or_str in rules.items():
                        # --- FLEXIBILITY LOGIC ---
                        # Normalize a single string to a list with one item for consistent processing.
                        keywords_list = [keywords_or_str] if isinstance(keywords_or_str, str) else keywords_or_str
                        # If any keyword is found in the filename, assign the category and stop.
                        if any(keyword.lower() in epw_name_lower for keyword in keywords_list):
                            file_categories[category] = final_value
                            break  # Move to the next category.

            # After processing, check if any categories were successfully found.
            if file_categories:
                logging.info(f"Mapped '{epw_path}': {file_categories}")

                # In keyword-only mode, check for completeness.
                if keyword_mapping and not input_filename_pattern:
                    all_defined_categories = set(keyword_mapping.keys())
                    found_categories = set(file_categories.keys())

                    # If not all categories were found, classify the file as incomplete.
                    if len(found_categories) < len(all_defined_categories):
                        missing = all_defined_categories - found_categories
                        logging.warning(f"File '{os.path.basename(epw_path)}' is missing categories: {list(missing)}.")
                        self.incomplete_epw_categories[epw_path] = file_categories
                    else:
                        # The file is complete.
                        self.epw_categories[epw_path] = file_categories
                else:
                    # In pattern mode, we assume the pattern defines completeness.
                    self.epw_categories[epw_path] = file_categories
            else:
                # If no categories could be mapped, warn the user.
                logging.warning(f"Could not map any categories for '{epw_path}'. Skipping.")

        logging.info("Category mapping complete.")

    def preview_rename_plan(self,
                            final_output_dir: str,
                            output_filename_pattern: str,
                            scenario_mapping: Optional[Dict[str, str]] = None):
        """STEP 2: Generates and displays a plan for renaming and moving files.

        This method performs a non-destructive "dry run" of the file processing
        logic. It iterates through all successfully mapped files (both complete
        and incomplete) and, for each one, simulates the final filenames that
        will be created for every possible scenario and year generated by the
        FutureWeatherGenerator tool.

        It performs critical validation by checking if the placeholders in the
        `output_filename_pattern` can be satisfied by the categories mapped
        for each file. If a file is missing a required category, it will print
        a clear error message for that file and exclude it from the final
        execution plan.

        This method populates the `self.rename_plan` attribute, which is
        essential for the `execute_morphing` step.

        Args:
            final_output_dir (str): The absolute or relative path to the
                directory where the final, renamed files will be moved.
            output_filename_pattern (str): A template string for the final
                filenames. It must use placeholders in braces that match the
                category names from the `map_categories` step.
                It MUST contain the placeholders `{ssp}` and `{year}`
                to ensure unique filenames.
                Example: `{city}_{uhi_type}_{ssp_full_name}_{year}`
            scenario_mapping (Optional[Dict[str, str]], optional): A dictionary
                to translate raw scenario names (e.g., 'ssp126') into a
                descriptive format (e.g., 'SSP1-2.6') for the `{ssp}`
                placeholder. Defaults to None.

        Raises:
            RuntimeError: If `map_categories()` has not been run first.
            ValueError: If `output_filename_pattern` is missing the required
                `{ssp}` or `{year}` placeholders.
        """
        if not self.epw_categories and not self.incomplete_epw_categories:
            raise RuntimeError("Please run map_categories() first. No files were successfully mapped.")

        logging.info("--- Step 2: Generating rename and move plan ---")

        # --- Validation: Ensure pattern contains required placeholders ---
        # This is a crucial safeguard to prevent multiple generated files
        # (e.g., ssp126_2050.epw and ssp245_2050.epw) from being renamed to
        # the exact same output filename, which would cause data loss.
        if '{ssp}' not in output_filename_pattern or '{year}' not in output_filename_pattern:
            raise ValueError(
                "The 'output_filename_pattern' must contain both '{ssp}' and '{year}' placeholders "
                "to prevent generated files from being overwritten."
            )
        # Store the configuration for this step in the instance's state.
        self.inputs.update({
            'final_output_dir': final_output_dir,
            'output_filename_pattern': output_filename_pattern,
            'scenario_mapping': scenario_mapping or {}
        })

        # Reset the rename plan to ensure idempotency.
        self.rename_plan = {}
        # Combine complete and incomplete files to provide a full preview.
        all_mapped_files = {**self.epw_categories, **self.incomplete_epw_categories}

        # --- Pre-computation for validation ---
        # Use regex to find all placeholders like {city}, {year} in the pattern.
        required_placeholders = set(re.findall(r'{(.*?)}', output_filename_pattern))
        # These placeholders are generated by the script, not the user mapping.
        auto_placeholders = {'scenario', 'ssp', 'year'}
        # Determine which placeholders MUST be provided by the mapping step.
        required_from_mapping = required_placeholders - auto_placeholders

        # --- Print plan header ---
        print("\n" + "=" * 60 + "\n          MORPHING AND RENAMING PREVIEW\n" + "=" * 60)
        print(f"\nFinal Output Directory: {os.path.abspath(final_output_dir)}")

        # Iterate through every file that was successfully mapped in Step 1.
        for epw_path, mapped_data in all_mapped_files.items():
            # Check if the file was flagged as incomplete for clear reporting.
            is_incomplete = epw_path in self.incomplete_epw_categories
            status_flag = " [INCOMPLETE MAPPING]" if is_incomplete else ""
            print(f"\n  For input file: {os.path.basename(epw_path)}{status_flag}")

            # --- Validation: Check if this file has all needed categories ---
            missing_keys = required_from_mapping - set(mapped_data.keys())
            if missing_keys:
                # If keys are missing, report the error and skip this file.
                print(f"    -> ERROR: This file is missing required categories for the output pattern: {list(missing_keys)}. Renaming will fail.")
                continue  # Move to the next file in the preview.

            # If validation passes, initialize the plan for this file.
            self.rename_plan[epw_path] = {}

            # Iterate through all possible outputs the FWG tool will create.
            for year in ALL_POSSIBLE_YEARS:
                for scenario in ALL_POSSIBLE_SCENARIOS:
                    # Create a complete data dictionary for formatting the filename.
                    filename_data = {
                        **mapped_data,
                        'scenario': scenario,
                        'ssp': self.inputs['scenario_mapping'].get(scenario, scenario),
                        'year': year
                    }

                    # Generate the final filename.
                    new_base_name = output_filename_pattern.format(**filename_data)
                    final_epw_path = os.path.join(final_output_dir, new_base_name + ".epw")

                    # The key for the plan is the raw filename the tool creates.
                    generated_file_key = f"{scenario}_{year}.epw"

                    # Populate the plan: map the generated name to its final path.
                    self.rename_plan[epw_path][generated_file_key] = final_epw_path
                    print(f"    -> Generated '{generated_file_key}' will be moved to: {os.path.abspath(final_epw_path)}")

        print("=" * 60 + "\nPreview complete. If this plan is correct, call set_morphing_config().")

    def _validate_fwg_params(self, params: Dict[str, Any]) -> bool:
        """(Private) Validates the final FWG parameters against known constraints.

        This helper method is called by `set_morphing_config` to ensure that the
        parameters provided by the user are valid before attempting to run the
        external tool.

        For each validation that fails, it logs a `WARNING` to the console,
        providing specific feedback to the user. It does not raise an error
        itself but returns a boolean flag indicating the overall validity.

        Args:
            params (Dict[str, Any]): A dictionary containing the final,
                user-friendly `fwg_` parameters after all overrides have been
                applied.

        Returns:
            bool: True if all parameters pass validation, False otherwise.
        """
        # Start with the assumption that the configuration is valid.
        # Any single failure will flip this to False.
        is_valid = True

        # --- GCM Validation ---
        # Check if the user provided a custom list of GCMs.
        if params.get('gcms'):
            # Iterate through each provided GCM name.
            for gcm in params['gcms']:
                # Check for membership in the set of known GCMs (a set is used for efficient lookup).
                if gcm not in DEFAULT_GCMS:
                    logging.warning(f"Validation failed: GCM '{gcm}' is not in the list of known GCMs.")
                    is_valid = False

        # --- Float Range Validation ---
        # Check if the standard deviation shifts are within the allowed [-2.0, 2.0] range.
        if not -2.0 <= params.get('winter_sd_shift', 0.0) <= 2.0:
            logging.warning(f"Validation failed: 'fwg_winter_sd_shift' must be between -2.0 and 2.0.")
            is_valid = False
        if not -2.0 <= params.get('summer_sd_shift', 0.0) <= 2.0:
            logging.warning(f"Validation failed: 'fwg_summer_sd_shift' must be between -2.0 and 2.0.")
            is_valid = False

        # --- Integer Range Validation ---
        # Check if the month transition hours are within the allowed [0, 336] range.
        if not 0 <= params.get('month_transition_hours', 72) <= 336:
            logging.warning(f"Validation failed: 'fwg_month_transition_hours' must be between 0 and 336.")
            is_valid = False
        # Check if the Local Climate Zone (LCZ) values are within the allowed [1, 17] range.
        if not 1 <= params.get('epw_original_lcz', 1) <= 17:
            logging.warning(f"Validation failed: 'fwg_epw_original_lcz' must be between 1 and 17.")
            is_valid = False
        if not 1 <= params.get('target_uhi_lcz', 1) <= 17:
            logging.warning(f"Validation failed: 'fwg_target_uhi_lcz' must be between 1 and 17.")
            is_valid = False

        # --- Integer Set Validation ---
        # Define the allowed integer values for specific parameters.
        validations = {
            'interpolation_method_id': {0, 1, 2},
            'solar_hour_adjustment': {0, 1, 2},
            'diffuse_irradiation_model': {0, 1, 2}
        }
        # Loop through the validation rules.
        for param_name, valid_values in validations.items():
            # Check if the user's value is in the set of allowed values.
            if params.get(param_name) not in valid_values:
                logging.warning(f"Validation failed: '{param_name}' has value {params.get(param_name)}, but allowed values are {valid_values}.")
                is_valid = False

        # Return the final validity status.
        return is_valid

    def set_morphing_config(self, *,
                            fwg_jar_path: str,
                            run_incomplete_files: bool = False,
                            delete_temp_files: bool = True,
                            temp_base_dir: str = './morphing_temp_results',
                            fwg_show_tool_output: bool = False,
                            fwg_params: Optional[Dict[str, Any]] = None,
                            # --- Explicit FutureWeatherGenerator Arguments (Overrides fwg_params) ---
                            fwg_gcms: Optional[List[str]] = None, fwg_create_ensemble: bool = True,
                            fwg_winter_sd_shift: float = 0.0, fwg_summer_sd_shift: float = 0.0,
                            fwg_month_transition_hours: int = 72, fwg_use_multithreading: bool = True,
                            fwg_interpolation_method_id: int = 0, fwg_limit_variables: bool = True,
                            fwg_solar_hour_adjustment: int = 1, fwg_diffuse_irradiation_model: int = 1,
                            fwg_add_uhi: bool = True, fwg_epw_original_lcz: int = 14, fwg_target_uhi_lcz: int = 1):
        """STEP 3: Sets, validates, and reviews the configuration for the morphing execution.

        This method acts as the final confirmation gate before running the main
        computation. It gathers all parameters required for the external
        FutureWeatherGenerator tool, validates them against known constraints,
        and stores them in the instance's state.

        It provides a clear, final summary of all settings for the user to
        review. If any parameters are invalid (e.g., a GCM name is incorrect
        or a value is out of range), it will log warnings and set the internal
        `self.is_config_valid` flag to `False`, which prevents `execute_morphing`
        from running.

        The method supports a flexible parameter system:
        - A base configuration can be passed via the `fwg_params` dictionary.
        - Any individual `fwg_` argument provided will override the corresponding
          value in the `fwg_params` dictionary.

        Args:
            fwg_jar_path (str): The absolute or relative path to the
                `FutureWeatherGenerator.jar` file.
            run_incomplete_files (bool, optional): If True, the workflow will
                also process files that were only partially categorized in the
                `map_categories` step. Defaults to False.
            delete_temp_files (bool, optional): If True, the temporary folders
                created for each EPW file will be deleted after processing.
                If False, they will be kept for inspection. Defaults to True.
            temp_base_dir (str, optional): The base directory where temporary
                folders will be created. Defaults to './morphing_temp_results'.
            fwg_show_tool_output (bool, optional): If True, prints the FWG tool's
                console output in real-time. If False, the output is captured
                and only shown if an error occurs. Defaults to False.
            fwg_params (Optional[Dict[str, Any]], optional): A dictionary
                containing a base set of parameters for the FWG tool.
                Defaults to None.
            fwg_gcms (Optional[List[str]], optional): List of GCMs to use.
                If None, the tool's default list is used. Overrides `fwg_params`.
            fwg_create_ensemble (bool, optional): If True, creates an ensemble
                from the selected GCMs. Overrides `fwg_params`.
            fwg_winter_sd_shift (float, optional): Standard deviation shift for
                winter peak month (-2.0 to 2.0). Overrides `fwg_params`.
            fwg_summer_sd_shift (float, optional): Standard deviation shift for
                summer peak month (-2.0 to 2.0). Overrides `fwg_params`.
            fwg_month_transition_hours (int, optional): Hours to smooth the
                transition between months (0-336). Overrides `fwg_params`.
            fwg_use_multithreading (bool, optional): Use multithread computation.
                Overrides `fwg_params`.
            fwg_interpolation_method_id (int, optional): Grid method ID (0, 1, 2).
                Overrides `fwg_params`.
            fwg_limit_variables (bool, optional): Bound variables to physical
                limits. Overrides `fwg_params`.
            fwg_solar_hour_adjustment (int, optional): Solar hour adjustment
                option (0, 1, 2). Overrides `fwg_params`.
            fwg_diffuse_irradiation_model (int, optional): Diffuse solar
                irradiation model option (0, 1, 2). Overrides `fwg_params`.
            fwg_add_uhi (bool, optional): Option to pre-process UHI effect.
                Overrides `fwg_params`.
            fwg_epw_original_lcz (int, optional): LCZ of the original EPW (1-17).
                Overrides `fwg_params`.
            fwg_target_uhi_lcz (int, optional): Target LCZ of the generated
                EPW (1-17). Overrides `fwg_params`.
        """
        logging.info("--- Step 3: Setting and Validating Morphing Configuration ---")

        # --- Parameter Handling with Override Logic ---
        # Start with the base dictionary, or an empty one if not provided.
        final_fwg_params = fwg_params.copy() if fwg_params else {}

        # Create a dictionary of all individual keyword arguments to override the base.
        overrides = {
            'gcms': fwg_gcms, 'create_ensemble': fwg_create_ensemble,
            'winter_sd_shift': fwg_winter_sd_shift, 'summer_sd_shift': fwg_summer_sd_shift,
            'month_transition_hours': fwg_month_transition_hours, 'use_multithreading': fwg_use_multithreading,
            'interpolation_method_id': fwg_interpolation_method_id, 'limit_variables': fwg_limit_variables,
            'solar_hour_adjustment': fwg_solar_hour_adjustment, 'diffuse_irradiation_model': fwg_diffuse_irradiation_model,
            'add_uhi': fwg_add_uhi, 'epw_original_lcz': fwg_epw_original_lcz, 'target_uhi_lcz': fwg_target_uhi_lcz
        }
        # Apply the overrides. Any value explicitly passed will replace the one from fwg_params.
        final_fwg_params.update(overrides)

        # --- Validation ---
        # Validate the final set of parameters and store the result.
        self.is_config_valid = self._validate_fwg_params(final_fwg_params)

        # --- State Preparation ---
        # Create a user-friendly version for review, filling in defaults if needed.
        review_params = final_fwg_params.copy()
        if review_params.get('gcms') is None:
            review_params['gcms'] = list(DEFAULT_GCMS)

        # Create a version formatted specifically for the command line.
        formatted_params = {
            'gcms': ",".join(review_params['gcms']),
            'ensemble': '1' if review_params['create_ensemble'] else '0',
            'sd_shift': f"{review_params['winter_sd_shift']}:{review_params['summer_sd_shift']}",
            'month_transition_hours': str(review_params['month_transition_hours']),
            'do_multithred_computation': str(review_params['use_multithreading']).lower(),
            'interpolation_method_id': str(review_params['interpolation_method_id']),
            'do_limit_variables': str(review_params['limit_variables']).lower(),
            'solar_hour_adjustment_option': str(review_params['solar_hour_adjustment']),
            'diffuse_irradiation_model_option': str(review_params['diffuse_irradiation_model']),
            'uhi_combined': f"{'1' if review_params['add_uhi'] else '0'}:{review_params['epw_original_lcz']}:{review_params['target_uhi_lcz']}"
        }

        # Determine the final list of files to be processed.
        files_to_process = list(self.epw_categories.keys())
        if run_incomplete_files:
            files_to_process.extend(self.incomplete_epw_categories.keys())
        self.epws_to_be_morphed = files_to_process

        # Store all settings in the instance's central 'inputs' dictionary.
        self.inputs.update({
            'fwg_jar_path': fwg_jar_path,
            'run_incomplete_files': run_incomplete_files,
            'delete_temp_files': delete_temp_files,
            'temp_base_dir': temp_base_dir,
            'show_tool_output': fwg_show_tool_output,
            'fwg_params': review_params,  # User-friendly version
            'fwg_params_formatted': formatted_params  # Command-line version
        })

        # --- Final Review Printout ---
        print("\n" + "=" * 60 + "\n          MORPHING CONFIGURATION REVIEW\n" + "=" * 60)
        if not self.is_config_valid:
            print("!!! WARNING: Configuration has invalid parameters. See logs above. Execution will be blocked. !!!\n")

        print(f"  - FWG JAR Path: {self.inputs['fwg_jar_path']}")
        print(f"  - Run Incomplete Files: {self.inputs['run_incomplete_files']}")
        print(f"  - Delete Temporary Files: {self.inputs['delete_temp_files']}")
        print(f"  - Show Tool Output: {self.inputs['show_tool_output']}")
        print(f"\n  - EPWs to be Morphed ({len(self.epws_to_be_morphed)} files):")
        for epw in self.epws_to_be_morphed:
            print(f"    - {os.path.basename(epw)}")

        print("\n  --- Final Formatted FWG Parameters ---")
        for key, value in self.inputs['fwg_params_formatted'].items():
            print(f"    - {key}: {value}")

        print("=" * 60 + "\nConfiguration set. Call execute_morphing() to start the process.")

    def execute_morphing(self):
        """STEP 4: Executes the morphing process using the previously set configuration.

        This method is the final action in the workflow and takes no arguments.
        It relies entirely on the state and configuration set by the previous
        steps (`map_categories`, `preview_rename_plan`, and `set_morphing_config`).

        It will first check if the configuration is valid. If not, it will
        raise an error to prevent running a faulty process. It then iterates
        through the list of files determined in the configuration step
        (`self.epws_to_be_morphed`), calling the external Java tool for each one.

        After each successful morphing operation, it processes the generated
        files, moving and renaming the `.epw` and `.stat` files according to
        the `rename_plan`. Finally, it handles the cleanup of temporary
        directories if requested.

        Raises:
            RuntimeError: If `set_morphing_config()` has not been run first, or
                if the configuration was found to be invalid during that step.
        """
        # --- Guard Clauses ---
        # Ensure the configuration has been set before proceeding.
        if 'fwg_params' not in self.inputs:
            raise RuntimeError("Configuration has not been set. Please run set_morphing_config() first.")
        # Block execution if the configuration was found to be invalid.
        if not self.is_config_valid:
            raise RuntimeError("Morphing configuration is invalid. Please correct the errors reported during set_morphing_config() and run it again.")

        logging.info("--- Step 4: Executing morphing workflow ---")

        # Create the final output and temporary base directories if they don't exist.
        os.makedirs(self.inputs['final_output_dir'], exist_ok=True)
        os.makedirs(self.inputs['temp_base_dir'], exist_ok=True)

        # Iterate through the definitive list of files to be processed.
        for epw_path in self.epws_to_be_morphed:
            # A final check to ensure a valid rename plan exists for this file.
            if epw_path not in self.rename_plan:
                logging.warning(f"Skipping '{os.path.basename(epw_path)}' as it had errors during the preview stage.")
                continue

            # Create a unique temporary subdirectory for this specific EPW file.
            temp_epw_output_dir = os.path.join(self.inputs['temp_base_dir'], os.path.splitext(os.path.basename(epw_path))[0])
            os.makedirs(temp_epw_output_dir, exist_ok=True)

            # Call the private helper method to run the external tool.
            success = self._execute_single_morph(epw_path, temp_epw_output_dir)

            # Only process the output files if the morphing was successful.
            if success:
                self._process_generated_files(epw_path, temp_epw_output_dir)
                # Check the instance's state to decide whether to clean up.
                if self.inputs.get('delete_temp_files', False):
                    logging.info(f"Deleting temporary directory: {temp_epw_output_dir}")
                    shutil.rmtree(temp_epw_output_dir)

        logging.info("Morphing workflow finished.")

    def _execute_single_morph(self, epw_path: str, temp_output_dir: str) -> bool:
        """(Private) Executes the external Java tool for a single EPW file.

        This helper method performs several key tasks:
        1. Copies the source EPW file into its dedicated temporary directory,
           as the tool expects the input to be in the output location.
        2. Constructs the full command-line argument list using the pre-formatted
           parameters stored in the instance's state.
        3. Prints a detailed summary of the command and parameters for user visibility.
        4. Runs the `java -cp ...` command using `subprocess.run`.
        5. Manages the real-time display or capture of the tool's console output.
        6. Handles and reports errors if the subprocess fails.

        Args:
            epw_path (str): The absolute path to the source EPW file.
            temp_output_dir (str): The path to the dedicated temporary directory
                for this EPW file's output.

        Returns:
            bool: True if the subprocess completed successfully, False otherwise.
        """
        # Copy the source EPW into the temporary folder before running the tool.
        try:
            temp_epw_path = os.path.join(temp_output_dir, os.path.basename(epw_path))
            shutil.copy2(epw_path, temp_epw_path)
            logging.info(f"Copied input file to temporary directory: {temp_epw_path}")
        except Exception as e:
            logging.error(f"Failed to copy EPW to temporary directory: {e}")
            return False

        # Retrieve the pre-formatted parameters for the command line.
        formatted_params = self.inputs['fwg_params_formatted']

        # Build the command as a list of strings for subprocess.
        command = [
            'java', '-cp', self.inputs['fwg_jar_path'], 'futureweathergenerator.Morph',
            os.path.abspath(temp_epw_path),  # Use the path to the *copied* EPW
            formatted_params['gcms'],
            formatted_params['ensemble'],
            formatted_params['sd_shift'],
            formatted_params['month_transition_hours'],
            os.path.abspath(temp_output_dir) + os.sep,
            formatted_params['do_multithred_computation'],
            formatted_params['interpolation_method_id'],
            formatted_params['do_limit_variables'],
            formatted_params['solar_hour_adjustment_option'],
            formatted_params['diffuse_irradiation_model_option'],
            formatted_params['uhi_combined']
        ]

        # Print a detailed summary for the user before execution.
        print("\n" + "-" * 20, f"Executing FWG for {os.path.basename(epw_path)}", "-" * 20)
        print("  Full Command:", ' '.join(command))

        # Determine whether to show the tool's output live or capture it.
        show_output = self.inputs.get('show_tool_output', False)
        stdout_dest = None if show_output else subprocess.PIPE
        stderr_dest = None if show_output else subprocess.PIPE

        if show_output:
            print("  --- FWG Real-time Output ---")

        # Execute the command.
        try:
            subprocess.run(command, text=True, check=True, timeout=600, stdout=stdout_dest, stderr=stderr_dest)
            if show_output:
                print("  --- End of FWG Output ---")
            return True
        except subprocess.CalledProcessError as e:
            # Handle errors from the Java tool itself.
            logging.error(f"Error morphing {os.path.basename(epw_path)}:")
            if e.stdout: logging.error(f"STDOUT:\n{e.stdout}")
            if e.stderr: logging.error(f"STDERR:\n{e.stderr}")
            return False

    def _process_generated_files(self, source_epw_path: str, temp_dir: str):
        """(Private) Moves and renames generated .epw and .stat files.

        This helper method iterates through all files in a temporary directory
        after a successful morphing run. It specifically looks for `.epw` and
        `.stat` files and matches them against the `rename_plan` created in
        Step 2.

        - If a file is an `.epw` or `.stat` and has a corresponding rule in the
          plan, it is moved to the final output directory with its new name.
        - Auxiliary files (`.csv`, `.log`, etc.) are ignored and left in the
          temporary directory for later cleanup.
        - The original source EPW that was copied in is also ignored.

        Args:
            source_epw_path (str): The path to the original source EPW file,
                used to look up the correct renaming plan.
            temp_dir (str): The temporary directory containing the generated files.
        """
        logging.info(f"Processing generated files in: {temp_dir}")

        # Get the specific renaming rules for this source EPW.
        plan_for_this_epw = self.rename_plan.get(source_epw_path, {})

        # Iterate through all files created by the tool.
        for generated_file in os.listdir(temp_dir):
            # Ignore the original EPW file that was copied into this directory.
            if generated_file == os.path.basename(source_epw_path):
                continue

            # Only process .epw and .stat files.
            if not (generated_file.endswith(".epw") or generated_file.endswith(".stat")):
                logging.info(f"Skipping auxiliary file: '{generated_file}'")
                continue

            destination_path = None
            # Find the correct renaming rule for this specific file.
            for expected_key, final_epw_path in plan_for_this_epw.items():
                # The key is like 'ssp126_2050.epw'. Check if its base name
                # is a substring of the generated file (handles prefixes like 'Ensemble_').
                if os.path.splitext(expected_key)[0] in generated_file:
                    # If it's a match, determine the final path based on the extension.
                    if generated_file.endswith(".epw"):
                        destination_path = final_epw_path
                    elif generated_file.endswith(".stat"):
                        destination_path = os.path.splitext(final_epw_path)[0] + ".stat"

                    break  # Match found, stop searching.

            if destination_path:
                source_path = os.path.join(temp_dir, generated_file)
                logging.info(f"Moving '{source_path}' to '{destination_path}'")
                shutil.move(source_path, destination_path)
            else:
                # This should only happen for unexpected .epw or .stat files.
                logging.warning(f"Could not find a rename plan for file '{generated_file}'. It will be left in the temp directory.")
