from dataclasses import dataclass
from typing import List

from .result import Result

from .schema import AtomType, Atom, Node, Record, Collection
from .visitor import Visitor
from .misc import closest


def validate(data, schema, fail=True):
    """Validate a piece of data against a Schema and return an interepreted result."""
    assert isinstance(schema, Node), f"schema should be a Node instance, not {schema}."

    r = Validator.validate(schema, data)

    if r.valid:
        return r.value
    elif fail:
        raise InvalidDataError(r.error)

    return None


class Validator(Visitor):
    @classmethod
    def validate(cls, schema, data):
        """validate data against schema.

        :param schema: instance of Node
        :param data: the data to validate
        """
        return cls().visit(schema, data)

    def visit_atom(self, atom, data) -> Result:
        if atom.type_ == AtomType.INT:
            if isinstance(data, int):
                return Result.ok(data)
            elif isinstance(data, float):
                if int(data) == data:
                    return Result.ok(int(data))

        elif atom.type_ == AtomType.FLOAT:
            if isinstance(data, (float, int)):
                return Result.ok(data)

        elif atom.type_ == AtomType.STR:
            if isinstance(data, str):
                return Result.ok(data)

        elif atom.type_ == AtomType.OPTION:
            assert atom.options is not None, "atom.options must be set"
            if isinstance(data, str) and data in atom.options:
                return Result.ok(data)

        elif atom.type_ == AtomType.BOOL:
            if isinstance(data, bool):
                return Result.ok(data)

        else:
            raise NotImplementedError(f"{atom.type_} is not supported yet.")

        # invalid atom
        return Result.fail(
            Mismatch(
                [],
                data,
                f"{repr(data)} is not a valid {atom.type_.name(atom.options)}.",
            )
        )

    def visit_union(self, union, data) -> Result:
        errors = []
        for op in union.options:
            m = self.visit(op, data)
            if m.valid:
                # success at first match
                return Result.ok((op, m.value))
            else:
                errors.append(m.error)

        # no valid option
        return Result.fail(
            Mismatch(
                [],
                data,
                f"{repr(data)} does not validate any of the valid alternatives:\n"
                + "\n".join("- " + error_message(err, no_loc=True) for err in errors),
            )
        )

    def visit_record(self, rec, data) -> Result:
        try:
            pairs = data.items()
        except AttributeError:
            # early return because we cannot go further anyway
            return Result.fail(
                Mismatch([], data, f"{repr(data)} does not implement the items method.")
            )

        got = set()
        res = {}
        errors = []

        for key, value in pairs:
            if key not in rec.fields:
                other = closest(rec.fields.keys(), key)
                errors.append(
                    Mismatch([], data, f"{key} is not a valid member of this record. Did you mean {other}")
                )
                continue

            m = self.visit(rec.fields[key], value)
            if m.valid:
                got.add(key)
                res[key] = m.value
            else:
                errors.append(m.error.from_(key))

        for key in set(rec.fields.keys()).difference(got):
            if not rec.fields[key].optional:
                # collect missing members
                errors.append(Mismatch([], data, f"{key} is missing."))
            else:
                default = rec.fields[key].default
                r = self.visit(rec.fields[key], default)
                if not r.valid:
                    return Result.fail(
                        Mismatch([key], default, "Default value is invalid data.")
                    )
                res[key] = r.value

        if errors:
            return Result.fail(Mismatches(errors))
        else:
            return Result.ok(res)

    def visit_tuple(self, tup, data) -> Result:
        try:
            elements = list(data)
        except TypeError:
            # early return because we cannot go further anyway
            return Result.fail(Mismatch([], data, f"{repr(data)} is not iterable."))

        res = []
        errors = []

        if len(elements) != len(tup.fields):
            errors.append(
                Mismatch(
                    [],
                    data,
                    f"Expected {len(tup.fields)} elements, but got {len(elements)}.",
                )
            )

        for i, (schema, value) in enumerate(zip(tup.fields, elements)):
            m = self.visit(schema, value)
            if m.valid:
                res.append(m.value)
            else:
                errors.append(m.error.from_(str(i)))

        if errors:
            return Result.fail(Mismatches(errors))
        else:
            return Result.ok(res)

    def visit_collection(self, collection, data) -> Result:
        if isinstance(data, str):
            return Result.fail(Mismatch([], data, f"{repr(data)} is a string, not a collection"))

        try:
            col = list(data)
        except AttributeError:
            return Result.fail(Mismatch([], data, f"{repr(data)} is not iterable."))

        validate = []
        errors = []

        for i, e in enumerate(col):
            m = self.visit(collection.element, e)
            if m.valid:
                validate.append(m.value)
            else:
                # collect mismatches in elements
                errors.append(m.error.from_(str(i)))

        if errors:
            return Result.fail(Mismatches(errors))
        else:
            return Result.ok(validate)

    def visit_map(self, map_, data) -> Result:
        try:
            pairs = data.items()
        except AttributeError:
            return Result.fail(
                Mismatch([], data, f"{repr(data)} does not implement the items method.")
            )

        res = {}
        errors = []

        for key, value in pairs:
            m = self.visit(map_.element, value)
            if m.valid:
                res[key] = m.value
            else:
                # collect mismatches in elements
                errors.append(m.error.from_(key))

        if errors:
            return Result.fail(Mismatches(errors))
        else:
            return Result.ok(res)


@dataclass
class Mismatch:
    where: List[str]
    what: object
    why: str

    def from_(self, where):
        return Mismatch([where, *self.where], self.what, self.why)


class Mismatches:
    def __init__(self, errors):
        self.errors = []

        for err in errors:
            if isinstance(err, Mismatches):
                self.errors.extend(err.errors)
            else:
                self.errors.append(err)

    def from_(self, where):
        return Mismatches([e.from_(where) for e in self.errors])


class InvalidDataError(ValueError):
    def __init__(self, mismatch):
        super().__init__(error_message(mismatch))


def error_message(mismatch, no_loc=False):
    if isinstance(mismatch, Mismatches):
        msg = []
        for mism in mismatch.errors:
            msg.append(error_message(mism, no_loc=no_loc))

        return "\n".join(msg)
    elif no_loc:
        return mismatch.why
    else:
        if None:
            location = ".".join(mismatch.where)
        else:
            location = "[toplevel]"

        return f"In {location}: {mismatch.why}"
