# -*- coding: utf-8 -*-
"""
Shared backend logic for network store operations.

This module is GUI-free so it can be reused from both dialogs
and QGIS Processing algorithms.
"""

import os
import re
from urllib.parse import urljoin, urlencode
import xml.etree.ElementTree as ET

import requests

from typing import Literal
from qgis.core import (
    QgsProject,
    QgsVectorLayer,
    QgsMessageLog,
    Qgis,
)

# --- helpers originally lived in network_store_dialog_get.py ---

_SRS_URN = re.compile(r"^urn:ogc:def:crs:EPSG::(\d+)$", re.IGNORECASE)
_DEFAULT_STYLE_SET = "default"


def _normalize_srsname(srs: str | None) -> str | None:
    """urn:ogc:def:crs:EPSG::3857  ->  EPSG:3857"""
    if not srs:
        return None
    m = _SRS_URN.match(srs.strip())
    return f"EPSG:{m.group(1)}" if m else srs.strip()


def _safe_ns_split(full_name: str) -> tuple[str | None, str]:
    # "ns:Layer" -> ("ns", "Layer"), "Layer" -> (None, "Layer")
    if ":" in full_name:
        ns, local = full_name.split(":", 1)
        return ns, local
    return None, full_name


def _infer_geom_from_dft_xml(xml_text: str) -> str | None:
    """
    Returns one of: 'Point','MultiPoint','LineString','MultiLineString',
                    'Polygon','MultiPolygon', or None (no geometry).
    Looks for the 'geometry' element type like gml:LineStringPropertyType.
    """
    try:
        root = ET.fromstring(xml_text)
    except Exception:
        return None
    ns = {
        "xsd": "http://www.w3.org/2001/XMLSchema",
        "gml": "http://www.opengis.net/gml/3.2",
    }
    # find any xsd:element with name='geometry'
    for el in root.findall(".//xsd:element", ns):
        if el.attrib.get("name") == "geometry":
            t = el.attrib.get("type", "")
            # examples: gml:PointPropertyType, gml:MultiPolygonPropertyType
            if "Point" in t and "Multi" not in t:
                return "Point"
            if "MultiPoint" in t:
                return "MultiPoint"
            if "LineString" in t and "Multi" not in t:
                return "LineString"
            if "MultiLineString" in t:
                return "MultiLineString"
            if "Polygon" in t and "Multi" not in t:
                return "Polygon"
            if "MultiPolygon" in t:
                return "MultiPolygon"
    return None


def _feature_types_from_caps(caps_xml: str) -> list[tuple[str, str | None]]:
    """[(typename, default_crs)]"""
    ns = {
        "wfs": "http://www.opengis.net/wfs/2.0",
        "ows": "http://www.opengis.net/ows/1.1",
    }
    root = ET.fromstring(caps_xml)
    out: list[tuple[str, str | None]] = []
    for ft in root.findall(".//wfs:FeatureTypeList/wfs:FeatureType", ns):
        name_el = ft.find("wfs:Name", ns)
        if not (name_el is not None and name_el.text):
            continue
        tname = name_el.text.strip()
        crs_el = ft.find("wfs:DefaultCRS", ns) or ft.find("ows:DefaultCRS", ns)
        crs = _normalize_srsname(crs_el.text if (crs_el is not None) else None)
        out.append((tname, crs))
    return out


def _bucket_for_geom(geom: str | None) -> str:
    if geom is None:
        return "Controls"
    geom = geom.lower()
    if "point" in geom:
        return "Nodes"
    if "line" in geom:
        return "Links"
    if "polygon" in geom:
        return "Polygons"
    return "Controls"


def styles_root() -> str:
    return os.path.join(os.path.dirname(__file__), "styles")


def list_style_sets() -> list[str]:
    root = styles_root()
    if not os.path.isdir(root):
        return [_DEFAULT_STYLE_SET]
    names = [
        name for name in os.listdir(root) if os.path.isdir(os.path.join(root, name))
    ]
    names = sorted({n for n in names if n})
    if _DEFAULT_STYLE_SET not in names:
        names.insert(0, _DEFAULT_STYLE_SET)
    return names


def style_set_dir(style_set: str) -> str:
    return os.path.join(styles_root(), style_set)


