import io
import json
import logging
import zipfile
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import ClassVar, Dict, Sequence, Type
from xml.etree import ElementTree as et

import pandas as pd

from .kpi import KPI

logger = logging.getLogger(__name__)


def detect_decimal(csv_path: Path, line_number: int, check: str = "") -> str:
    """Detect whether '.' or ',' is used as decimal separator on the given line."""
    with open(csv_path, encoding="utf-8") as csv:
        for _ in range(line_number):
            next(csv)
        line = next(csv)
        if check and (check not in line):
            raise ValueError(f"Couldn't find '{check}' in '{line}'.")
        possible_delimiters = [".", ","]
        delimiter = next(d for d in possible_delimiters if d in line)
        logger.debug("Decimal for %s is '%s'", csv_path.name, delimiter)
        return delimiter


def get_workflow_provider(output_files: list[Path]) -> str:
    """Determine the SimStadt workflow provider class name from a params.xml file."""
    if len(output_files) == 0:
        raise ValueError("No output files were provided!")
    first_output_file = output_files[0]
    workflow_path = next(d for d in first_output_file.parents if d.suffix == ".flow")
    params_xml = workflow_path / "params.xml"

    tree = et.parse(params_xml)
    root = tree.getroot()
    element = root.find(".//void[@property='workflowProvider']/object")
    assert element is not None, "workflowProvider element not found"
    return element.get("class", "UnknownWorkflowProvider")


@dataclass
class SimStadtResults(ABC):
    """Abstract base class for SimStadt workflow results.

    Subclasses register themselves via __init_subclass__ so that
    create_simstadt_results() can instantiate the correct type automatically.
    """

    description: str
    output_files: list[Path]

    supported_providers: ClassVar[list[str]]
    csv_identifier: ClassVar[str]
    _registry: ClassVar[Dict[str, Type["SimStadtResults"]]] = {}

    _df: pd.DataFrame | None = field(default=None, init=False, repr=False)

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        for provider in cls.supported_providers:
            SimStadtResults._registry[provider] = cls

    @abstractmethod
    def _parse_results(self) -> pd.DataFrame:
        pass

    @property
    def dataframe(self) -> pd.DataFrame:
        if self._df is None:
            self._df = self._parse_results()
        return self._df

    @property
    def more_info(self) -> dict[str, list[KPI]]:
        return {}

    @property
    @abstractmethod
    def kpis(self) -> list[KPI]:
        pass

    def kpi(self, name: str) -> KPI:
        for kpi in self.kpis:
            if kpi.name == name:
                return kpi
        raise ValueError(f"No '{name}' KPI found for {self}")

    def _prepare_kpis(self, sums: Sequence, averages: Sequence) -> list[KPI]:
        kpis = []
        default_precision = 0
        for item in sums:
            name, unit, precision = (item + (default_precision,))[:3]
            kpis.append(KPI(name, self.dataframe[name].sum(), unit, precision))
        for item in averages:
            name, unit, precision = (item + (default_precision,))[:3]
            kpis.append(KPI(name, float(self.dataframe[name].mean()), unit, precision))
        return kpis

    def _params_xml(self) -> Path:
        return self.workflow_path / "params.xml"

    def _params(self) -> et.Element:
        tree = et.parse(self._params_xml())
        return tree.getroot()

    def _params_element(self, xpath: str) -> et.Element:
        element = self._params().find(xpath)
        if element is None:
            raise ValueError(f"'{xpath}' not found in XML")
        return element

    @property
    def name(self) -> str:
        return self._params_element(".//void[@property='name']/string").text or "Unknown"

    @property
    def short_name(self) -> str:
        return self._params_element(".//void[@property='shortName']/string").text or "Unknown"

    @property
    def workflow_provider(self) -> str:
        return self._params_element(".//void[@property='workflowProvider']/object").get("class", "Unknown provider")

    @property
    def workflow_path(self) -> Path:
        first_output_file = self.output_files[0]
        return next(d for d in first_output_file.parents if d.suffix == ".flow")

    @property
    def citygml(self) -> str:
        return self._params_element(".//void[@property='cityGmlFileNames']//string").text or "Unknown CityGML"

    @property
    def project_path(self) -> Path:
        return self.workflow_path.parent

    @property
    def project(self) -> str:
        return self.project_path.stem

    @property
    def repository(self) -> Path:
        return self.project_path.parent

    def relative_paths(self) -> list[str]:
        return [f.relative_to(self.workflow_path).as_posix() for f in self.output_files]

    def __str__(self) -> str:
        return f"{self.description} ({self.class_name})"

    def __repr__(self) -> str:
        files_description = "\n".join(f"  - {f}" for f in self.relative_paths())
        kpis = "\n".join(f"  {k}" for k in self.kpis)
        return (
            f"{self.description} ({self.class_name}, {self.project}, "
            f"{self.workflow_path.name}, {self.citygml})\n{files_description}\n{kpis}"
        )

    @property
    def class_name(self) -> str:
        return type(self).__name__

    @property
    def csv_path(self) -> Path:
        csvs = self.get_all_by_extension(".csv")
        relevant_csvs = [csv for csv in csvs if self.csv_identifier in csv.name]
        if len(relevant_csvs) == 0:
            raise ValueError("Workflow didn't seem to have returned any CSV file!")
        if len(relevant_csvs) > 1:
            logger.warning("Too many CSV results found for %s", self)
        return relevant_csvs[-1]

    def csv_content(self) -> bytes:
        with open(self.csv_path, "rb") as f:
            return f.read()

    def get_all_by_extension(self, ext: str) -> list[Path]:
        return [f for f in sorted(self.output_files) if f.suffix == ext]

    @property
    def diagrams(self) -> dict[str, Path]:
        return {}

    def log_content(self) -> bytes:
        logs = self.get_all_by_extension(".log")
        if len(logs) == 0:
            return b""
        with open(logs[-1], "rb") as f:
            return f.read()

    def zip_content(self) -> bytes:
        zip_buffer = io.BytesIO()
        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED, False) as zf:
            for file_path in self.output_files:
                zf.write(file_path, arcname=file_path.name)
        zip_buffer.seek(0)
        return zip_buffer.read()

    def to_dict(self) -> dict:
        data = {
            "description": self.description,
            "output_files": [str(path) for path in self.output_files],
            "_type": self.class_name,
            "_df_dict": self.dataframe.to_dict("records"),
        }
        if self.dataframe.attrs:
            data["_df_attrs"] = self.dataframe.attrs
        return data

    @classmethod
    def from_dict(cls, data: dict) -> "SimStadtResults":
        data.pop("_type", None)
        df_dict = data.pop("_df_dict", None)
        df_attrs = data.pop("_df_attrs", {})
        data["output_files"] = [Path(p) for p in data["output_files"]]

        if cls is SimStadtResults:
            provider = get_workflow_provider(data["output_files"])
            result = cls.create(provider, **data)
        else:
            result = cls(**data)

        result._df = pd.DataFrame(df_dict)
        result._df.attrs = df_attrs
        return result

    def to_json(self) -> str:
        return json.dumps(self.to_dict())

    @classmethod
    def from_json(cls, json_str: str) -> "SimStadtResults":
        return cls.from_dict(json.loads(json_str))

    @classmethod
    def create(cls, provider: str, **kwargs) -> "SimStadtResults":
        if provider not in cls._registry:
            # TODO: Warning, and return GenericSimStadtResult instead?
            raise ValueError(f"Unknown workflow provider: {provider}")
        return cls._registry[provider](**kwargs)
