import os
import sys
import warnings

from pyogrio._err cimport check_pointer
from pyogrio._err import CPLE_BaseError, NullPointerError
from pyogrio.errors import DataSourceError

MULTI_EXTENSIONS = (".gpkg.zip", ".shp.zip")


cdef get_string(const char *c_str, str encoding="UTF-8"):
    """Get Python string from a char *.

    IMPORTANT: the char * must still be freed by the caller.

    Parameters
    ----------
    c_str : char *
        C string to convert
    encoding : str, optional (default: UTF-8)
        Encoding to use for decoding bytes to string.

    Returns
    -------
    Python string

    """
    cdef bytes py_str

    py_str = c_str
    return py_str.decode(encoding)


def get_gdal_version():
    """Get GDAL version as tuple.

    Returns
    -------
    tuple
        A tuple of (major, minor, revision) version numbers.

    """
    version = int(GDALVersionInfo("VERSION_NUM"))
    major = version // 1000000
    minor = (version - (major * 1000000)) // 10000
    revision = (version - (major * 1000000) - (minor * 10000)) // 100
    return (major, minor, revision)


def get_gdal_version_string():
    """Get GDAL version string."""
    cdef const char* version = GDALVersionInfo("RELEASE_NAME")
    return get_string(version)


cdef extern from "ogr_api.h":
    bint OGRGetGEOSVersion(int *pnMajor, int *pnMinor, int *pnPatch)


def get_gdal_geos_version():
    """Get the version of the GEOS library used by GDAL.

    Returns
    -------
    tuple or None
        A tuple of (major, minor, revision) version numbers if GEOS is available,
        or None if GEOS is not available.

    """
    cdef int major, minor, revision

    if not OGRGetGEOSVersion(&major, &minor, &revision):
        return None
    return (major, minor, revision)


def set_gdal_config_options(dict options):
    """Set multiple GDAL configuration options.

    Parameters
    ----------
    options : dict
        Dictionary of configuration options to set.

    """
    for name, value in options.items():
        name_b = name.encode("utf-8")
        name_c = name_b

        # None is a special case; this is used to clear the previous value
        if value is None:
            CPLSetConfigOption(<const char*>name_c, NULL)
            continue

        # normalize bool to ON/OFF
        if isinstance(value, bool):
            value_b = b"ON" if value else b"OFF"
        else:
            value_b = str(value).encode("utf-8")

        value_c = value_b
        CPLSetConfigOption(<const char*>name_c, <const char*>value_c)


def get_gdal_config_option(str name):
    """Get the value of a GDAL configuration option.

    Parameters
    ----------
    name : str
        Name of the configuration option.

    Returns
    -------
    The value of the configuration option, which can be an int, bool, str, or None if
    the option is not set.

    """
    name_b = name.encode("utf-8")
    name_c = name_b
    value = CPLGetConfigOption(<const char*>name_c, NULL)

    if not value:
        return None

    if value.isdigit():
        return int(value)

    if value == b"ON":
        return True
    if value == b"OFF":
        return False

    str_value = get_string(value)

    return str_value


def ogr_driver_supports_open(driver):
    """Check if driver supports opening and reading an existing file.

    Is determined based on the GDAL driver metadata.

    Parameters
    ----------
    driver : str
        Driver to check

    Returns
    -------
    bool
        True if driver supports opening and reading an existing file, False otherwise.

    """
    if _get_driver_metadata_item(driver, "DCAP_OPEN") == "YES":
        return True

    return False


def ogr_driver_supports_write(driver):
    """Check if driver supports creation/writing of new files.

    Is determined based on the GDAL driver metadata.

    Parameters
    ----------
    driver : str
        Driver to check

    Returns
    -------
    bool
        True if driver supports creation of new files, False otherwise.

    """
    if _get_driver_metadata_item(driver, "DCAP_CREATE") == "YES":
        return True

    return False


def ogr_driver_supports_update(driver):
    """Check metadata for driver to see if it supports update.

    Note that update here means that both support for general updates to
    features as well as appending new features is supported.

    Parameters
    ----------
    driver : str
        Driver to check

    Returns
    -------
    bool
        True if driver supports update, False otherwise.
    """
    IF CTE_GDAL_VERSION >= (3, 11, 0):
        if _get_driver_metadata_item(driver, "DCAP_UPDATE") == "YES":
            return True
        else:
            return False

    return None


def ogr_driver_supports_append(driver):
    """Check metadata for driver to see if it supports append.

    Note that this only returns True for drivers that do not support
    general updates, but do support appending.

    Parameters
    ----------
    driver : str
        Driver to check

    Returns
    -------
    bool
        True if driver supports append, False otherwise.
    """
    IF CTE_GDAL_VERSION >= (3, 12, 0):
        if _get_driver_metadata_item(driver, "DCAP_APPEND") == "YES":
            return True
        else:
            return False

    return None