def _style_search_dirs(
    style_set: str | None, *, fallback_default: bool = True
) -> list[tuple[str, str]]:
    root = styles_root()
    style_set = (style_set or _DEFAULT_STYLE_SET).strip() or _DEFAULT_STYLE_SET
    dirs: list[tuple[str, str]] = []

    cand = os.path.join(root, style_set)
    if os.path.isdir(cand):
        dirs.append((cand, style_set))
    elif style_set == _DEFAULT_STYLE_SET:
        # Backwards-compat: if default folder is missing, fall back to root.
        dirs.append((root, _DEFAULT_STYLE_SET))

    if fallback_default and style_set != _DEFAULT_STYLE_SET:
        default_dir = os.path.join(root, _DEFAULT_STYLE_SET)
        if os.path.isdir(default_dir):
            dirs.append((default_dir, _DEFAULT_STYLE_SET))
        elif not dirs:
            dirs.append((root, _DEFAULT_STYLE_SET))

    return dirs


def _apply_qml_style(
    layer: QgsVectorLayer,
    display_name: str,
    *,
    style_set: str | None = None,
    fallback_default: bool = True,
) -> bool:
    """
    Try styles/<style_set>/<display_name>.qml first (namespace-stripped),
    then styles/<style_set>/<full_typename>.qml and a colon-safe variant.
    Optionally falls back to the default style set.
    """
    tried: list[str] = []
    tfull = layer.customProperty("network:typename_full", "")
    tfull_safe = tfull.replace(":", "_")

    def _normalize_load_result(result) -> tuple[bool, str]:
        if isinstance(result, tuple):
            if len(result) >= 2:
                first, second = result[0], result[1]
                if isinstance(first, bool) and isinstance(second, str):
                    return first, second
                if isinstance(first, str) and isinstance(second, bool):
                    return second, first
                if isinstance(first, bool):
                    return first, str(second)
                if isinstance(second, bool):
                    return second, str(first)
                return bool(first), str(second)
            if len(result) == 1:
                return bool(result[0]), ""
            return False, ""
        return bool(result), ""

    def _try_load(path: str, styles_name: str) -> bool:
        ok, msg = _normalize_load_result(layer.loadNamedStyle(path))
        if ok:
            layer.triggerRepaint()
            layer.setCustomProperty("network:style_applied", os.path.basename(path))
            layer.setCustomProperty("network:style_set", styles_name)
            return True
        if msg:
            QgsMessageLog.logMessage(
                f"Style load failed for '{display_name}' from '{path}': {msg}",
                "NetworkStore",
                Qgis.Warning,
            )
        else:
            QgsMessageLog.logMessage(
                f"Style load failed for '{display_name}' from '{path}'.",
                "NetworkStore",
                Qgis.Warning,
            )
        return False

    for styles_dir, styles_name in _style_search_dirs(
        style_set, fallback_default=fallback_default
    ):
        # 1) match by display name (what you see in the layer tree)
        cand1 = os.path.join(styles_dir, f"{display_name}.qml")
        tried.append(cand1)
        if os.path.exists(cand1):
            if _try_load(cand1, styles_name):
                return True

        # 2) fallback: match by full typename (ns:Local)
        for fp in (
            os.path.join(styles_dir, f"{tfull}.qml"),
            os.path.join(styles_dir, f"{tfull_safe}.qml"),
        ):
            tried.append(fp)
            if os.path.exists(fp):
                if _try_load(fp, styles_name):
                    return True

    QgsMessageLog.logMessage(
        f"No matching style for '{display_name}'. Tried: {', '.join(tried)}",
        "NetworkStore",
        Qgis.Info,
    )
    return False


def _make_wfs_layer_http(
    wfs_root: str, typename: str, display_name: str, srsname: str | None
) -> QgsVectorLayer | None:
    """
    Build a WFS layer using the legacy/HTTP datasource form (works on builds
    where provider-URI parsing is flaky). We try both typename and typenames.
    """

    def _try(params: dict) -> QgsVectorLayer | None:
        # keep namespace colon in typename* unescaped
        qs = urlencode(params, safe=":")
        uri = f"{wfs_root}?{qs}"
        vl = QgsVectorLayer(uri, display_name, "WFS")
        if vl.isValid():
            return vl
        QgsMessageLog.logMessage(
            f"[WFS invalid] {typename} URI={uri}", "NetworkStore", Qgis.Warning
        )
        return None

    common = {"service": "WFS", "request": "GetFeature", "version": "2.0.0"}
    if srsname:
        common["srsname"] = srsname

    lyr = _try({**common, "typename": typename})
    if lyr:
        return lyr

    return _try({**common, "typenames": typename})


# -----------------------------------------------------------------------------
# PUBLIC BACKEND API
# -----------------------------------------------------------------------------


