##################################################################
# Copyright 2018 Open Source Geospatial Foundation and others    #
# licensed under MIT, Please consult LICENSE.txt for details     #
##################################################################
"""
WPS Output classes
"""
import os
import re
from typing import Dict, Optional, Sequence, Union

from pywps import configuration as config
from pywps import xml_util as etree
from pywps.app.Common import Metadata
from pywps.exceptions import InvalidParameterValue
from pywps.inout import basic
from pywps.inout.formats import FORMATS, Format, Supported_Formats
from pywps.inout.storage.file import FileStorageBuilder
from pywps.inout.types import Translations
from pywps.validator.mode import MODE


class BoundingBoxOutput(basic.BBoxOutput):
    """
    :param identifier: The name of this input.
    :param str title: Title of the input
    :param str abstract: Input abstract
    :param crss: List of supported coordinate reference system (e.g. ['EPSG:4326'])
    :param int dimensions: number of dimensions (2 or 3)
    :param int min_occurs: minimum occurence
    :param int max_occurs: maximum occurence
    :param pywps.validator.mode.MODE mode: validation mode (none to strict)
    :param metadata: List of metadata advertised by this process. They
                     should be :class:`pywps.app.Common.Metadata` objects.
    :param dict[str,dict[str,str]] translations: The first key is the RFC 4646 language code,
        and the nested mapping contains translated strings accessible by a string property.
        e.g. {"fr-CA": {"title": "Mon titre", "abstract": "Une description"}}
    """

    def __init__(self, identifier, title, crss, abstract='', keywords=[],
                 dimensions=2, metadata=[], min_occurs='1',
                 max_occurs='1', as_reference=False,
                 mode=MODE.NONE, translations=None):
        basic.BBoxOutput.__init__(self, identifier, title=title,
                                  abstract=abstract, keywords=keywords, crss=crss,
                                  dimensions=dimensions, mode=mode, translations=translations)

        self.metadata = metadata
        self.min_occurs = min_occurs
        self.max_occurs = max_occurs
        self.as_reference = as_reference

    @property
    def json(self):
        """Get JSON representation of the output."""
        return {
            'identifier': self.identifier,
            'title': self.title,
            'abstract': self.abstract,
            'keywords': self.keywords,
            'min_occurs': self.min_occurs,
            'max_occurs': self.max_occurs,
            'metadata': self.metadata,
            'type': 'bbox',
            'crs': self.crs,
            'crss': self.crss,
            'dimensions': self.dimensions,
            'bbox': self.data,
            'll': self.ll,
            'ur': self.ur,
            'workdir': self.workdir,
            'mode': self.valid_mode,
            'translations': self.translations,
        }

    @classmethod
    def from_json(cls, json_output):
        instance = cls(
            identifier=json_output['identifier'],
            title=json_output['title'],
            abstract=json_output['abstract'],
            keywords=json_output['keywords'],
            min_occurs=json_output['min_occurs'],
            max_occurs=json_output['max_occurs'],
            metadata=[Metadata.from_json(data) for data in json_output.get('metadata', [])],
            crss=json_output['crss'],
            dimensions=json_output['dimensions'],
            mode=json_output['mode'],
            translations=json_output.get('translations'),
        )
        instance.data = json_output['bbox']
        instance.workdir = json_output['workdir']

        return instance


