# coding: utf8
# Debian Contributors data source core data structure
#
# Copyright (C) 2013--2014  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/>.
from collections import defaultdict
from .types import Contribution
import sys
import os
import io
import json

__all__ = ["Submission"]

DEFAULT_BASE_URL = "https://contributors.debian.org/"


class Submission:
    """
    A submission to contributors.debian.org
    """
    def __init__(
        self,
        name: str,
        auth_token: str | None = None,
        baseurl: str = DEFAULT_BASE_URL,
        method: str = "replace"
    ) -> None:
        """
        name: data source name
        """
        # Data source name, as in the website
        self.name = name
        # Authentication token
        self.auth_token = None
        # Base URL
        self.baseurl = baseurl
        # List of contributions by identifier
        self.entries = defaultdict(dict)
        if auth_token is not None:
            self.set_auth_token(auth_token)
        # Submission method ("replace" or "extend")
        self.method = method

    def set_auth_token(self, auth_token):
        """
        Set the auth token for this source. If auth_token starts with '@', the
        rest is treated as a pathname to a file that contains the token.
        """
        if not auth_token:
            raise ValueError("auth_token is empty")
        if auth_token[0] == "@":
            with open(auth_token[1:], "r") as fd:
                self.auth_token = fd.read().strip()
        else:
            self.auth_token = auth_token

    def add_contribution(self, identifier, contrib):
        """
        Add information about a contribution.

        identifier: Identifier for the user that made this contribution
        contrib: Contribution object
        """
        entries = self.entries[identifier]
        old = entries.get(contrib.type, None)
        if old is None:
            entries[contrib.type] = contrib
        else:
            entries[contrib.type] = Contribution.merged(old, contrib)

    def add_contribution_data(self, identifier, type, begin=None, end=None, url=None):
        """
        Add information about a contribution.

        identifier: Identifier for the user that made this contribution
        name: contribution name (chosen among the source contribution types)
        begin: start time of this contribution. None to reuse the last start time.
        end: end time of this contribution. None to mean 'now'.
        """
        self.add_contribution(identifier, Contribution(type, begin, end, url))

    def merge_with(self, submission):
        """
        Merge another submission into this one
        """
        if self.name != submission.name:
            raise ValueError("Merging submission for two different sources: {}!={}".format(
                self.name, submission.name))
        for ident, contribs in submission.entries.items():
            old = self.entries.get(ident, None)
            if old is None:
                self.entries[ident] = dict(contribs)
            else:
                self.entries[ident] = merge_contrib_dicts(old, contribs)

    def _gen_records(self):
        """
        Generate DC records for serialization
        """
        for ident, contributions in self.entries.items():
            yield {
                "id": (ident.to_json(),),
                "contributions": [c.to_json() for c in contributions.values()],
            }

    def to_json(self, file=None, indent=None):
        """
        Convert to JSON.

        file: if set to a file-like object, send data there. Else, return the
              JSON data as a string
        indent: passed as-is to the indent parameter of the encoder
        """
        if file is not None:
            return json.dump(list(self._gen_records()), file, indent=indent)
        else:
            return json.dumps(list(self._gen_records()), indent=indent)

    def print_compact(self, file=sys.stdout):
        """
        Make a compact dump of this source to the given file
        """
        for ident, contributions in self.entries.items():
            for ctype, c in sorted(contributions.items()):
                if ident.desc:
                    lead = "{}:{} <{}>".format(ident.type, ident.desc, ident.id)
                else:
                    lead = "{}:{}".format(ident.type, ident.id)

                print("{}: {} from {} to {}".format(lead, c.type, c.begin, c.end), file=file)
                if c.url:
                    print("{}: {} url: {}".format(lead, c.type, c.url), file=file)

    def post(self):
        """
        POST this submission to the contributors server

        Returns a couple (success, info).

        success: a bool, true if everything was imported correctly, false if
                 there has been some problem.
        info:    a dict with detailed status and error information, plus import
                 statistics
        """
        # Yuck! Python's stdlib cannot do file uploads :'(
        # We need to introduce an external dependency for it
        import requests
        from urllib.parse import urljoin

        # Build the POST request to contributors.debian.org
        url = urljoin(self.baseurl, '/contributors/post')

        # Prepare the file to post
        try:
            import lzma
            compress_type = "xz"
            compress = lzma.compress
        except ImportError:
            import gzip
            compress_type = "gzip"

            def compress(data):
                out = io.BytesIO()
                with gzip.GzipFile(mode="wb", fileobj=out) as fd:
                    fd.write(data)
                return out.getvalue()

        file_data = io.BytesIO(compress(self.to_json().encode("utf-8")))
        files = {
            "data": file_data
        }

        # POST data
        data = {
            "source": self.name,
            "auth_token": self.auth_token,
            "data_compression": compress_type,
            "method": self.method,
        }

        # POST everything to the server
        args = {
            "data": data,
            "files": files,
        }
        # If we are running on a machine that has special debian CA
        # certificates (like *.debian.org machines), use them
        if os.path.exists("/etc/ssl/ca-debian/ca-certificates.crt"):
            args["verify"] = "/etc/ssl/ca-debian/ca-certificates.crt"
        else:
            args["verify"] = True

        session = requests.Session()
        try:
            # Do a GET before the POST, to do HTTPS negotiation without a huge
            # payload. See #801506
            res = session.get(url)
            res.raise_for_status()
            res = session.post(url, **args)
            res.raise_for_status()
        except requests.ConnectionError as e:
            return False, {
                "code": None,
                "errors": [
                    "Connection error: " + str(e)
                ]
            }
        except requests.HTTPError as e:
            try:
                parsed = json.loads(res.text)
                errors = parsed["errors"]
            except Exception:
                errors = []
            return False, {
                "code": None,
                "errors": [
                    "Server responded with an HTTP error: " + str(e)
                ] + errors
            }

        # Whether the POST was successful or not, the response body contains
        # information and statistics in JSON format.
        response = res.json()
        if res.status_code == requests.codes.ok:
            return True, response
        else:
            return False, response

    @classmethod
    def from_json(cls, name, data):
        """
        Build a Submission from previously generated JSON

        name: the data source name
        data: the JSON data, either in a string, in a file, or as a parsed data
              structure
        """
        if isinstance(data, str):
            data = json.loads(data)
        elif hasattr(data, "read"):
            data = json.load(data)

        res = cls(name)

        from .parser import Parser
        parser = Parser()
        for ids, contribs in parser.parse_submission(data):
            for i in ids:
                res.entries[i] = {c.type: c for c in contribs}

        return res


def merge_contrib_dicts(d1, d2):
    """
    Merge two dicts of contributions from the same identifier.

    Contribution types that happen in both lists will have their timespans
    merged
    """
    res = {}

    # Add elements from d1, merging them with d2 if they also exist in d2
    for ctype, c1 in d1.items():
        c2 = d2.get(ctype, None)
        if c2 is None:
            res[ctype] = c1
        else:
            res[ctype] = Contribution.merged(c1, c2)

    # Add the elements that only exist in d2
    for ctype, c2 in d2.items():
        res.setdefault(ctype, c2)

    return res
