"""Functions for reading and writing input/output files."""
import csv
import os
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Type, Union

import pandas as pd
from pydantic import BaseModel
from pydantic_core import PydanticUndefined

from epeira.helpers.marking import is_subcomponent
from epeira.helpers.parsing import get_model_input_key

CatalogInDict = Dict[str, Dict[str, List[Type[BaseModel]]]]


def write_input_file_from_catalogs(catalogs: List[CatalogInDict], filename: str) -> None:
    """Use one or more catalogs to prepare an input file.

    Takes a list of nested catalog dicts of the form {category: {component_name: [models]}}.
    For each component in each category, writes a model-selection row and rows for all
    model fields that do not already exist in the file.

    If there is only one model for a given component+category, that model name is used as the
    value. If there are multiple, the value is set to [TBD] to be filled out manually.
    All field values default to [TBD] unless the model field has a default value.
    """
    # Load existing parameters if file exists
    existing_params = set()

    if os.path.exists(filename):
        with open(filename, newline="", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            rows = list(reader)
            existing_params = {row["parameter"] for row in rows}
    else:
        rows = []

    # Process catalogs
    for catalog in catalogs:
        for category, component_dict in catalog.items():
            for component_name, model_list in component_dict.items():
                model_key = get_model_input_key(component_name, category)
                model_name = model_list[0].__name__ if len(model_list) == 1 else "[TBD]"
                if model_key not in existing_params:
                    rows.append({"parameter": model_key, "value": model_name, "description": "[TBD]"})
                    existing_params.add(model_key)
                for model_cls in model_list:
                    for field_name, field in model_cls.model_fields.items():
                        if is_subcomponent(field):
                            continue
                        if field_name not in existing_params:
                            value = str(field.default) if field.default is not PydanticUndefined else "[TBD]"
                            rows.append({"parameter": field_name, "value": value, "description": "[TBD]"})
                            existing_params.add(field_name)

    # Write the updated rows back to file
    with open(filename, "w", newline="", encoding="utf-8") as f:
        f.write("parameter,value,description\n")
        for row in rows:
            f.write(f'{row["parameter"]},{row["value"]},"{row["description"]}"\n')


def infer_type(value: Union[str, List[str]]) -> Any:
    """Try to convert a string to int, float, bool, or None, or a list of these.

    Fall back to string. Semicolon-delimited strings are parsed as lists.
    """
    def _convert(v: str):
        """Convert a single entry to the appropriate Python type."""
        if not isinstance(v, str):
            return v
        v = v.strip()
        if v == "" or v.lower() == "nan":
            return None
        if v.lower() in {"true", "false"}:
            return v.lower() == "true"
        try:
            if "." in v:
                return float(v)
            return int(v)
        except ValueError:
            return v

    if isinstance(value, list):
        return [_convert(v) for v in value]
    if isinstance(value, str):
        if ";" in value:
            return [_convert(v) for v in value.split(";") if v.strip() != ""]
        return _convert(value)
    return value


def csv_or_df_to_dict(filename: Optional[str] = None,
                      dataframe: Optional[pd.DataFrame] = None) -> dict:
    """Convert a CSV file or a DataFrame with 'parameter' and 'value' columns into a dictionary.

    Applies type inference on the values.
    """
    if filename is not None:
        df = pd.read_csv(filename, usecols=[0, 1])
    elif dataframe is not None:
        df = dataframe.iloc[:, :2].copy()
    else:
        raise ValueError("Either 'filename' or 'dataframe' must be provided.")

    df.columns = ['parameter', 'value']
    df['value'] = df['value'].apply(infer_type)
    return dict(zip(df['parameter'], df['value']))


def write_dict_to_csv(data: dict, filename: str) -> None:
    """Write a dictionary to a CSV file with 'parameter' and 'value' columns.

    Lists are serialized as semicolon-delimited strings.
    """
    with open(filename, mode='w', newline='') as csvfile:
        writer = csv.writer(csvfile)
        writer.writerow(['parameter', 'value'])
        for key, value in data.items():
            if isinstance(value, list):
                serialized = ";".join("" if v is None else str(v) for v in value)
                writer.writerow([key, f'"{serialized}"'])
            else:
                writer.writerow([key, value])


def get_git_root() -> Path:
    """Return the full path to the root of the current Git repository.

    Raises RuntimeError if not inside a Git repo.
    """
    try:
        root = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], text=True).strip()
        return Path(root).resolve()
    except subprocess.CalledProcessError:
        raise RuntimeError("Not inside a Git repository.")