def ogr_driver_supports_vsi(driver):
    """Check if driver supports virtual system interface (VSI).

    Is determined based on the GDAL driver metadata.

    Parameters
    ----------
    driver : str
        Driver to check

    Returns
    -------
    bool
        True if driver supports  (VSI), False otherwise.

    """
    if _get_driver_metadata_item(driver, "DCAP_VIRTUALIO") == "YES":
        return True

    return False


def ogr_list_drivers():
    """List all available OGR drivers with their supported access modes.

    Returns
    -------
    dict
        Dictionary with the driver name as key and the supported modes as value
        ("r", "rw" or "raw")

    """
    cdef OGRSFDriverH driver = NULL
    cdef int i
    cdef char *name_c

    drivers = dict()
    for i in range(OGRGetDriverCount()):
        driver = OGRGetDriver(i)
        name_c = <char *>OGR_Dr_GetName(driver)
        name = get_string(name_c)

        supports_append = ogr_driver_supports_update(name)
        if not supports_append and CTE_GDAL_VERSION >= (3, 12, 0):
            supports_append = ogr_driver_supports_append(name)

        access_modes = ""
        if ogr_driver_supports_open(name):
            access_modes += "r"
        if supports_append:
            access_modes += "a"
        if ogr_driver_supports_write(name):
            access_modes += "w"

        drivers[name] = access_modes

    return drivers


def ogr_list_drivers_details():
    """List all available OGR drivers with detailed information.

    Returns
    -------
    dict
        Dictionary with the driver name as key and a dict with detailed driver
        properties.

    """
    cdef OGRSFDriverH driver = NULL
    cdef int i
    cdef char *name_c

    GDAL_URL = "https://gdal.org/en/stable"

    drivers = dict()
    for i in range(OGRGetDriverCount()):
        driver = OGRGetDriver(i)

        name_c = <char *>OGR_Dr_GetName(driver)
        name = get_string(name_c)

        extensions = _get_driver_metadata_item(name, "DMD_EXTENSIONS")
        if extensions is not None:
            extensions = [f".{ext}" for ext in extensions.split(" ")]

        relative_help_url = _get_driver_metadata_item(name, "DMD_HELPTOPIC")
        help_topic_url = (
            f"{GDAL_URL}/{relative_help_url}" if relative_help_url is not None else None
        )

        supports_append = ogr_driver_supports_update(name)
        if not supports_append and CTE_GDAL_VERSION >= (3, 12, 0):
            supports_append = ogr_driver_supports_append(name)

        drivers[name] = {
            "long_name": _get_driver_metadata_item(name, "DMD_LONGNAME"),
            "read": ogr_driver_supports_open(name),
            "append": supports_append,
            "write": ogr_driver_supports_write(name),
            "supports_vsi": ogr_driver_supports_vsi(name),
            "help_topic_url": help_topic_url,
            "extensions": extensions,
        }

    return drivers


cdef void set_proj_search_path(str path):
    """Set PROJ library data file search path for use in GDAL."""
    cdef char **paths = NULL
    cdef const char *path_c = NULL
    path_b = path.encode("utf-8")
    path_c = path_b
    paths = CSLAddString(paths, path_c)
    OSRSetPROJSearchPaths(<const char *const *>paths)


def has_gdal_data():
    """Verify that GDAL library data files are correctly found.

    Adapted from Fiona (_env.pyx).

    Returns
    -------
    bool
        True if GDAL data files are correctly found, False otherwise.

    """
    if CPLFindFile("gdal", "header.dxf") != NULL:
        return True

    return False


def get_gdal_data_path():
    """Get the path to the directory GDAL uses to read data files."""
    cdef const char *path_c = CPLFindFile("gdal", "header.dxf")
    if path_c != NULL:
        return get_string(path_c).replace("header.dxf", "")
    return None


def has_proj_data():
    """Verify that PROJ library data files are correctly found.

    Returns
    -------
    bool
        True if a test spatial reference object could be created, which verifies
        that data files are correctly loaded.

    Adapted from Fiona (_env.pyx).

    """
    cdef OGRSpatialReferenceH srs = OSRNewSpatialReference(NULL)

    retval = OSRImportFromEPSG(srs, 4326)
    if srs != NULL:
        OSRRelease(srs)

    if retval == OGRERR_NONE:
        # Succesfull return, so PROJ data files are correctly found
        return True
    else:
        return False


