import os
from collections.abc import Mapping
from copy import deepcopy as dpcpy
from logging import getLogger
from warnings import warn

import pandas as pd
from jsonschema import Draft7Validator
from pandas.core.common import flatten

from ..project import Project
from ..sample import Sample
from ..utils import load_yaml
from .const import PROP_KEY, SAMPLES_KEY, SIZING_KEY, TANGIBLE_KEY
from .exceptions import EidoValidationError, PathAttrNotFoundError
from .schema import preprocess_schema, read_schema

_LOGGER = getLogger(__name__)


def _validate_object(
    obj: Mapping,
    schema: str | dict,
    sample_name_colname: str | bool = False,
) -> None:
    """
    Generic function to validate object against a schema.

    Args:
        obj: An object to validate
        schema: Schema dict to validate against or a path to one
        sample_name_colname: Column name for sample names in error reporting

    Raises:
        EidoValidationError: If validation is unsuccessful
    """
    validator = Draft7Validator(schema)
    _LOGGER.debug(f"{obj},\n {schema}")
    if not validator.is_valid(obj):
        errors = sorted(validator.iter_errors(obj), key=lambda e: e.path)
        errors_by_type = {}

        # Accumulate and restructure error objects by error type
        for error in errors:
            if error.message not in errors_by_type:
                errors_by_type[error.message] = []

            try:
                instance_name = error.instance[sample_name_colname]
            except KeyError:
                instance_name = "project"
            except TypeError:
                instance_name = obj["samples"][error.absolute_path[1]][
                    sample_name_colname
                ]
            errors_by_type[error.message].append(
                {
                    "type": error.message,
                    "message": f"{error.message} on instance {instance_name}",
                    "sample_name": instance_name,
                }
            )

        raise EidoValidationError("Validation failed", errors_by_type)
    else:
        _LOGGER.debug("Validation was successful...")


def validate_project(project: Project, schema: str | dict) -> None:
    """
    Validate a project object against a schema.

    Args:
        project: A project object to validate
        schema: Schema dict to validate against or a path to one

    Raises:
        EidoValidationError: If validation is unsuccessful
    """
    sample_name_colname = project.sample_name_colname
    schema_dicts = read_schema(schema=schema)
    for schema_dict in schema_dicts:
        project_dict = project.to_dict()
        _validate_object(
            project_dict, preprocess_schema(schema_dict), sample_name_colname
        )
        _LOGGER.debug("Project validation successful")


def _validate_sample_object(sample: Sample, schemas: list[dict]) -> None:
    """
    Validate a peppy.Sample object without requiring a reference to peppy.Project.

    Args:
        sample: A sample object to validate
        schemas: List of schemas to validate against or a path to one
    """
    for schema_dict in schemas:
        schema_dict = preprocess_schema(schema_dict)
        sample_schema_dict = schema_dict[PROP_KEY][SAMPLES_KEY]["items"]
        _validate_object(sample.to_dict(), sample_schema_dict)
        _LOGGER.debug(
            f"{getattr(sample, 'sample_name', '')} sample validation successful"
        )


def validate_sample(
    project: Project, sample_name: str | int, schema: str | dict
) -> None:
    """
    Validate the selected sample object against a schema.

    Args:
        project: A project object to validate
        sample_name: Name or index of the sample to validate
        schema: Schema dict to validate against or a path to one

    Raises:
        EidoValidationError: If validation is unsuccessful
    """
    sample = (
        project.samples[sample_name]
        if isinstance(sample_name, int)
        else project.get_sample(sample_name)
    )
    _validate_sample_object(
        sample=sample,
        schemas=read_schema(schema=schema),
    )