class ComplexOutput(basic.ComplexOutput):
    """
    :param identifier: The name of this output.
    :param title: Readable form of the output name.
    :param supported_formats: List of supported
        formats. The first format in the list will be used as the default.
    :type supported_formats: (pywps.inout.formats.Format, )
    :param str abstract: Description of the output
    :param pywps.validator.mode.MODE mode: validation mode (none to strict)
    :param metadata: List of metadata advertised by this process. They
                     should be :class:`pywps.app.Common.Metadata` objects.
    :param dict[str,dict[str,str]] translations: The first key is the RFC 4646 language code,
        and the nested mapping contains translated strings accessible by a string property.
        e.g. {"fr-CA": {"title": "Mon titre", "abstract": "Une description"}}
    """

    def __init__(self, identifier: str, title: str, supported_formats: Supported_Formats = None,
                 data_format=None, abstract: str = '', keywords=[], workdir=None,
                 metadata: Optional[Sequence[Metadata]] = None,
                 as_reference=False, mode: MODE = MODE.NONE, translations: Translations = None):
        if metadata is None:
            metadata = []

        basic.ComplexOutput.__init__(self, identifier, title=title,
                                     data_format=data_format, abstract=abstract, keywords=keywords,
                                     workdir=workdir,
                                     supported_formats=supported_formats,
                                     mode=mode, translations=translations)
        self.metadata = metadata
        self.as_reference = as_reference

        self.storage = None

    @property
    def json(self):
        """Get JSON representation of the output."""
        data = {
            "identifier": self.identifier,
            "title": self.title,
            "abstract": self.abstract,
            'keywords': self.keywords,
            'type': 'complex',
            'supported_formats': [frmt.json for frmt in self.supported_formats],
            'asreference': self.as_reference,
            'data_format': self.data_format.json if self.data_format else None,
            'file': self.file if self.prop == 'file' else None,
            'workdir': self.workdir,
            'mode': self.valid_mode,
            'min_occurs': self.min_occurs,
            'max_occurs': self.max_occurs,
            'translations': self.translations,
        }

        if self.data_format:
            if self.data_format.mime_type:
                data['mimetype'] = self.data_format.mime_type
            if self.data_format.encoding:
                data['encoding'] = self.data_format.encoding
            if self.data_format.schema:
                data['schema'] = self.data_format.schema

        if self.as_reference:
            data = self._json_reference(data)
        else:
            data = self._json_data(data)

        return data

    @classmethod
    def from_json(cls, json_output):
        instance = cls(
            identifier=json_output['identifier'],
            title=json_output.get('title'),
            abstract=json_output.get('abstract'),
            keywords=json_output.get('keywords', []),
            workdir=json_output.get('workdir'),
            metadata=[Metadata.from_json(data) for data in json_output.get('metadata', [])],
            data_format=Format(
                schema=json_output['data_format'].get('schema'),
                extension=json_output['data_format'].get('extension'),
                mime_type=json_output['data_format']['mime_type'],
                encoding=json_output['data_format'].get('encoding')
            ),
            supported_formats=[
                Format(
                    schema=infrmt.get('schema'),
                    extension=infrmt.get('extension'),
                    mime_type=infrmt['mime_type'],
                    encoding=infrmt.get('encoding')
                ) for infrmt in json_output['supported_formats']
            ],
            mode=json_output.get('mode', MODE.NONE),
            translations=json_output.get('translations'),
        )
        instance.as_reference = json_output.get('asreference', False)
        if json_output.get('file'):
            instance.file = json_output['file']

        return instance

    def _json_reference(self, data):
        """Return Reference node."""
        data["type"] = "reference"

        # get_url will create the file and return the url for it
        if self.prop == 'url':
            data["href"] = self.url
        elif self.prop is not None:
            self.storage = FileStorageBuilder().build()
            data["href"] = self.get_url()

        return data

    def _json_data(self, data):
        """Return Data node
        """
        data["type"] = "complex"

        if self.data:
            if self.data_format.mime_type in [FORMATS.GEOJSON.mime_type, FORMATS.JSON.mime_type]:
                data["data"] = self.data
            elif self.data_format.mime_type in ["application/xml", "application/gml+xml", "text/xml"]:
                # Note that in a client-server round trip, the original and returned file will not be identical.
                data_doc = etree.parse(self.file)
                data["data"] = etree.tostring(data_doc, pretty_print=True).decode('utf-8')

            else:
                if self.data_format.encoding == 'base64':
                    data["data"] = self.base64.decode('utf-8')
                else:
                    # Match only data that are safe CDATA pattern.
                    CDATA_PATTERN = re.compile(r'^<!\[CDATA\[((?!\]\]>).)*\]\]>$')

                    # Otherwise we assume all other formats are unsafe and need to be enclosed in a CDATA tag.
                    if isinstance(self.data, bytes):
                        # Try to inline data as text but if fail encode is in base64
                        if self.data_format.encoding == 'utf-8':
                            out = self.data.decode('utf-8')
                            # If data is already enclosed with CDATA pattern, do not add it twice
                            if CDATA_PATTERN.match(out):
                                data["data"] = out
                            else:
                                # Check if the data does not contain ]]> patern if is safe to use CDATA
                                # otherwise we fall back to base64 encoding.
                                if not re.search('\\]\\]>', out):
                                    data["data"] = '<![CDATA[{}]]>'.format(out)
                                else:
                                    data['encoding'] = 'base64'  # override the unsafe encoding
                                    data["data"] = self.base64.decode('utf-8')
                        else:
                            data['encoding'] = 'base64'  # override the unsafe encoding
                            data["data"] = self.base64.decode('utf-8')
                    else:
                        out = str(self.data)
                        # If data is already enclose with CDATApatern do not add it twise
                        if CDATA_PATTERN.match(out):
                            data["data"] = out
                        else:
                            # Check if the data does not contain ]]> pattern if is safe to use CDATA
                            # otherwise we fall back to base64 encoding.
                            if not re.search('\\]\\]>', out):
                                data["data"] = '<![CDATA[{}]]>'.format(out)
                            else:
                                data['encoding'] = 'base64'  # override the unsafe encoding
                                data["data"] = self.base64.decode('utf-8')

        return data


