import logging
import numbers
import os
from typing import Union, List, Tuple

import sys

from osgeo import ogr, osr

logger = logging.getLogger(__name__)


geometry_map = {
    "Point": ogr.wkbPoint,
    "LineString": ogr.wkbLineString,
    "LineString25D": ogr.wkbLineString25D,
    "Polygon": ogr.wkbPolygon
}


def get_ogr_type(
    ogr_type_str: str
) -> Union[None, 'ogr.OGRFieldType']:
    """
    Parse the provided textual field type to return an actual OGRFieldType.

    :param ogr_type_str: the string referring to the ogr field type.
    :return: the actual ogr type.
    :raise: Exception.
    """

    try:

        if ogr_type_str.endswith("OFTInteger"):
            return ogr.OFTInteger
        elif ogr_type_str.endswith("OFTIntegerList"):
            return ogr.OFTIntegerList
        elif ogr_type_str.endswith("OFTReal"):
            return ogr.OFTReal
        elif ogr_type_str.endswith("OFTRealList"):
            return ogr.OFTRealList
        elif ogr_type_str.endswith("OFTString"):
            return ogr.OFTString
        elif ogr_type_str.endswith("OFTStringList"):
            return ogr.OFTStringList
        elif ogr_type_str.endswith("OFTBinary"):
            return ogr.OFTBinary
        elif ogr_type_str.endswith("OFTDate"):
            return ogr.OFTDate
        elif ogr_type_str.endswith("OFTTime"):
            return ogr.OFTTime
        elif ogr_type_str.endswith("OFTDateTime"):
            return ogr.OFTDateTime
        elif ogr_type_str.endswith("OFTInteger64"):
            return ogr.OFTInteger64
        elif ogr_type_str.endswith("OFTInteger64List"):
            return ogr.OFTInteger64List
        else:
            raise Exception(f"Unrecognized OGR type: {ogr_type_str}")

    except Exception as e:

        print(f"\n{e!r}")
        return None


def create_geopackage(
    path: str,
    overwrite: bool = False,
) -> Tuple[bool, str]:
    """
    Creates an empty GeoPackage.

    An existing file is left alone unless overwriting it is asked for explicitly:
    the GPKG driver recreates it from scratch, taking every layer it holds with it,
    and reports the same success as a genuine creation, so a path given by mistake
    destroyed the GeoPackage it named without a word.

    :param path: the path of the GeoPackage to create.
    :param overwrite: whether an existing file at that path is to be replaced.
    :return: whether it was created, and why it was not when it was not. The reason
        is returned rather than printed, since the caller is the one that can put it
        in front of whoever asked for the GeoPackage.
    """

    try:

        if os.path.exists(path) and not overwrite:
            return False, f"'{path}' already exists"

        driver = ogr.GetDriverByName("GPKG")
        dataset = driver.CreateDataSource(path)
        if dataset is None:
            return False, f"the GPKG driver could not create a dataset at '{path}'"

        return True, ""

    except Exception as e:

        return False, f"{e!r}"


def vector_layer_names(
    geopackage_path: str,
) -> Tuple[Union[None, List[str]], str]:
    """
    Lists the vector layers a GeoPackage holds.

    :param geopackage_path: the path of the GeoPackage to read.
    :return: the layer names, or None with the reason when they cannot be read.
    """

    try:

        dataset = ogr.Open(geopackage_path, 0)  # 0 = read only
        if dataset is None:
            return None, f"'{geopackage_path}' could not be opened as a GeoPackage"

        return [
            dataset.GetLayerByIndex(ndx).GetName()
            for ndx in range(dataset.GetLayerCount())
        ], ""

    except Exception as e:

        return None, f"{e!r}"