def validate_config(project: Project | dict | str, schema: str | dict) -> None:
    """
    Validate the config part of the Project object against a schema.

    Args:
        project: A project object, dict, or path to config file to validate
        schema: Schema dict to validate against or a path to one
    """
    schema_dicts = read_schema(schema=schema)
    for schema_dict in schema_dicts:
        schema_cpy = preprocess_schema(dpcpy(schema_dict))
        try:
            del schema_cpy[PROP_KEY][SAMPLES_KEY]
        except KeyError:
            # Schema doesn't have samples key, which is fine for config-only validation
            pass
        if "required" in schema_cpy:
            try:
                schema_cpy["required"].remove(SAMPLES_KEY)
            except ValueError:
                # SAMPLES_KEY is not in required list, no action needed
                pass
        if isinstance(project, dict):
            _validate_object({"project": project}, schema_cpy)

        elif isinstance(project, str):
            try:
                project_dict = load_yaml(project)
            except (FileNotFoundError, IOError, OSError) as e:
                raise ValueError(
                    f"Please provide a valid yaml config of PEP project; invalid config path: {project}"
                ) from e
            _validate_object({"project": project_dict}, schema_cpy)
        else:
            project_dict = project.to_dict()
            _validate_object(project_dict, schema_cpy)
            _LOGGER.debug("Config validation successful")


def _get_attr_values(obj: Mapping, attrlist: str | list[str]) -> list[str] | None:
    """
    Get value corresponding to each given attribute.

    Args:
        obj: An object to get the attributes from
        attrlist: Names of attributes to retrieve values for

    Returns:
        Value corresponding to each named attribute; None if this Sample's
        value for the attribute is empty/null, or if this Sample lacks the
        indicated attribute
    """
    # If attribute is None, then value is also None.
    if not attrlist:
        return None
    if not isinstance(attrlist, list):
        attrlist = [attrlist]
    # Strings contained here are appended later so shouldn't be null.
    return list(flatten([getattr(obj, attr, "") for attr in attrlist]))


def validate_input_files(
    project: Project,
    schemas: str | dict,
    sample_name: str | int | None = None,
) -> None:
    """
    Determine which of the required and optional files are missing.

    The names of the attributes that are required and/or deemed as inputs
    are sourced from the schema, more specifically from `required_files`
    and `files` sections in samples section:

    - If any of the required files are missing, this function raises an error.
    - If any of the optional files are missing, the function raises a warning.

    Note, this function also performs Sample object validation with jsonschema.

    Args:
        project: Project that defines the samples to validate
        schemas: Schema dict to validate against or a path to one
        sample_name: Name or index of the sample to validate. If None,
            validate all samples in the project

    Raises:
        PathAttrNotFoundError: If any required sample attribute is missing
    """

    if sample_name is None:
        samples = project.samples
    else:
        samples = (
            project.samples[sample_name]
            if isinstance(sample_name, int)
            else project.get_sample(sample_name)
        )
        samples = [samples]

    if isinstance(schemas, str):
        schemas = read_schema(schemas)

    for sample in samples:
        # validate attrs existence first
        _validate_sample_object(schemas=schemas, sample=sample)

        all_inputs = set()
        required_inputs = set()
        schema = schemas[-1]  # use only first schema, in case there are imports
        sample_schema_dict = schema[PROP_KEY][SAMPLES_KEY]["items"]
        if SIZING_KEY in sample_schema_dict:
            all_inputs.update(_get_attr_values(sample, sample_schema_dict[SIZING_KEY]))
        if TANGIBLE_KEY in sample_schema_dict:
            required_inputs = set(
                _get_attr_values(sample, sample_schema_dict[TANGIBLE_KEY])
            )
            all_inputs.update(required_inputs)

        missing_required_inputs = [i for i in required_inputs if not os.path.exists(i)]
        missing_inputs = [i for i in all_inputs if not os.path.exists(i)]
        if missing_inputs:
            warn(
                f"For sample '{getattr(sample, project.sample_table_index)}'. "
                f"Optional inputs not found: {missing_inputs}"
            )
        if missing_required_inputs:
            raise PathAttrNotFoundError(
                f"For sample '{getattr(sample, project.sample_table_index)}'. "
                f"Required inputs not found: {required_inputs}"
            )


def validate_original_samples(samples: str | pd.DataFrame, schema: str | dict) -> None:
    """
    Validate the original samples from the csv table against a schema.

    Args:
        samples: The path to the sample table csv or the dataframe from the table
        schema: Schema dict to validate against or a path to one

    Raises:
        EidoValidationError: If validation is unsuccessful
    """
    if isinstance(samples, str):
        samples = pd.read_csv(samples)

    assist_project = Project.from_pandas(samples)
    for s in assist_project.samples:
        _validate_sample_object(
            sample=s,
            schemas=read_schema(schema=schema),
        )