class LiteralOutput(basic.LiteralOutput):
    """
    :param identifier: The name of this output.
    :param str title: Title of the input
    :param pywps.inout.literaltypes.LITERAL_DATA_TYPES data_type: data type
    :param str abstract: Input abstract
    :param list uoms: units
    :param pywps.validator.mode.MODE mode: validation mode (none to strict)
    :param metadata: List of metadata advertised by this process. They
                     should be :class:`pywps.app.Common.Metadata` objects.
    :param dict[str,dict[str,str]] translations: The first key is the RFC 4646 language code,
        and the nested mapping contains translated strings accessible by a string property.
        e.g. {"fr-CA": {"title": "Mon titre", "abstract": "Une description"}}
    """

    def __init__(self, identifier, title, data_type='string', abstract='', keywords=[],
                 metadata=[], uoms=None, mode=MODE.SIMPLE, translations=None):
        if uoms is None:
            uoms = []
        basic.LiteralOutput.__init__(self, identifier, title=title, abstract=abstract, keywords=keywords,
                                     data_type=data_type, uoms=uoms, mode=mode, translations=translations)
        self.metadata = metadata

    @property
    def json(self):
        """Get JSON representation of the output
        """
        data = {
            "identifier": self.identifier,
            "title": self.title,
            "abstract": self.abstract,
            "keywords": self.keywords,
            "data": self.data,
            "data_type": self.data_type,
            "type": "literal",
            "uoms": [u.json for u in self.uoms],
            "translations": self.translations,
        }

        if self.uom:
            data["uom"] = self.uom.json

        return data

    @classmethod
    def from_json(cls, json_output):
        uoms = [
            basic.UOM(uom['uom'], uom['reference'])
            for uom in json_output.get('uoms', [])
        ]
        uom = json_output.get('uom')

        instance = cls(
            identifier=json_output['identifier'],
            title=json_output['title'],
            data_type=json_output['data_type'],
            abstract=json_output['abstract'],
            keywords=json_output['keywords'],
            uoms=uoms,
            translations=json_output.get('translations'),
        )

        instance.data = json_output.get('data')
        if uom:
            instance.uom = basic.UOM(uom['uom'], uom['reference'])

        return instance


