import re
import threading
from ctypes import POINTER, Structure, byref, c_byte, c_char_p, c_int, c_size_t

from django.contrib.gis.geos.base import GEOSBase
from django.contrib.gis.geos.libgeos import (
    GEOM_PTR,
    GEOSFuncFactory,
    geos_version_tuple,
)
from django.contrib.gis.geos.prototypes.errcheck import (
    check_geom,
    check_sized_string,
    check_string,
)
from django.contrib.gis.geos.prototypes.geom import c_uchar_p, geos_char_p
from django.utils.encoding import force_bytes
from django.utils.functional import SimpleLazyObject
from django.utils.regex_helper import _lazy_re_compile


# ### The WKB/WKT Reader/Writer structures and pointers ###
class WKTReader_st(Structure):
    pass


class WKTWriter_st(Structure):
    pass


class WKBReader_st(Structure):
    pass


class WKBWriter_st(Structure):
    pass


WKT_READ_PTR = POINTER(WKTReader_st)
WKT_WRITE_PTR = POINTER(WKTWriter_st)
WKB_READ_PTR = POINTER(WKBReader_st)
WKB_WRITE_PTR = POINTER(WKBReader_st)

# WKTReader routines
wkt_reader_create = GEOSFuncFactory("GEOSWKTReader_create", restype=WKT_READ_PTR)
wkt_reader_destroy = GEOSFuncFactory("GEOSWKTReader_destroy", argtypes=[WKT_READ_PTR])

wkt_reader_read = GEOSFuncFactory(
    "GEOSWKTReader_read",
    argtypes=[WKT_READ_PTR, c_char_p],
    restype=GEOM_PTR,
    errcheck=check_geom,
)
# WKTWriter routines
wkt_writer_create = GEOSFuncFactory("GEOSWKTWriter_create", restype=WKT_WRITE_PTR)
wkt_writer_destroy = GEOSFuncFactory("GEOSWKTWriter_destroy", argtypes=[WKT_WRITE_PTR])

wkt_writer_write = GEOSFuncFactory(
    "GEOSWKTWriter_write",
    argtypes=[WKT_WRITE_PTR, GEOM_PTR],
    restype=geos_char_p,
    errcheck=check_string,
)

wkt_writer_get_outdim = GEOSFuncFactory(
    "GEOSWKTWriter_getOutputDimension", argtypes=[WKT_WRITE_PTR], restype=c_int
)
wkt_writer_set_outdim = GEOSFuncFactory(
    "GEOSWKTWriter_setOutputDimension", argtypes=[WKT_WRITE_PTR, c_int]
)

wkt_writer_set_trim = GEOSFuncFactory(
    "GEOSWKTWriter_setTrim", argtypes=[WKT_WRITE_PTR, c_byte]
)
wkt_writer_set_precision = GEOSFuncFactory(
    "GEOSWKTWriter_setRoundingPrecision", argtypes=[WKT_WRITE_PTR, c_int]
)

# WKBReader routines
wkb_reader_create = GEOSFuncFactory("GEOSWKBReader_create", restype=WKB_READ_PTR)
wkb_reader_destroy = GEOSFuncFactory("GEOSWKBReader_destroy", argtypes=[WKB_READ_PTR])


class WKBReadFunc(GEOSFuncFactory):
    # Although the function definitions take `const unsigned char *`
    # as their parameter, we use c_char_p here so the function may
    # take Python strings directly as parameters. Inside Python there
    # is not a difference between signed and unsigned characters, so
    # it is not a problem.
    argtypes = [WKB_READ_PTR, c_char_p, c_size_t]
    restype = GEOM_PTR
    errcheck = staticmethod(check_geom)


wkb_reader_read = WKBReadFunc("GEOSWKBReader_read")
wkb_reader_read_hex = WKBReadFunc("GEOSWKBReader_readHEX")

# WKBWriter routines
wkb_writer_create = GEOSFuncFactory("GEOSWKBWriter_create", restype=WKB_WRITE_PTR)
wkb_writer_destroy = GEOSFuncFactory("GEOSWKBWriter_destroy", argtypes=[WKB_WRITE_PTR])


# WKB Writing prototypes.
class WKBWriteFunc(GEOSFuncFactory):
    argtypes = [WKB_WRITE_PTR, GEOM_PTR, POINTER(c_size_t)]
    restype = c_uchar_p
    errcheck = staticmethod(check_sized_string)


wkb_writer_write = WKBWriteFunc("GEOSWKBWriter_write")
wkb_writer_write_hex = WKBWriteFunc("GEOSWKBWriter_writeHEX")