def check_3d_line_layer(
    geopackage_path: str,
    layer_name: str,
) -> Tuple[bool, str]:
    """
    Checks that a GeoPackage layer can take the 3D lines insert_3d_line writes,
    i.e. that it is there and holds 3D linestrings.

    Meant to be called before a long computation whose result is to be stored, so
    that an unusable target is reported while there is still nothing to lose.

    :param geopackage_path: the path of the GeoPackage holding the layer.
    :param layer_name: the name of the layer to check.
    :return: whether the layer can take 3D lines, and why it cannot when it cannot.
    """

    try:

        dataset = ogr.Open(geopackage_path, 0)  # 0 = read only
        if dataset is None:
            return False, f"'{geopackage_path}' could not be opened as a GeoPackage"

        layer = dataset.GetLayerByName(layer_name)
        if layer is None:
            available = [
                dataset.GetLayerByIndex(ndx).GetName()
                for ndx in range(dataset.GetLayerCount())
            ]
            listed = ", ".join(f"'{name}'" for name in available) if available else "none"
            return False, f"it holds no layer named '{layer_name}' (it holds: {listed})"

        geom_type = layer.GetLayerDefn().GetGeomType()
        if geom_type != ogr.wkbLineString25D:
            return False, (
                f"the layer '{layer_name}' holds "
                f"{ogr.GeometryTypeToName(geom_type)} geometries, not 3D lines"
            )

        return True, ""

    except Exception as e:

        return False, f"{e!r}"


def add_vector_layer(
    geopackage_path: str,
    layer_name: str,
    geometry_type: str,
    fields: Union[None, list] = None,
    epsg_code: int = 4326,
) -> Tuple[bool, str]:
    """
    Adds an empty vector layer to an existing GeoPackage.

    :param geopackage_path: the path of the GeoPackage to add the layer to.
    :param layer_name: the name of the layer to create.
    :param geometry_type: the layer geometry, one of the keys of geometry_map.
    :param fields: the optional fields, as (name, type[, width]) tuples.
    :param epsg_code: the EPSG code of the layer CRS.
    :return: whether the layer was added, and why it was not when it was not. Every
        way this can fail used to collapse into a bare False, leaving the caller to
        report a generic failure and the reason on a stdout no one reads.
    """

    try:

        if geometry_type not in geometry_map:
            return False, f"unsupported geometry '{geometry_type}'"

        srs = osr.SpatialReference()
        srs.ImportFromEPSG(epsg_code)

        dataset = ogr.Open(geopackage_path, 1)  # 1 = write
        if dataset is None:
            return False, f"'{geopackage_path}' could not be opened for writing"

        if dataset.GetLayerByName(layer_name):
            return False, f"a layer named '{layer_name}' already exists"

        layer = dataset.CreateLayer(
            layer_name,
            srs,
            geometry_map[geometry_type],
        )

        if layer is None:
            return False, f"the layer '{layer_name}' could not be created"

        if fields is not None:

            for field_name, field_type, *field_width in fields:

                ogr_field_type = get_ogr_type(field_type)
                if ogr_field_type is None:
                    logger.warning(
                        "Unsupported field type '%s' for field '%s'", field_type, field_name
                    )
                    continue

                field_defn = ogr.FieldDefn(field_name, ogr_field_type)
                if ogr_field_type == ogr.OFTString and len(field_width) > 0:
                    field_defn.SetWidth(int(field_width[0]))

                layer.CreateField(field_defn)

        return True, ""

    except Exception as e:

        return False, f"{e!r}"