def load_network_from_store(
    base_url: str,
    network_id: str,
    *,
    style_set: str | None = None,
    feedback=None,
) -> tuple[
    list[QgsVectorLayer],
    list[QgsVectorLayer],
    list[QgsVectorLayer],
    list[QgsVectorLayer],
    bool,
]:
    """
    Load all WFS layers for a network into the current project, grouped under:

        <network_id> / {Nodes, Links, Polygons, Controls}

    Returns (nodes, links, polygons, controls, success).

    This is GUI-free: callers (dialogs / processing) are responsible
    for user-facing messages.
    """
    base_url = (base_url or "").strip().rstrip("/")
    network_id = (network_id or "").strip()

    if not base_url or not network_id:
        msg = "Base URL or network ID missing."
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Warning)
        if feedback:
            feedback.reportError(msg)
        return ([], [], [], [], False)

    wfs_root = urljoin(base_url + "/", f"network-store/networks/{network_id}/wfs")

    # GetCapabilities
    caps_url = f"{wfs_root}?service=WFS&request=GetCapabilities&version=2.0.0"
    try:
        r = requests.get(caps_url, timeout=20)
        r.raise_for_status()
        caps_xml = r.text
    except Exception as e:
        msg = f"WFS GetCapabilities failed: {caps_url}\n{e}"
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Critical)
        if feedback:
            feedback.reportError(msg)
        return ([], [], [], [], False)

    ft_list = _feature_types_from_caps(caps_xml)
    if not ft_list:
        msg = f"No FeatureTypes found for network '{network_id}'."
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Info)
        if feedback:
            feedback.pushInfo(msg)
        return ([], [], [], [], False)

    # Ensure groups
    prj = QgsProject.instance()
    root_group = prj.layerTreeRoot()
    parent_group = root_group.findGroup(network_id) or root_group.addGroup(network_id)
    buckets = {
        b: parent_group.findGroup(b) or parent_group.addGroup(b)
        for b in ("Nodes", "Links", "Polygons", "Controls")
    }

    nodes_l: list[QgsVectorLayer] = []
    links_l: list[QgsVectorLayer] = []
    polys_l: list[QgsVectorLayer] = []
    ctrls_l: list[QgsVectorLayer] = []

    # Iterate feature types
    for tname, default_crs in ft_list:
        if feedback and feedback.isCanceled():
            break

        dft_url = f"{wfs_root}?{urlencode({'service': 'WFS', 'request': 'DescribeFeatureType', 'version': '2.0.0', 'typeName': tname})}"
        try:
            dft_r = requests.get(dft_url, timeout=20)
            dft_r.raise_for_status()
            geom_kind = _infer_geom_from_dft_xml(dft_r.text)
        except Exception as e:
            geom_kind = None
            QgsMessageLog.logMessage(
                f"DescribeFeatureType failed for {tname}: {e}",
                "NetworkStore",
                Qgis.Warning,
            )

        bucket = _bucket_for_geom(geom_kind)

        ns_name, local_name = _safe_ns_split(tname)
        display_name = local_name

        vl = _make_wfs_layer_http(wfs_root, tname, display_name, default_crs)
        if not vl:
            continue

        vl.setCustomProperty("network:base_url", base_url)
        vl.setCustomProperty("network:typename_full", tname)
        vl.setCustomProperty("network:namespace", ns_name or "")
        vl.setCustomProperty("network:layer", local_name)
        vl.setCustomProperty("network:bucket", bucket)
        vl.setCustomProperty("network:id", network_id)

        # Add to project without auto-adding to legend
        prj.addMapLayer(vl, addToLegend=False)

        # Insert directly into the desired bucket group
        buckets[bucket].insertLayer(0, vl)

        # prj.addMapLayer(vl, addToLegend=True)
        # node = root_group.findLayer(vl.id())
        # if node:
        #     buckets[bucket].insertChildNode(0, node.clone())
        #     node.parent().removeChildNode(node)

        applied = _apply_qml_style(vl, display_name, style_set=style_set)
        if not applied and style_set:
            vl.setCustomProperty("network:style_set", style_set)

        if bucket == "Nodes":
            nodes_l.append(vl)
        elif bucket == "Links":
            links_l.append(vl)
        elif bucket == "Polygons":
            polys_l.append(vl)
        else:
            ctrls_l.append(vl)

    success = any([nodes_l, links_l, polys_l, ctrls_l])
    return (nodes_l, links_l, polys_l, ctrls_l, success)


def list_loaded_network_ids() -> list[str]:
    ids: set[str] = set()
    for layer in QgsProject.instance().mapLayers().values():
        if not isinstance(layer, QgsVectorLayer):
            continue
        nid = str(layer.customProperty("network:id", "") or "").strip()
        if nid:
            ids.add(nid)
    return sorted(ids)