# WKBWriter property getter/setter prototypes.
class WKBWriterGet(GEOSFuncFactory):
    argtypes = [WKB_WRITE_PTR]
    restype = c_int


class WKBWriterSet(GEOSFuncFactory):
    argtypes = [WKB_WRITE_PTR, c_int]


wkb_writer_get_byteorder = WKBWriterGet("GEOSWKBWriter_getByteOrder")
wkb_writer_set_byteorder = WKBWriterSet("GEOSWKBWriter_setByteOrder")
wkb_writer_get_outdim = WKBWriterGet("GEOSWKBWriter_getOutputDimension")
wkb_writer_set_outdim = WKBWriterSet("GEOSWKBWriter_setOutputDimension")
wkb_writer_get_include_srid = WKBWriterGet(
    "GEOSWKBWriter_getIncludeSRID", restype=c_byte
)
wkb_writer_set_include_srid = WKBWriterSet(
    "GEOSWKBWriter_setIncludeSRID", argtypes=[WKB_WRITE_PTR, c_byte]
)


# ### Base I/O Class ###
class IOBase(GEOSBase):
    "Base class for GEOS I/O objects."

    def __init__(self):
        # Getting the pointer with the constructor.
        self.ptr = self._constructor()
        # Loading the real destructor function at this point as doing it in
        # __del__ is too late (import error).
        self.destructor.func


# ### Base WKB/WKT Reading and Writing objects ###

# Sits just under PostGIS's effective ceiling: liblwgeom's LW_PARSER_MAX_DEPTH
# is 200 and counts the leaf geometry, so PostGIS rejects at 199 nested
# collections. 198 keeps Django's guard below that (and far below the GEOS
# segfault threshold) so it rejects before any backend supporting nested
# geometries does. (Oracle and MariaDB don't support nesting.)
MAX_GEOM_COLLECTIONS = 198

# GEOS accepts any amount of whitespace around the optional dimension marker,
# so the separators must be \s*, not \s? or \s+. The root variants also allow
# leading whitespace, which GEOS skips before the geometry type.
_WKT_COLLECTION_START_RE = _lazy_re_compile(
    r"\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(",
    re.IGNORECASE,
)
_WKT_COLLECTION_START_BYTES_RE = _lazy_re_compile(
    rb"\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(",
    re.IGNORECASE,
)
_WKT_COLLECTION_ROOT_RE = _lazy_re_compile(
    r"\s*\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(",
    re.IGNORECASE,
)
_WKT_COLLECTION_ROOT_BYTES_RE = _lazy_re_compile(
    rb"\s*\bGEOMETRYCOLLECTION(?:\s*(?:ZM|Z|M))?\s*\(",
    re.IGNORECASE,
)


def _build_collection_header_re():
    """GEOS normalizes WKB types using: (type_code & 0xFFFF) % 1000

    Therefore, every low 16-bit value congruent to 7 modulo 1000 is interpreted
    as a GeometryCollection. Upper 16 bits may contain arbitrary EWKB flags.
    """
    low_types = range(7, 0x10000, 1000)
    little_endian_types = b"|".join(
        re.escape(type_code.to_bytes(2, "little")) for type_code in low_types
    )
    big_endian_types = b"|".join(
        re.escape(type_code.to_bytes(2, "big")) for type_code in low_types
    )
    return _lazy_re_compile(
        rb"(?=("
        rb"\x01(?:" + little_endian_types + rb")[\x00-\xff]{2}"
        rb"|"
        rb"\x00[\x00-\xff]{2}(?:" + big_endian_types + rb")"
        rb"))"
    )


_COLLECTION_HEADER_RE = _build_collection_header_re()


# Non-public WKB/WKT reader classes for internal use because
# their `read` methods return _pointers_ instead of GEOSGeometry
# objects.
class _WKTReader(IOBase):
    _constructor = wkt_reader_create
    ptr_type = WKT_READ_PTR
    destructor = wkt_reader_destroy

    def limit(self, wkt, max_geom_collections):
        if max_geom_collections is None:
            return
        if isinstance(wkt, str):
            pattern = _WKT_COLLECTION_START_RE
            root_pattern = _WKT_COLLECTION_ROOT_RE
            open_paren = "("
            close_paren = ")"
        else:
            pattern = _WKT_COLLECTION_START_BYTES_RE
            root_pattern = _WKT_COLLECTION_ROOT_BYTES_RE
            open_paren = ord("(")
            close_paren = ord(")")
        if root_pattern.match(wkt) is None:
            # Fast path: If the beginning does not match GEOMETRYCOLLECTION(,
            # then GEOS rejects early (no need to limit):
            # GEOS_ERROR: countered : 'GEOMETRYCOLLECTION'
            return
        collection_starts = {match.end() - 1 for match in pattern.finditer(wkt)}
        # Nesting depth can't exceed the total number of collections, so if the
        # total is already within the limit, there is nothing to walk.
        if len(collection_starts) <= max_geom_collections:
            return
        collection_depth = 0
        parentheses = []
        for index, char in enumerate(wkt):
            if char == open_paren:
                is_collection = index in collection_starts
                parentheses.append(is_collection)
                if is_collection:
                    collection_depth += 1
            elif char == close_paren and parentheses:
                if parentheses.pop():
                    collection_depth -= 1
            if collection_depth > max_geom_collections:
                msg = "WKT contains too many possible GeometryCollections."
                raise ValueError(msg)

    def read(self, wkt, max_geom_collections=MAX_GEOM_COLLECTIONS):
        if not isinstance(wkt, (bytes, str)):
            raise TypeError(f"'wkt' must be bytes or str (got {wkt!r} instead).")
        self.limit(wkt, max_geom_collections)
        return wkt_reader_read(self.ptr, force_bytes(wkt))


