# coding: utf8
# Debian Contributors data source parser from untrusted data
#
# Copyright (C) 2013  Enrico Zini <enrico@debian.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
import datetime
import json
import codecs
import io


class Fail(BaseException):
    """
    Exception raised when a validation or lookup fails
    """
    def __init__(self, code:int, msg: str) -> None:
        self.code = code
        self.msg = msg


class ClusterFail(Fail):
    """
    Exception raised to report a number of errors of the same kind
    """
    def __init__(self, code, msg, errors):
        super(ClusterFail, self).__init__(code, msg)
        self.errors = errors


def get_key(d, key):
    "Get a key from a dict"
    try:
        return d[key]
    except KeyError:
        raise Fail(400, "Key '{}' not found".format(key))


def get_key_int(d, key):
    "Get a key from a dict, as an int"
    try:
        return int(get_key(d, key))
    except ValueError:
        raise Fail(400, "Key '{}' does not contain an integer value".format(key))


def get_key_string(d, key, empty=False):
    "Get a key from a dict, as a string"
    if empty:
        res = d.get(key, "")
        if not res:
            return ""
    else:
        res = get_key(d, key)

    try:
        res = str(res)
    except ValueError:
        raise Fail(400, "Key '{}' does not contain a string value".format(key))

    if not empty and not res:
        raise Fail(400, "Key '{}' contains an empty string".format(key))

    return res


def get_key_unicode(d, key, empty=False):
    "Get a key from a dict, as a unicode, decoded from utf8 if necessary"
    if empty:
        res = d.get(key, "")
        if not res:
            return ""
    else:
        res = get_key(d, key)
        if not res:
            raise Fail(400, "Key '{}' contains an empty string".format(key))

    if isinstance(res, str):
        return res
    if not isinstance(res, bytes):
        raise Fail(400, "Key '{}' does not contain a string value".format(key))

    try:
        return res.decode("utf8")
    except (UnicodeEncodeError, UnicodeDecodeError):
        escaped = res.decode(encoding="utf8", errors="replace")
        raise Fail(400, "Key '{}' contain {} which is not a valid UTF8 string".format(key, escaped))


def get_key_sequence(d, key):
    "Get a key from a dict, ensuring it is a list or tuple"
    res = get_key(d, key)
    if not isinstance(res, (list, tuple)):
        raise Fail(400, "Key '{}' does not contain an array".format(key))
    return res


def get_key_sequence_or_object(d, key):
    """
    Get a key from a dict, ensuring it is a list or tuple, allowing singleton
    lists of objects to be just the object itself
    """
    res = get_key(d, key)
    if isinstance(res, (list, tuple)):
        return res
    elif isinstance(res, dict):
        return [res]
    else:
        raise Fail(400, "Key '{}' does not contain an array or object".format(key))


def get_key_date_or_none(d, key):
    "Get a key from a dict, as a date, allowing None"
    res = get_key_string(d, key, empty=True)
    if not res:
        return None

    try:
        return datetime.datetime.strptime(res, "%Y-%m-%d").date()
    except ValueError:
        raise Fail(400, "Key '{}' does not contain a YYYY-MM-DD date".format(key))


def get_json(f, compression=None):
    """
    Parse JSON from data from a file-like object, with optional decompression
    """
    if compression:
        if compression == "gzip":
            import gzip
            try:
                with gzip.GzipFile(mode="rb", fileobj=f) as fd:
                    try:
                        reader = codecs.getreader("utf-8")
                        return json.load(reader(fd))
                    except (ValueError, UnicodeDecodeError):
                        raise Fail(400, "invalid JSON data")
            except IOError:
                raise Fail(400, "invalid gzip compressed data")
        elif compression == "xz":
            try:
                import lzma
            except ImportError:
                raise Fail(500, "but python-lzma is not installed to decode xz-compressed data")
            try:
                reader = codecs.getreader("utf-8")
                return json.load(reader(io.BytesIO(lzma.decompress(f.read()))))
            except IOError:
                raise Fail(400, "invalid xz compressed data")
            except (ValueError, UnicodeDecodeError):
                raise Fail(400, "invalid JSON data")
        else:
            raise Fail(500, "{} compression is not supported".format(compression))
    else:
        try:
            reader = codecs.getreader("utf-8")
            return json.load(reader(f))
        except (ValueError, UnicodeDecodeError):
            raise Fail(400, "invalid JSON data")


class Parser:
    def parse_identifier(self, d):
        """
        Parse a dict as an Identifier
        """
        from .types import Identifier
        i_type = get_key_string(d, "type")
        i_id = get_key_unicode(d, "id")
        i_desc = get_key_unicode(d, "desc", True)
        res = Identifier(i_type, i_id, i_desc)
        res.validate()
        return res

    def parse_contribution(self, d):
        """
        Parse a dict as a Contribution
        """
        from .types import Contribution
        c_type = get_key_string(d, "type")
        c_begin = get_key_date_or_none(d, "begin")
        c_until = get_key_date_or_none(d, "end")
        c_url = get_key_unicode(d, "url", True) or None
        res = Contribution(c_type, c_begin, c_until, c_url)
        res.validate()
        return res

    def parse_submission(self, seq):
        """
        Parse a sequence as a submission

        generate a sequence of (ids, contributions)
        """
        if not isinstance(seq, (list, tuple)):
            raise Fail(400, "Submission is not an Array")

        errors = []
        total_count = 0
        for idx, rec in enumerate(seq):
            total_count += 1
            if not isinstance(rec, dict):
                errors.append("#{}: submission is not an Array")
                continue

            # Parse identifiers
            try:
                s_ids = [self.parse_identifier(d)
                         for d in get_key_sequence_or_object(rec, "id")]
            except Fail as f:
                errors.append("#{}: cannot parse identifier(s): {}".format(idx, f.msg))
                continue

            if not s_ids:
                errors.append("#{}: identifier list is empty".format(idx))
                continue

            # Parse contributions
            try:
                s_contribs = [self.parse_contribution(d)
                              for d in get_key_sequence_or_object(rec, "contributions")]
            except Fail as f:
                errors.append("#{} for {}: cannot parse contribution(s): {}".format(idx, s_ids[0].id, f.msg))
                continue

            if not s_contribs:
                errors.append("#{} for {}: contribution list is empty".format(idx, s_ids[0].id))
                continue

            yield s_ids, s_contribs

        if errors:
            if len(errors) == 1:
                raise Fail(400, errors[0])
            elif len(errors) == total_count:
                raise ClusterFail(400, "All submissions failed", errors)
            else:
                raise ClusterFail(400, "Some submission failed", errors)