def apply_style_set_to_network(
    network_id: str,
    style_set: str,
    *,
    fallback_default: bool = True,
    feedback=None,
) -> int:
    """
    Apply a style set to all loaded layers for a given network_id.
    Returns the count of layers successfully styled.
    """
    network_id = (network_id or "").strip()
    if not network_id:
        return 0
    styled = 0
    for layer in QgsProject.instance().mapLayers().values():
        if not isinstance(layer, QgsVectorLayer):
            continue
        if str(layer.customProperty("network:id", "") or "").strip() != network_id:
            continue
        display_name = (
            str(layer.customProperty("network:layer", "") or "").strip() or layer.name()
        )
        ok = _apply_qml_style(
            layer,
            display_name,
            style_set=style_set,
            fallback_default=fallback_default,
        )
        if not ok:
            layer.setCustomProperty("network:style_set", style_set)
        else:
            styled += 1
    if feedback:
        feedback.pushInfo(
            f"Applied style set '{style_set}' to {styled} layer(s) for '{network_id}'."
        )
    return styled


# --- stubs for other operations you’ll later implement -----------------------


def create_network_in_store(
    base_url: str,
    network_id: str,
    load_network: bool = False,
    *,
    style_set: str | None = None,
    feedback=None,
) -> bool:
    """
    Create a new (empty) network in the store and load it into the project.

    Returns
    -------
    bool
        True if the network was created and loaded successfully,
        False otherwise.
    """
    base_url = (base_url or "").strip().rstrip("/")
    network_id = (network_id or "").strip()

    if not base_url or not network_id:
        msg = "Base URL or network ID missing."
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Warning)
        if feedback:
            feedback.reportError(msg)
        return False

    # ------------------------------------------------------------------ #
    # 1) Check if the network already exists
    # ------------------------------------------------------------------ #
    networks_url = urljoin(base_url + "/", "network-store/networks")

    try:
        resp = requests.get(networks_url, timeout=20)
        resp.raise_for_status()
        data = resp.json()
    except Exception as e:
        msg = f"Failed to query existing networks from {networks_url}: {e}"
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Critical)
        if feedback:
            feedback.reportError(msg)
        return False

    existing_ids: set[str] = set()

    if isinstance(data, list):
        for item in data:
            existing_ids.add(item)

    if network_id in existing_ids:
        msg = f"Network '{network_id}' already exists in the store."
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Warning)
        if feedback:
            feedback.reportError(msg)
        return False

    # ------------------------------------------------------------------ #
    # 2) POST blank network template
    # ------------------------------------------------------------------ #
    blank_network = {
        "version": 2,
        "nodes": [],
        "links": [],
        "polygons": [],
        "controls": [],
        "location_extent": {},
        "location_projection": {
            "geographic": {
                "CRS": "EPSG:4326",
            }
        },
    }

    post_url = urljoin(base_url + "/", f"network-store/networks/{network_id}")
    params = {
        "network_id": network_id,
        "dry_run": "false",
        "old_format": "false",
    }

    try:
        post_resp = requests.post(
            post_url,
            params=params,
            json=blank_network,
            timeout=20,
        )
        post_resp.raise_for_status()
    except Exception as e:
        msg = f"Failed to create network '{network_id}' at {post_url}: {e}"
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Critical)
        if feedback:
            feedback.reportError(msg)
        return False

    # ------------------------------------------------------------------ #
    # 3) Load the newly created network into the project
    # ------------------------------------------------------------------ #
    if load_network:
        _, _, _, _, ok = load_network_from_store(
            base_url,
            network_id,
            style_set=style_set,
            feedback=feedback,
        )

        if not ok:
            msg = (
                f"Network '{network_id}' was created in the store, "
                "but could not be loaded into QGIS."
            )
            QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Warning)
            if feedback:
                feedback.reportError(msg)
            return False

    return True


