#  Copyright 2020 Tom Caruso & individual contributors
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.

import struct as _struct

from geomet.util import (
    as_bin_str as _as_bin_str,
    take as _take,
    endian_token as _endian_token
)
from geomet import wkb as _wkb


def dump(obj, dest_file, big_endian=True):
    """
    Dump GeoJSON-like `dict` to GeoPackage binary
    and write it to the `dest_file`.

    :param dict obj:
        A GeoJSON-like dictionary. It must at least the keys 'type' and
        'coordinates'.
    :param dest_file:
        Open and writable file-like object.
    :param bool big_endian:
        specify endianess of the dumped object.

    :return:
    """
    dest_file.write(dumps(obj, big_endian))


def load(source_file):
    """
    Load a GeoJSON `dict` object from a ``source_file`` containing
    GeoPackage (as a byte string).

    :param source_file:
        Open and readable file-like object.

    :return:
        A GeoJSON `dict` representing the geometry read from the file.
    """
    return loads(source_file.read())


def dumps(obj, big_endian=True):
    """
    Dump a GeoJSON-like dict to a GeoPackage bytestring.


    If the dict contains a top-level 'meta' key like so:

    ```
    'meta': {'srid': 4326}
    ```
    then the srid will be added to the geopackage header, but *not*
    to the WKB geometry header.


    If the dict contains a top-level 'bbox' key like so:

    ```
    'bbox': [0, 0, 3, 3]
    ```

    Then an envelope will be added to the geopackage header
    with this information.


    If the geometry's coordinates are empty (an empty list)
    then the geopackage header's "empty" flag will be set,
    denoting that this geometry has no coordinates.

    Please note that while this library can parse geopackages
    with a mixed byte-order in the header, it will only produce
    blobs with consistent byte order (albeit properly marked as such).
    That means you cannot product a geopackage with e.g. little-endian
    header and big-endian WKB geometry.

    :param dict obj:
        The geojson geometry to dump
    :param bool big_endian:
        if True, the geopackage binary will use big-endian
        byte order, little-endian otherwise.

    :return bytes:
        bytestring representing the geometry in geopackage
        format.
    """
    header = _build_geopackage_header(obj, not big_endian)
    result = _wkb._dumps(obj, big_endian, include_meta=False)
    return header + result


def loads(string):
    """
    Construct a GeoJSON `dict` from geopackage (string).

    This function strips the geopackage header from the
    string and passes the remaining WKB geometry to the
    `geomet.wkb.loads` function.

    The envelope, if present, is added to the GeoJSON as
    a key called 'bbox' as per the GeoJSON spec, [1].

    If an SRID is specified in the geopackage header
    AND the wkb header, the SRID in the geopackage header
    will take precedence and will replace that SRID
    in the returned dict.

    [1] https://tools.ietf.org/html/rfc7946#section-5

    :param bytes string:
        geopackage byte string.
    :return dict:
        GeoJSON represented the parsed geopackage binary.
    """
    string = iter(string)

    header = _as_bin_str(_take(_GeoPackage.HEADER_LEN, string))

    _check_is_valid(header)
    g, p, version, empty, envelope_indicator, is_little_endian, srid = (
        _parse_header(header)
    )

    wkb_offset = _get_wkb_offset(envelope_indicator)
    left_to_take = (wkb_offset - _GeoPackage.HEADER_LEN)
    envelope_data = _as_bin_str(_take(left_to_take, string))

    if envelope_data:
        envelope = _parse_envelope(
            envelope_indicator, envelope_data, is_little_endian
        )

    result = _wkb.loads(string)

    if srid:
        result['meta'] = {'srid': int(srid)}
        result['crs'] = {
            'type': 'name',
            'properties': {'name': 'EPSG%s' % srid},
        }

    if envelope_data:
        result['bbox'] = envelope

    return result


class _GeoPackage:
    """
    Much more information on geopackage structure
    can be found here: http://www.geopackage.org/spec/#gpb_format
    """
    # The ascii letter 'G'
    MAGIC1 = 0x47
    # The ascii letter 'P'
    MAGIC2 = 0x50
    VERSION1 = 0x00
    HEADER_LEN = 8
    HEADER_PACK_FMT = "BBBBI"
    ENVELOPE_2D_LEN = 32
    ENVELOPE_3D_LEN = 48
    ENVELOPE_4D_LEN = 64
    ENVELOPE_MASK = 0b00001111
    EMPTY_GEOM_MASK = 0b00011111
    ENDIANNESS_MASK = 0b00000001


# map the "envelope indicator" integer we get out of the geopackage header
# to the dimensionality of the envelope.
# more info here: http://www.geopackage.org/spec/#gpb_format
# in the "flags" section, bits 3, 2, 1.
_indicator_to_dim = {
    0: 0,
    1: 4,
    2: 6,
    3: 6,
    4: 8,
}

# Map the dimensionality of our envelope to the indicator
# integer we will use in the geopackage binary header.
# because we have no way to tell between Z and M values,
# if the geometry has 3 dimensions we default to assume Z.
_dim_to_indicator = {
    0: 0,
    4: 1,
    6: 2,
    8: 4
}


def is_valid(data):
    """
    Check if the data represents a valid geopackage
    geometry. Input can be either the full geometry or
    just the header.

    :param bytes data:
        bytes representing the geopackage binary.

    :return (bool, str):
        Is the geopackage valid, if not, string describing why
    """
    g, p, version, _, envelope_indicator, _, _ = _parse_header(data[:8])
    if (g != _GeoPackage.MAGIC1) or (p != _GeoPackage.MAGIC2):
        return False, "Missing Geopackage header magic bytes"
    if version != _GeoPackage.VERSION1:
        return False, "Geopackage version must be 0"
    if (envelope_indicator < 0) or (envelope_indicator > 4):
        return False, "Envelope indicator must be between 0-4"
    return True, ""


def _header_is_little_endian(header):
    """
    Check to see if the header is encoded
    as little endian or big endian.

    Either the entire binary blob or
    just the header can be passed in.

    :param bytes header:
        geopackage header or binary blob

    :return bool: is the header little endian
    """
    (flags,) = _struct.unpack("B", header[3:4])
    return flags & _GeoPackage.ENDIANNESS_MASK


def _parse_header(header):
    """
    Unpack all information from the geopackage
    header, including "magic" GP bytes. Returns
    all of them so we can confirm that this
    geopackage is validly formed. Can also accept
    the full binary blob.

    :param header:
        the header or the full geometry.

    :return 7-tuple:
        all attributes stored in the binary header.
    """
    is_little_endian = _header_is_little_endian(header)
    fmt = _endian_token(is_little_endian) + _GeoPackage.HEADER_PACK_FMT

    g, p, version, flags, srid = _struct.unpack(
        fmt, header[:_GeoPackage.HEADER_LEN]
    )