class _WKBReader(IOBase):
    _constructor = wkb_reader_create
    ptr_type = WKB_READ_PTR
    destructor = wkb_reader_destroy

    def limit_wkb(self, wkb, max_geom_collections):
        if max_geom_collections is None:
            return
        for count, _ in enumerate(_COLLECTION_HEADER_RE.finditer(wkb), 1):
            if count > max_geom_collections:
                msg = "WKB contains too many possible GeometryCollections."
                raise ValueError(msg)

    def limit_hex(self, wkb, max_geom_collections):
        if max_geom_collections is None:
            return

        def _byteswap_uint32(value):
            return (
                ((value & 0x000000FF) << 24)
                | ((value & 0x0000FF00) << 8)
                | ((value & 0x00FF0000) >> 8)
                | ((value & 0xFF000000) >> 24)
            )

        count = 0
        for index in range(0, len(wkb) - 9, 2):
            byte_order = wkb[index : index + 2]
            if byte_order not in (b"00", b"01"):
                continue
            try:
                geometry_type = int(wkb[index + 2 : index + 10], 16)
            except ValueError:
                continue
            geometry_type = _byteswap_uint32(geometry_type)
            # Match GEOS WKBReader's geometry-type normalization.
            if (geometry_type & 0xFFFF) % 1000 == 7:  # GeometryCollection.
                count += 1
                if count > max_geom_collections:
                    msg = "WKB contains too many possible GeometryCollections."
                    raise ValueError(msg)

    def read(self, wkb, max_geom_collections=MAX_GEOM_COLLECTIONS):
        "Return a _pointer_ to C GEOS Geometry object from the given WKB."
        limiter = self.limit_hex
        reader = wkb_reader_read_hex

        if isinstance(wkb, memoryview):
            wkb = bytes(wkb)
            limiter = self.limit_wkb
            reader = wkb_reader_read
        elif isinstance(wkb, str):
            wkb = wkb.encode()
        elif not isinstance(wkb, bytes):
            raise TypeError(
                f"'wkb' must be bytes, str or memoryview (got {wkb!r} instead)."
            )

        # Limit nested geometry collections. Should become unnecessary when
        # GEOS 3.15.0 is the minimum supported version. See:
        # https://github.com/libgeos/geos/commit/8b8b3da7a3d9fb8953ff60bc49aa0320d51ae45c
        limiter(wkb, max_geom_collections)
        return reader(self.ptr, wkb, len(wkb))


def default_trim_value():
    """
    GEOS changed the default value in 3.12.0. Can be replaced by True when
    3.12.0 becomes the minimum supported version.
    """
    return geos_version_tuple() >= (3, 12)


DEFAULT_TRIM_VALUE = SimpleLazyObject(default_trim_value)


# ### WKB/WKT Writer Classes ###
class WKTWriter(IOBase):
    _constructor = wkt_writer_create
    ptr_type = WKT_WRITE_PTR
    destructor = wkt_writer_destroy
    _precision = None

    def __init__(self, dim=2, trim=False, precision=None):
        super().__init__()
        self._trim = DEFAULT_TRIM_VALUE
        self.trim = trim
        if precision is not None:
            self.precision = precision
        self.outdim = dim

    def write(self, geom):
        "Return the WKT representation of the given geometry."
        return wkt_writer_write(self.ptr, geom.ptr)

    @property
    def outdim(self):
        return wkt_writer_get_outdim(self.ptr)

    @outdim.setter
    def outdim(self, new_dim):
        if new_dim not in (2, 3):
            raise ValueError("WKT output dimension must be 2 or 3")
        wkt_writer_set_outdim(self.ptr, new_dim)

    @property
    def trim(self):
        return self._trim

    @trim.setter
    def trim(self, flag):
        if bool(flag) != self._trim:
            self._trim = bool(flag)
            wkt_writer_set_trim(self.ptr, self._trim)

    @property
    def precision(self):
        return self._precision

    @precision.setter
    def precision(self, precision):
        if (not isinstance(precision, int) or precision < 0) and precision is not None:
            raise AttributeError(
                "WKT output rounding precision must be non-negative integer or None."
            )
        if precision != self._precision:
            self._precision = precision
            wkt_writer_set_precision(self.ptr, -1 if precision is None else precision)