def insert_3d_line(
    file_path: str,
    layer_name: str,
    line_coordinates: List[Tuple[numbers.Real, numbers.Real, numbers.Real]]
) -> bool:
    """
    Inserisce una linea 3D in un layer esistente all'interno di un GeoPackage.

    :param file_path: Percorso al file GeoPackage.
    :param layer_name: Nome del layer in cui inserire la linea.
    :param line_coordinates: Lista di tuple (x, y, z) che definiscono la linea.
    """

    try:

        # Apri il GeoPackage in modalità di scrittura

        dataset = ogr.Open(file_path, 1)  # 1 significa modalità di scrittura
        if dataset is None:
            raise Exception(f"Unable to open {file_path}")

        # Ottieni il layer

        layer = dataset.GetLayerByName(layer_name)
        if layer is None:
            raise Exception(f"Unable to find layer {layer_name} in file {file_path}")

        # Controlla se il layer supporta la geometria 3D

        layer_defn = layer.GetLayerDefn()
        geom_type = layer_defn.GetGeomType()
        if geom_type != ogr.wkbLineString25D:
            raise Exception(f"Layer {layer_name} does not support 3D geometries (found type: {geom_type})")

        # Crea una nuova feature con una linea 3D
        import math

        line = ogr.Geometry(ogr.wkbLineString25D)
        points_added = 0
        points_without_z = 0

        for x, y, z in line_coordinates:
            # Validate and convert coordinates to float
            try:
                x_f = float(x)
                y_f = float(y)
                z_f = float(z) if z is not None else None
            except (TypeError, ValueError) as e:
                print(
                    f"Invalid coordinate values: x={x}, y={y}, z={z}, error: {e!r}",
                )
                continue

            # Validate that coordinates are finite
            if not (math.isfinite(x_f) and math.isfinite(y_f)):
                print(
                    f"Non-finite coordinates: x={x_f}, y={y_f}",
                )
                continue

            # Add point with appropriate number of coordinates.
            # A wkbLineString25D vertex always carries a z, so adding it
            # without one (line.AddPoint(x, y)) does not mean 'no elevation':
            # it stores a fictitious 0 m elevation, indistinguishable from a
            # real sea-level sample. The missing value is written as NaN
            # instead, the no-data marker that the rest of the code already
            # expects (np.nanmin / np.nanmax on the elevations, matplotlib
            # leaving a gap in the plotted profile).

            if z_f is None or not math.isfinite(z_f):
                z_f = float("nan")
                points_without_z += 1

            line.AddPoint(x_f, y_f, z_f)
            points_added += 1

        # Skip empty geometries
        if points_added == 0:
            raise Exception("No valid points found in line_coordinates")

        if points_without_z > 0:
            print(
                f"Warning: {points_without_z} of {points_added} points of the line written to "
                f"{layer_name} have no elevation (written as NaN): check that the profile lies "
                f"within the DEM extent and over valid (non nodata) cells",
            )

        # Crea una nuova feature e assegna la geometria

        feature = ogr.Feature(layer_defn)
        feature.SetGeometry(line)

        # Aggiungi la feature al layer
        result = layer.CreateFeature(feature)
        if result != ogr.OGRERR_NONE:
            raise Exception(f"Error in adding feature to layer: OGR error code {result}")

        # Libera la memoria
        try:
            feature.Destroy()
        except Exception:
            logger.debug("feature.Destroy() failed", exc_info=True)
        feature = None
        dataset = None

        return True

    except Exception as e:

        print(f"\n{e!r}")
        return False


def read_lines_3d_from_layer(
    gpkg_path: str,
    layer_name: str,
) -> Union[None, List[Tuple[numbers.Real, numbers.Real, numbers.Real]]]:

    try:

        # Carica il geopackage

        driver = ogr.GetDriverByName('GPKG')
        data_source = driver.Open(gpkg_path, 0)  # 0 significa solo lettura

        if data_source is None:
            print("Error while opening the geopackage")
            return None

        # Accedi al layer
        layer = data_source.GetLayerByName(layer_name)

        # Itera attraverso le feature del layer

        features_xyz = []

        for feature in layer:

            # Ottieni la geometria della feature
            geometry = feature.GetGeometryRef()

            # Verifica che la geometria sia una LineString

            if geometry.GetGeometryType() == ogr.wkbLineString:

                # Estrai le coordinate (x, y, z) della LineString

                points = []
                for i in range(geometry.GetPointCount()):
                    x, y, z = geometry.GetPoint(i)  # x, y, z sono le coordinate del punto
                    points.append((x, y, z))  # Salva le coordinate

                features_xyz.append(points)

        # Chiudi il datasource

        data_source = None

        return features_xyz

    except Exception as e:

        print(f"\n{e!r}")
        return None