def init_gdal_data():
    """Set GDAL data search directories.

    They are set in the following precedence:
    - wheel copy of gdal_data
    - default detection by GDAL, including GDAL_DATA (detected automatically by GDAL)
    - other well-known paths under sys.prefix

    Adapted from Fiona (env.py, _env.pyx).

    """
    # wheels are packaged to include GDAL data files at pyogrio/gdal_data
    wheel_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "gdal_data"))
    if os.path.exists(wheel_path):
        set_gdal_config_options({"GDAL_DATA": wheel_path})
        if not has_gdal_data():
            raise ValueError(
                "Could not correctly detect GDAL data files installed by pyogrio wheel"
            )
        return

    # GDAL correctly found data files from GDAL_DATA or compiled-in paths
    if has_gdal_data():
        return

    wk_path = os.path.join(sys.prefix, "share", "gdal")
    if os.path.exists(wk_path):
        set_gdal_config_options({"GDAL_DATA": wk_path})
        if not has_gdal_data():
            raise ValueError(
                f"Found GDAL data directory at {wk_path} but it does not appear to "
                "correctly contain GDAL data files"
            )
        return

    warnings.warn(
        "Could not detect GDAL data files. Set GDAL_DATA environment variable to the "
        "correct path.",
        RuntimeWarning
    )


def init_proj_data():
    """Set Proj search directories in the following precedence:
    - wheel copy of proj_data
    - default detection by PROJ, including PROJ_LIB (detected automatically by PROJ)
    - search other well-known paths under sys.prefix

    Adapted from Fiona (env.py, _env.pyx).
    """

    # wheels are packaged to include PROJ data files at pyogrio/proj_data
    wheel_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "proj_data"))
    if os.path.exists(wheel_path):
        set_proj_search_path(wheel_path)
        # verify that this now resolves
        if not has_proj_data():
            raise ValueError(
                "Could not correctly detect PROJ data files installed by pyogrio wheel"
            )
        return

    # PROJ correctly found data files from PROJ_LIB or compiled-in paths
    if has_proj_data():
        return

    wk_path = os.path.join(sys.prefix, "share", "proj")
    if os.path.exists(wk_path):
        set_proj_search_path(wk_path)
        # verify that this now resolves
        if not has_proj_data():
            raise ValueError(
                f"Found PROJ data directory at {wk_path} but it does not appear to "
                "correctly contain PROJ data files"
            )
        return

    warnings.warn(
            "Could not detect PROJ data files. Set PROJ_LIB environment variable to "
            "the correct path.", RuntimeWarning)


def _register_drivers():
    # Register all drivers
    GDALAllRegister()


def _get_driver_metadata_item(driver, metadata_item):
    """
    Query driver metadata items.

    Parameters
    ----------
    driver : str
        Driver to query
    metadata_item : str
        Metadata item to query

    Returns
    -------
    str or None
        Metadata item
    """
    cdef const char* metadata_c = NULL
    cdef void *cogr_driver = NULL

    try:
        cogr_driver = check_pointer(GDALGetDriverByName(driver.encode("UTF-8")))
    except NullPointerError:
        raise DataSourceError(
            f"Could not obtain driver: {driver} (check that it was installed "
            "correctly into GDAL)"
        )
    except CPLE_BaseError as exc:
        raise DataSourceError(str(exc))

    metadata_c = GDALGetMetadataItem(cogr_driver, metadata_item.encode("UTF-8"), NULL)

    metadata = None
    if metadata_c != NULL:
        metadata = metadata_c
        metadata = metadata.decode("UTF-8")
        if len(metadata) == 0:
            metadata = None

    return metadata


def _get_drivers_for_path(path):
    cdef OGRSFDriverH driver = NULL
    cdef int i
    cdef char *name_c

    path = str(path).lower()

    parts = os.path.splitext(path)
    if len(parts) == 2 and len(parts[1]) > 1:
        ext = parts[1][1:]
    else:
        ext = None

    # allow specific drivers to have a .zip extension to match GDAL behavior
    if ext == "zip":
        for multi_ext in MULTI_EXTENSIONS:
            if path.endswith(multi_ext):
                ext = multi_ext[1:]  # strip leading dot
                break

    drivers = []
    for i in range(OGRGetDriverCount()):
        driver = OGRGetDriver(i)
        name_c = <char *>OGR_Dr_GetName(driver)
        name = get_string(name_c)

        if not ogr_driver_supports_write(name):
            continue

        # extensions is a space-delimited list of supported extensions
        # for driver
        extensions = _get_driver_metadata_item(name, "DMD_EXTENSIONS")
        if (
            ext is not None
            and extensions is not None
            and ext in extensions.lower().split(" ")
        ):
            drivers.append(name)
        else:
            prefix = _get_driver_metadata_item(name, "DMD_CONNECTION_PREFIX")
            if prefix is not None and path.startswith(prefix.lower()):
                drivers.append(name)

    return drivers