def export_network(
    base_url: str,
    network_id: str,
    format: Literal["json", "json_old_format", "geojson"],
    target_filename: str,
    *,
    feedback=None,
) -> bool:
    """
    Export a network configuration to a local file.

    Parameters
    ----------
    base_url : str
        Base URL of the network-store service (e.g. http://localhost:8888).
    network_id : str
        ID / UID of the network to export.
    format : {"json","json_old_format","geojson"}
        Desired export format; passed as format_type query parameter.
    target_filename : str
        Path of the file to write. Parent directories will be created if needed.
    feedback : QgsProcessingFeedback, optional
        Optional processing feedback for reporting errors.

    Returns
    -------
    bool
        True if download and file write succeeded, False otherwise.
    """
    base_url = (base_url or "").strip().rstrip("/")
    network_id = (network_id or "").strip()
    target_filename = os.path.expanduser(
        os.path.expandvars(target_filename or "")
    ).strip()

    if not base_url or not network_id or not target_filename:
        msg = "Base URL, network ID, or target filename missing."
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Warning)
        if feedback:
            feedback.reportError(msg)
        return False

    if format not in ("json", "json_old_format", "geojson"):
        msg = f"Unsupported export format '{format}'."
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Warning)
        if feedback:
            feedback.reportError(msg)
        return False

    # ------------------------------------------------------------------ #
    # 1) GET network from API
    # ------------------------------------------------------------------ #
    get_url = urljoin(base_url + "/", f"network-store/networks/{network_id}/network")
    params = {
        "network_id": network_id,
        "dereference": "true",
        "format_type": format,
    }

    try:
        resp = requests.get(get_url, params=params, timeout=60)
        resp.raise_for_status()
    except Exception as e:
        msg = f"Failed to export network '{network_id}' from {get_url}: {e}"
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Critical)
        if feedback:
            feedback.reportError(msg)
        return False

    # ------------------------------------------------------------------ #
    # 2) Write response body to file
    # ------------------------------------------------------------------ #
    try:
        # Ensure parent directory exists
        parent = os.path.dirname(target_filename)
        if parent and not os.path.isdir(parent):
            os.makedirs(parent, exist_ok=True)

        # For json / geojson the response is textual; write as UTF-8 text
        with open(target_filename, "w", encoding="utf-8") as f:
            f.write(resp.text)
    except Exception as e:
        msg = f"Failed to write exported network to '{target_filename}': {e}"
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Critical)
        if feedback:
            feedback.reportError(msg)
        return False

    msg = f"Exported network '{network_id}' as {format} to '{target_filename}'."
    QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Info)
    if feedback:
        feedback.pushInfo(msg)

    return True


def delete_network_in_store(
    base_url: str,
    network_id: str,
    *,
    delete_elements: bool = True,
    unload_network: bool = True,
    feedback=None,
) -> bool:
    """
    Delete a network from the store.

    Parameters
    ----------
    base_url : str
        Base URL of the network-store service (e.g. http://localhost:8888).
    network_id : str
        ID / UID of the network to delete.
    delete_elements : bool, optional
        If True, referenced elements are deleted first (default True).
    unload_network : bool, optional
        If True, unloads all the layers from the QGIS project. This prevents WFS errors after delete.
    feedback : QgsProcessingFeedback, optional
        Optional processing feedback for reporting errors.

    Returns
    -------
    bool
        True if the network was deleted successfully, False otherwise.
    """
    base_url = (base_url or "").strip().rstrip("/")
    network_id = (network_id or "").strip()

    if not base_url or not network_id:
        msg = "Base URL or network ID missing."
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Warning)
        if feedback:
            feedback.reportError(msg)
        return False

    delete_url = urljoin(base_url + "/", f"network-store/networks/{network_id}")
    params = {
        "network_id": network_id,
        "delete_elements": "true" if delete_elements else "false",
    }

    try:
        resp = requests.delete(delete_url, params=params, timeout=20)
        resp.raise_for_status()
    except Exception as e:
        msg = f"Failed to delete network '{network_id}' at {delete_url}: {e}"
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Critical)
        if feedback:
            feedback.reportError(msg)
        return False

    # ------------------------------------------------------------------ #
    # Remove any layers in the project that belong to this network
    # (based on the custom 'network:id' property) and clean up the group.
    # ------------------------------------------------------------------ #
    if unload_network:
        prj = QgsProject.instance()

        # Remove layers tagged with this network id
        to_remove = [
            lyr.id()
            for lyr in prj.mapLayers().values()
            if str(lyr.customProperty("network:id", "")) == network_id
        ]
        for lid in to_remove:
            prj.removeMapLayer(lid)

        # Remove the top-level group (if present)
        root = prj.layerTreeRoot()
        grp = root.findGroup(network_id)
        if grp is not None:
            parent = grp.parent() or root
            parent.removeChildNode(grp)

        msg = f"Network '{network_id}' deleted from store (delete_elements={delete_elements})."
        QgsMessageLog.logMessage(msg, "NetworkStore", Qgis.Info)
        if feedback:
            feedback.pushInfo(msg)

    return True