class MetaFile:
    """MetaFile object."""

    def __init__(self, identity=None, description=None, fmt=None):
        """Create a `MetaFile` object.

        :param str identity: human-readable identity.
        :param str description: human-readable file description.
        :param pywps.FORMAT fmt: file mime type.

        The content of each metafile is set like `ComplexOutputs`, ie
        using either the `data`, `file`, `stream` or `url` properties.

        The metalink document is created by a `MetaLink` instance, which
        holds a number of `MetaFile` instances.
        """
        self._size = None
        self._output = ComplexOutput(
            identifier=identity or '',
            title=description or '',
            as_reference=True,
            supported_formats=[fmt, ],
        )

    def _set_workdir(self, workdir):
        self._output.workdir = workdir

    @property
    def hash(self):
        """Text construct that conveys a cryptographic hash for a file.

        All hashes are encoded in lowercase hexadecimal format.  Hashes
        are used to verify the integrity of a complete file or portion
        of a file to determine if the file has been transferred without
        any errors.
        """
        import hashlib
        m = hashlib.sha256()
        with open(self.file, "rb") as f:
            for chunk in iter(lambda: f.read(4096), b""):
                m.update(chunk)
        return m.hexdigest()

    @property
    def identity(self):
        """Human-readable identity."""
        return self._output.identifier

    @property
    def name(self):
        """Indicate a specific file in a document describing multiple files."""
        js = self._output.json
        (_, name) = os.path.split(js.get('href', 'http:///'))
        return name

    @property
    def size(self):
        """Size of the linked content in bytes."""
        if self._size is None:
            self._size = self._output.size
        return self._size

    @size.setter
    def size(self, value):
        """Set size to avoid size calculation."""
        self._size = int(value)

    @property
    def urls(self):
        js = self._output.json
        return [js.get('href', ''), ]

    @property
    def mediatype(self):
        """Multipurpose Internet Mail Extensions (MIME) media type
        [RFC4288] of the metadata file available at the IRI."""
        return self._output.data_format.mime_type

    @property
    def data(self):
        return self._output.data

    @data.setter
    def data(self, value):
        self._output.data = value

    @property
    def file(self):
        return self._output.file

    @file.setter
    def file(self, value):
        self._output.file = value

    @property
    def url(self):
        return self._output.url

    @url.setter
    def url(self, value):
        self._output.url = value

    @property
    def stream(self):
        return self._output.stream

    @stream.setter
    def stream(self, value):
        self._output.stream = value

    def __str__(self):
        out = "MetaFile {}:".format(self.name)
        for url in self.urls:
            out += "\n\t{}".format(url)
        return out

    def __repr__(self):
        return "<pywps.inout.outputs.MetaFile {}>".format(self.name)


class MetaLink:
    _xml_template = 'metalink/3.0/main.xml'
    # Specs: https://www.metalinker.org/Metalink_3.0_Spec.pdf

    def __init__(self, identity=None, description=None, publisher=None, files=(),
                 workdir=None, checksums=False):
        """Create a MetaLink v3.0 instance.

        :param str identity: human-readable identity.
        :param str description: human-readable file description.
        :param str publisher: The name of the file's publisher.
        :param tuple files: Sequence of files to include in Metalink. Can also be added using `append`.
        :param str workdir: Work directory to store temporary files.
        :param bool checksums: Whether to compute checksums on files.

        To use, first append `MetaFile` instances, then write the metalink using the `xml`
        property.

        Methods:
            - `append`: add a `MetaFile` instance
        """
        self.identity = identity
        self.description = description
        self.workdir = workdir
        self.publisher = publisher
        self.files = []
        self.checksums = checksums

        for file in files:
            self.append(file)
        self._load_template()

    def append(self, file):
        """Append a `MetaFile` instance."""
        if not isinstance(file, MetaFile):
            raise ValueError("file must be a MetaFile instance.")
        file._set_workdir(self.workdir)
        self.files.append(file)

    @property
    def xml(self):
        return self._template.render(meta=self)

    @property
    def origin(self):
        """IRI where the Metalink Document was originally published.
        If the dynamic attribute of metalink:origin is "true", then
        updated versions of the Metalink can be found at this IRI.
        """
        return ""

    @property
    def published(self):
        """Date construct indicating an instant in time associated
        with an event early in the life cycle of the entry."""
        import datetime
        return datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")

    @property
    def generator(self):
        """Generating agent name and version used to generate a
        Metalink Document, for debugging and other purposes."""
        import pywps
        return "PyWPS/{}".format(pywps.__version__)

    @property
    def url(self):
        """Return the server URL."""
        return config.get_config_value('server', 'url')

    def _load_template(self):
        from jinja2 import PackageLoader

        from pywps.response import RelEnvironment

        template_env = RelEnvironment(
            loader=PackageLoader('pywps', 'templates'),
            trim_blocks=True, lstrip_blocks=True,
            autoescape=True, )

        self._template = template_env.get_template(self._xml_template)


class MetaLink4(MetaLink):
    _xml_template = 'metalink/4.0/main.xml'
    # Specs: https://tools.ietf.org/html/rfc5854


def output_from_json(json_data):
    data_type = json_data.get('type', 'literal')
    if data_type == 'complex':
        output = ComplexOutput.from_json(json_data)
    elif data_type == 'literal':
        output = LiteralOutput.from_json(json_data)
    elif data_type == 'bbox':
        output = BoundingBoxOutput.from_json(json_data)
    else:
        raise InvalidParameterValue("Output type not recognized: {}".format(data_type))

    return output