class WKBWriter(IOBase):
    _constructor = wkb_writer_create
    ptr_type = WKB_WRITE_PTR
    destructor = wkb_writer_destroy
    geos_version = geos_version_tuple()

    def __init__(self, dim=2):
        super().__init__()
        self.outdim = dim

    def _handle_empty_point(self, geom):
        from django.contrib.gis.geos import Point

        if isinstance(geom, Point) and geom.empty:
            if self.srid:
                # PostGIS uses POINT(NaN NaN) for WKB representation of empty
                # points. Use it for EWKB as it's a PostGIS specific format.
                # https://trac.osgeo.org/postgis/ticket/3181
                geom = Point(float("NaN"), float("NaN"), srid=geom.srid)
            else:
                raise ValueError("Empty point is not representable in WKB.")
        return geom

    def write(self, geom):
        "Return the WKB representation of the given geometry."
        geom = self._handle_empty_point(geom)
        wkb = wkb_writer_write(self.ptr, geom.ptr, byref(c_size_t()))
        return memoryview(wkb)

    def write_hex(self, geom):
        "Return the HEXEWKB representation of the given geometry."
        geom = self._handle_empty_point(geom)
        wkb = wkb_writer_write_hex(self.ptr, geom.ptr, byref(c_size_t()))
        return wkb

    # ### WKBWriter Properties ###

    # Property for getting/setting the byteorder.
    def _get_byteorder(self):
        return wkb_writer_get_byteorder(self.ptr)

    def _set_byteorder(self, order):
        if order not in (0, 1):
            raise ValueError(
                "Byte order parameter must be 0 (Big Endian) or 1 (Little Endian)."
            )
        wkb_writer_set_byteorder(self.ptr, order)

    byteorder = property(_get_byteorder, _set_byteorder)

    # Property for getting/setting the output dimension.
    @property
    def outdim(self):
        return wkb_writer_get_outdim(self.ptr)

    @outdim.setter
    def outdim(self, new_dim):
        if new_dim not in (2, 3):
            raise ValueError("WKB output dimension must be 2 or 3")
        wkb_writer_set_outdim(self.ptr, new_dim)

    # Property for getting/setting the include srid flag.
    @property
    def srid(self):
        return bool(wkb_writer_get_include_srid(self.ptr))

    @srid.setter
    def srid(self, include):
        wkb_writer_set_include_srid(self.ptr, bool(include))


# `ThreadLocalIO` object holds instances of the WKT and WKB reader/writer
# objects that are local to the thread. The `GEOSGeometry` internals
# access these instances by calling the module-level functions, defined
# below.
class ThreadLocalIO(threading.local):
    wkt_r = None
    wkt_w = None
    wkb_r = None
    wkb_w = None
    ewkb_w = None


thread_context = ThreadLocalIO()


# These module-level routines return the I/O object that is local to the
# thread. If the I/O object does not exist yet it will be initialized.
def wkt_r():
    thread_context.wkt_r = thread_context.wkt_r or _WKTReader()
    return thread_context.wkt_r


def wkt_w(dim=2, trim=False, precision=None):
    if not thread_context.wkt_w:
        thread_context.wkt_w = WKTWriter(dim=dim, trim=trim, precision=precision)
    else:
        thread_context.wkt_w.outdim = dim
        thread_context.wkt_w.trim = trim
        thread_context.wkt_w.precision = precision
    return thread_context.wkt_w


def wkb_r():
    thread_context.wkb_r = thread_context.wkb_r or _WKBReader()
    return thread_context.wkb_r


def wkb_w(dim=2):
    if not thread_context.wkb_w:
        thread_context.wkb_w = WKBWriter(dim=dim)
    else:
        thread_context.wkb_w.outdim = dim
    return thread_context.wkb_w


def ewkb_w(dim=2):
    if not thread_context.ewkb_w:
        thread_context.ewkb_w = WKBWriter(dim=dim)
        thread_context.ewkb_w.srid = True
    else:
        thread_context.ewkb_w.outdim = dim
    return thread_context.ewkb_w
