"""ConfigManage creates configuration files from folders and can create components
for reports from YAML config files.
"""

import json
import logging
import os
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union

from . import report as r
from .utils import assert_enum_value, get_logger, is_pyvis_html


class ConfigManager:
    """
    Class for handling metadata of reports from YAML config file and creating report
    objects.
    """

    def __init__(self, logger: Optional[logging.Logger] = None, max_depth: int = 2):
        """
        Initializes the ConfigManager with a logger.

        Parameters
        ----------
        logger : logging.Logger, optional
            A logger instance for the class.
            If not provided, a default logger will be created.
        max_depth : int, optional
            The maximum depth of the directory structure to consider when generating
            the report config from a directory.
            The default is 2, which means it will include sections and subsections.
        """
        if logger is None:
            logger, _ = get_logger("report")
        self.logger = logger
        self.max_depth = max_depth

    def _create_title_fromdir(self, file_dirname: str) -> str:
        """
        Infers title from a file or directory, removing leading numeric prefixes.

        Parameters
        ----------
        file_dirname : str
            The file or directory name to infer the title from.

        Returns
        -------
        str
            A title generated from the file or directory name.
        """
        # Remove leading numbers and underscores if they exist
        name = os.path.splitext(file_dirname)[0]
        parts = name.split("_", 1)
        title = parts[1] if parts[0].isdigit() and len(parts) > 1 else name
        return title.replace("_", " ").title()

    def _create_component_config_fromfile(self, file_path: Path) -> Dict[str, str]:
        """
        Infers a component config from a file, including component type, plot type,
        and additional fields.

        Parameters
        ----------
        file_path : Path
            The file path to analyze.

        Returns
        -------
        component_config : Dict[str, str]
            A dictionary containing inferred component configuration.
        """
        file_ext = file_path.suffix.lower()
        component_config = {}

        # Add title, file path, and description
        component_config["title"] = self._create_title_fromdir(file_path.name)
        component_config["file_path"] = (
            file_path.resolve().as_posix()
        )  # ! needs to be posix for all OS support
        component_config["description"] = ""
        component_config["caption"] = ""  # ? It is not populated here

        # Infer component config
        if file_ext in [
            r.DataFrameFormat.CSV.value_with_dot,
            r.DataFrameFormat.TXT.value_with_dot,
        ]:
            # Check for CSVNetworkFormat keywords
            if "edgelist" in file_path.stem.lower():
                component_config["component_type"] = r.ComponentType.PLOT.value
                component_config["plot_type"] = r.PlotType.INTERACTIVE_NETWORK.value
                component_config["csv_network_format"] = (
                    r.CSVNetworkFormat.EDGELIST.value
                )
            elif "adjlist" in file_path.stem.lower():
                component_config["component_type"] = r.ComponentType.PLOT.value
                component_config["plot_type"] = r.PlotType.INTERACTIVE_NETWORK.value
                component_config["csv_network_format"] = (
                    r.CSVNetworkFormat.ADJLIST.value
                )
            # Fill the config with dataframe content
            else:
                component_config["component_type"] = r.ComponentType.DATAFRAME.value
                component_config["file_format"] = (
                    r.DataFrameFormat.CSV.value
                    if file_ext == r.DataFrameFormat.CSV.value_with_dot
                    else r.DataFrameFormat.TXT.value
                )
                component_config["delimiter"] = (
                    "," if file_ext == r.DataFrameFormat.CSV.value_with_dot else "\\t"
                )
        # Check other DataframeFormats than csv and txt
        elif file_ext in [
            fmt.value_with_dot
            for fmt in r.DataFrameFormat
            if fmt not in [r.DataFrameFormat.CSV, r.DataFrameFormat.TXT]
        ]:
            component_config["component_type"] = r.ComponentType.DATAFRAME.value
            component_config["file_format"] = next(
                fmt.value for fmt in r.DataFrameFormat if fmt.value_with_dot == file_ext
            )
        elif file_ext == ".html":
            if is_pyvis_html(file_path):
                component_config["component_type"] = r.ComponentType.PLOT.value
                component_config["plot_type"] = r.PlotType.INTERACTIVE_NETWORK.value
            else:
                component_config["component_type"] = r.ComponentType.HTML.value
        # Check for network formats
        elif file_ext in [fmt.value_with_dot for fmt in r.NetworkFormat]:
            component_config["component_type"] = r.ComponentType.PLOT.value
            if file_ext in [
                r.NetworkFormat.PNG.value_with_dot,
                r.NetworkFormat.JPG.value_with_dot,
                r.NetworkFormat.JPEG.value_with_dot,
                r.NetworkFormat.SVG.value_with_dot,
            ]:
                component_config["plot_type"] = r.PlotType.STATIC.value
            else:
                component_config["plot_type"] = r.PlotType.INTERACTIVE_NETWORK.value
        # Check for interactive plots
        elif file_ext == ".json":
            component_config["component_type"] = r.ComponentType.PLOT.value
            try:
                with open(file_path, "r", encoding="utf-8") as f:
                    json_data = json.load(f)
                if "$schema" in json_data:
                    component_config["plot_type"] = r.PlotType.ALTAIR.value
                else:
                    component_config["plot_type"] = r.PlotType.PLOTLY.value
            except Exception as e:
                self.logger.warning(
                    "Could not parse JSON file %s: %s", file_path, e, exc_info=True
                )
                component_config["plot_type"] = "unknown"
        elif file_ext == ".md":
            component_config["component_type"] = r.ComponentType.MARKDOWN.value
        else:
            if not file_ext:
                # hidden files starting with a dot
                file_ext = file_path.name
            self.logger.error(
                "Unsupported file extension: %s. Skipping file: %s", file_ext, file_path
            )
            return None

        return component_config

    def _sort_paths_by_numprefix(self, paths: List[Path]) -> List[Path]:
        """
        Sorts a list of Paths by numeric prefixes in their names, placing non-numeric
        items at the end.

        Parameters
        ----------
        paths : List[Path]
            The list of Path objects to sort.

        Returns
        -------
        List[Path]
            The sorted list of Path objects.
        """

        def get_sort_key(path: Path) -> tuple:
            parts = path.name.split("_", 1)
            if parts[0].isdigit():
                numeric_prefix = int(parts[0])
            else:
                # Non-numeric prefixes go to the end
                numeric_prefix = float("inf")
            return numeric_prefix, path.name.lower()

        return sorted(paths, key=get_sort_key)

    def _read_description_file(self, folder_path: Path) -> str:
        """
        Reads the content of a description.md file if it exists in the given folder.

        Parameters
        ----------
        folder_path : Path
            Path to the folder where description.md might be located.

        Returns
        -------
        str
            Content of the description.md file if found, otherwise an empty string.
        """
        description_file = folder_path / "description.md"
        if description_file.exists():
            ret = description_file.read_text().strip()
            return f"{ret}\n"
        return ""

    def _read_home_image_file(self, folder_path: Path) -> str:
        """
        Looks for an image file named 'home_image' with any supported image extension
        in the given folder.

        Parameters
        ----------
        folder_path : Path
            Path to the folder where the 'home_image' file might be located.

        Returns
        -------
        str
            Path to the 'home_image' image file as a string if found, otherwise an
            empty string.
        """
        for image_format in r.ImageFormat:
            candidate = folder_path / f"home_image{image_format.value_with_dot}"
            if candidate.exists() and candidate.is_file():
                return str(candidate)
        return ""

    def _create_subsect_config_fromdir(
        self, subsection_dir_path: Path, level: int = 2
    ) -> Dict[str, Union[str, List[Dict]]]:
        """
        Creates subsection config from a directory.

        Parameters
        ----------
        subsection_dir_path : Path
            Path to the subsection directory.

        Returns
        -------
        Dict[str, Union[str, List[Dict]]]
            The subsection config.
        """
        # Sort files by number prefix
        sorted_files = self._sort_paths_by_numprefix(
            list(subsection_dir_path.iterdir())
        )
        components = []
        for file in sorted_files:
            if file.is_file():
                component_config = self._create_component_config_fromfile(file)
                # Skip unsupported files
                if component_config is None:
                    continue
                # Add component config to list
                components.append(component_config)
            elif file.is_dir():
                if level >= self.max_depth:
                    self.logger.warning(
                        "Subsection nesting level exceeded: %s. Skipping.", file.name
                    )
                    continue
                # components are added to subsection
                # ! Alternatively, one could add (sub-)sections to the subsection
                # ? Then one could remove differentiation between sections and
                # ? subsections
                nested_components = self._create_subsect_config_fromdir(file, level + 1)
                components.extend(nested_components["components"])

        subsection_config = {
            "title": self._create_title_fromdir(subsection_dir_path.name),
            "description": self._read_description_file(subsection_dir_path),
            "components": components,
        }
        return subsection_config

    def _create_sect_config_fromdir(
        self, section_dir_path: Path
    ) -> Dict[str, Union[str, List[Dict]]]:
        """
        Creates section config from a directory.

        Parameters
        ----------
        section_dir_path : Path
            Path to the section directory.

        Returns
        -------
        Dict[str, Union[str, List[Dict]]]
            The section config.
        """
        # Sort subsections by number prefix
        sorted_subsections = self._sort_paths_by_numprefix(
            list(section_dir_path.iterdir())
        )

        subsections = []
        components = []
        for subsection_dir in sorted_subsections:
            if subsection_dir.is_dir():
                subsections.append(self._create_subsect_config_fromdir(subsection_dir))
            else:
                file_in_subsection_dir = (
                    subsection_dir  # ! maybe take more generic names?
                )
                component_config = self._create_component_config_fromfile(
                    file_in_subsection_dir
                )
                if component_config is not None:
                    components.append(component_config)

        section_config = {
            "title": self._create_title_fromdir(section_dir_path.name),
            "description": self._read_description_file(section_dir_path),
            "subsections": subsections,
            "components": components,
        }
        return section_config

    def create_yamlconfig_fromdir(
        self, base_dir: str
    ) -> Tuple[Dict[str, Union[str, List[Dict]]], Path]:
        """
        Generates a YAML-compatible config file from a directory. It also returns the
        resolved folder path.

        Parameters
        ----------
        base_dir : str
            The base directory containing section and subsection folders.

        Returns
        -------
        Tuple[Dict[str, Union[str, List[Dict]]], Path]
            The YAML config and the resolved directory path.
        """
        # Get absolute path from base directory
        base_dir_path = Path(base_dir)

        # Generate the YAML config
        yaml_config = {
            "report": {
                # This will be used for the home section of a report
                "title": self._create_title_fromdir(base_dir_path.name),
                "description": self._read_description_file(base_dir_path),
                "graphical_abstract": self._read_home_image_file(base_dir_path),
                "logo": "",
            },
            "sections": [],
        }

        # Sort sections by their number prefix
        sorted_sections = self._sort_paths_by_numprefix(list(base_dir_path.iterdir()))

        main_section_config = {
            "title": self._create_title_fromdir(base_dir_path.name),
            "description": "",
            "components": [],
        }

        # Generate sections and subsections config
        for section_dir in sorted_sections:
            if section_dir.is_dir():
                yaml_config["sections"].append(
                    self._create_sect_config_fromdir(section_dir)
                )
            # could be single plots?
            else:
                file_in_main_section_dir = section_dir
                if (
                    file_in_main_section_dir.name.lower() == "description.md"
                    or "home_image" in file_in_main_section_dir.name.lower()
                ):
                    continue  # Skip description file and home_image in the main section
                component_config = self._create_component_config_fromfile(
                    file_in_main_section_dir
                )
                if component_config is not None:
                    main_section_config["components"].append(component_config)

        if main_section_config["components"]:
            # If components were added to the main section, i.e. there were components
            # found in the main report directory, add it to the first position of the
            # list of sections
            yaml_config["sections"].insert(0, main_section_config)

        return yaml_config, base_dir_path

    def initialize_report(self, config: dict) -> tuple[r.Report, dict]:
        """
        Extracts report metadata from a YAML config file and returns a Report object and
        the raw metadata.

        Parameters
        ----------
        config : dict
            The report metadata obtained from a YAML config file.

        Returns
        -------
        report, config : tuple[Report, dict]
            A tuple containing the Report object created from the YAML config file and
            the raw metadata dictionary.

        Raises
        ------
        FileNotFoundError
            If the specified file does not exist.
        ValueError
            If the YAML config file is corrupted or contains missing/invalid values.
        """
        # Create a Report object from metadata
        report = r.Report(
            title=config["report"]["title"],
            logger=self.logger,
            sections=[],
            description=config["report"].get("description"),
            graphical_abstract=config["report"].get("graphical_abstract"),
            logo=config["report"].get("logo"),
        )

        # Create sections and subsections
        for section_data in config.get("sections", []):
            section = self._create_section(section_data)
            report.sections.append(section)

        self.logger.info(
            "Report '%s' initialized with %d sections.",
            report.title,
            len(report.sections),
        )
        return report, config

    def _create_section(self, section_data: dict) -> r.Section:
        """
        Creates a Section object from a dictionary of section data.

        Parameters
        ----------
        section_data : dict
            A dictionary containing section metadata.

        Returns
        -------
        section : Section
            A Section object populated with the provided metadata.
        """
        # Initialize the Section object
        section = r.Section(
            title=section_data["title"],
            subsections=[],
            description=section_data.get("description"),
        )

        for component_data in section_data.get("components", []):
            component = self._create_component(component_data)
            section.components.append(component)

        # Create subsections
        for subsection_data in section_data.get("subsections", []):
            subsection = self._create_subsection(subsection_data)
            section.subsections.append(subsection)

        return section

    def _create_subsection(self, subsection_data: dict) -> r.Subsection:
        """
        Creates a Subsection object from a dictionary of subsection data.

        Parameters
        ----------
        subsection_data : dict
            A dictionary containing subsection metadata.

        Returns
        -------
        subsection : Subsection
            A Subsection object populated with the provided metadata.
        """
        # Initialize the Subsection object
        subsection = r.Subsection(
            title=subsection_data["title"],
            components=[],
            description=subsection_data.get("description"),
        )

        # Create components
        for component_data in subsection_data.get("components", []):
            component = self._create_component(component_data)
            subsection.components.append(component)

        return subsection

    def _create_component(self, component_data: dict) -> r.Component:
        """
        Creates a Component object from a dictionary of component data.

        Parameters
        ----------
        component_data : dict
            A dictionary containing component metadata.

        Returns
        -------
        Component
            A Component object (Plot, DataFrame, or Markdown) populated with the
            provided metadata.
        """
        # Determine the component type
        component_type = assert_enum_value(
            r.ComponentType, component_data["component_type"], self.logger
        )

        # Dispatch to the corresponding creation method
        if component_type == r.ComponentType.PLOT:
            return self._create_plot_component(component_data)
        elif component_type == r.ComponentType.DATAFRAME:
            return self._create_dataframe_component(component_data)
        elif component_type == r.ComponentType.MARKDOWN:
            return self._create_markdown_component(component_data)
        elif component_type == r.ComponentType.HTML:
            return self._create_html_component(component_data)
        elif component_type == r.ComponentType.APICALL:
            return self._create_apicall_component(component_data)
        elif component_type == r.ComponentType.CHATBOT:
            return self._create_chatbot_component(component_data)

    def _create_plot_component(self, component_data: dict) -> r.Plot:
        """
        Creates a Plot component.

        Parameters
        ----------
        component_data : dict
            A dictionary containing plot component metadata.

        Returns
        -------
        Plot
            A Plot object populated with the provided metadata.
        """
        # Validate enum fields
        plot_type = assert_enum_value(
            r.PlotType, component_data["plot_type"], self.logger
        )
        csv_network_format = (
            assert_enum_value(
                r.CSVNetworkFormat,
                component_data.get("csv_network_format", ""),
                self.logger,
            )
            if component_data.get("csv_network_format")
            else None
        )

        return r.Plot(
            title=component_data["title"],
            logger=self.logger,
            file_path=component_data["file_path"],
            plot_type=plot_type,
            csv_network_format=csv_network_format,
            caption=component_data.get("caption"),
        )

    def _create_dataframe_component(self, component_data: dict) -> r.DataFrame:
        """
        Creates a DataFrame component.

        Parameters
        ----------
        component_data : dict
            A dictionary containing dataframe component metadata.

        Returns
        -------
        DataFrame
            A DataFrame object populated with the provided metadata.
        """
        # Validate enum field and return dataframe
        file_format = assert_enum_value(
            r.DataFrameFormat, component_data["file_format"], self.logger
        )

        return r.DataFrame(
            title=component_data["title"],
            logger=self.logger,
            file_path=component_data["file_path"],
            file_format=file_format,
            delimiter=component_data.get("delimiter"),
            caption=component_data.get("caption"),
        )

    def _create_markdown_component(self, component_data: dict) -> r.Markdown:
        """
        Creates a Markdown component.

        Parameters
        ----------
        component_data : dict
            A dictionary containing markdown component metadata.

        Returns
        -------
        Markdown
            A Markdown object populated with the provided metadata.
        """
        return r.Markdown(
            title=component_data["title"],
            logger=self.logger,
            file_path=component_data["file_path"],
            caption=component_data.get("caption"),
        )

    def _create_html_component(self, component_data: dict) -> r.Html:
        """
        Creates an Html component.

        Parameters
        ----------
        component_data : dict
            A dictionary containing hml component metadata.

        Returns
        -------
        Html
            An Html object populated with the provided metadata.
        """
        return r.Html(
            title=component_data["title"],
            logger=self.logger,
            file_path=component_data["file_path"],
            caption=component_data.get("caption"),
        )

    def _create_apicall_component(self, component_data: dict) -> r.APICall:
        """
        Creates an APICall component.

        Parameters
        ----------
        component_data : dict
            A dictionary containing apicall component metadata.

        Returns
        -------
        APICall
            An APICall object populated with the provided metadata.
        """
        request_body = component_data.get("request_body")
        parsed_body = None
        if request_body:
            try:
                parsed_body = json.loads(request_body)
            except json.JSONDecodeError as e:
                self.logger.error(
                    "Failed to parse request_body JSON: %s", e, exc_info=True
                )
                raise ValueError("Invalid JSON in request_body.") from e

        return r.APICall(
            title=component_data["title"],
            logger=self.logger,
            api_url=component_data["api_url"],
            method=component_data["method"],
            caption=component_data.get("caption"),
            headers=component_data.get("headers"),
            params=component_data.get("params"),
            request_body=parsed_body,
        )

    def _create_chatbot_component(self, component_data: dict) -> r.ChatBot:
        """
        Creates a ChatBot component.

        Parameters
        ----------
        component_data : dict
            A dictionary containing apicall component metadata.

        Returns
        -------
        APICall
            A chatbot object populated with the provided metadata.
        """
        return r.ChatBot(
            title=component_data["title"],
            logger=self.logger,
            api_url=component_data["api_url"],
            model=component_data.get("model"),
            caption=component_data.get("caption"),
            headers=component_data.get("headers"),
            params=component_data.get("params"),
        )
