# -*- coding: utf-8 -*-
from __future__ import annotations

import base64
import json
import os
import re
from dataclasses import dataclass
from datetime import timezone
from datetime import datetime
from typing import Literal, Optional, Protocol
from urllib.parse import quote

import requests
from qgis.PyQt.QtCore import QDateTime, QVariant, Qt
from qgis.core import (
    QgsMessageLog,
    QgsProcessingException,
    QgsProject,
    QgsRasterLayer,
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform,
    QgsFeature,
    QgsFields,
    QgsField,
    QgsGeometry,
    QgsPointXY,
    QgsVectorLayer,
    Qgis,
    QgsDateTimeRange,
    QgsRasterLayerTemporalProperties,
)


# -------------------------
# Typed interfaces / models
# -------------------------


class Feedback(Protocol):
    """Minimal feedback interface compatible with QgsProcessingFeedback."""

    def pushInfo(self, msg: str) -> None: ...


IdentType = Literal["ts_id", "path"]
RasterFormat = Literal["geotiff"]


@dataclass(frozen=True)
class ArrayStorageRasterRequest:
    """
    High-level request object for loading a single raster from ArrayStorage.

    - time_dt: mandatory time dimension value.
    - t0_dt: optional t0 dimension value; if provided, it is sent via the
      'selection' query parameter as a JSON string:
         selection={"t0": "<ISO8601 UTC>"}
    """

    base_url: str
    ident_type: IdentType  # "ts_id" or "path"
    identifier: str  # ts_id or path depending on ident_type
    time_dt: QDateTime  # mandatory
    t0_dt: Optional[QDateTime] = None  # optional t0
    dispatch_info: Optional[str] = None  # optional dispatch info for forecasts
    member: Optional[str] = None  # optional member for forecasts
    as_user: Optional[str] = None  # HTTP Basic auth
    as_pass: Optional[str] = None
    target_file: str = ""  # may be temp output without extension
    format: RasterFormat = "geotiff"
    store_id: Optional[str] = None
    activate_temporal: bool = True


@dataclass(frozen=True)
class ArrayStorageRasterLoadResult:
    tif_path: str
    ts_id: str
    time_iso: str
    layer: QgsRasterLayer


# -------------------------
# Low-level REST utilities
# -------------------------


def make_session(as_user: Optional[str], as_pass: Optional[str]) -> requests.Session:
    """
    Create a requests.Session with optional basic auth and JSON accept header.
    """
    s = requests.Session()
    if as_user and as_pass:
        s.auth = (as_user, as_pass)
    s.headers.update({"accept": "application/json"})
    return s


def qdt_to_iso_with_tz(qdt: QDateTime) -> str:
    """
    Convert a QDateTime to an ISO8601 string in UTC, rounded down to the minute.

    Assumptions:
    - The time the user enters in the QGIS widget is already in UTC.
    - We do NOT shift by local timezone; we only strip seconds/microseconds.
    - Output format: YYYY-MM-DDTHH:MM:00+00:00 (24h clock).
    """
    if not qdt.isValid():
        raise QgsProcessingException("Invalid datetime value")

    # QGIS gives us a QDateTime with some timeSpec (often LocalTime).
    # We treat whatever is entered as already in UTC, i.e. no hour shift.
    py = qdt.toPyDateTime()

    # Drop seconds and microseconds (round down to the minute).
    py = py.replace(second=0, microsecond=0, tzinfo=timezone.utc)

    # Explicit 24h format, explicit +00:00 offset
    return py.strftime("%Y-%m-%dT%H:%M:00+00:00")


def ensure_tif_extension(path: str) -> str:
    """
    Ensure output path has some extension, defaulting to '.tif'.
    """
    root, ext = os.path.splitext(path)
    if not ext:
        return root + ".tif"
    return path


def _parse_iso_duration_seconds(value: str) -> Optional[int]:
    """
    Parse a minimal subset of ISO8601 durations like 'PT3H', 'PT15M', 'PT1H30M', 'PT45S'.
    Returns total seconds or None if parsing fails.
    """
    if not value or not isinstance(value, str):
        return None

    m = re.match(
        r"^P(?:(?P<days>\d+)D)?(?:T(?:(?P<hours>\d+)H)?(?:(?P<mins>\d+)M)?(?:(?P<secs>\d+(?:\.\d+)?)S)?)?$",
        value,
    )
    if not m:
        return None

    try:
        days = int(m.group("days") or 0)
        hours = int(m.group("hours") or 0)
        mins = int(m.group("mins") or 0)
        secs = float(m.group("secs") or 0)
        total = days * 86400 + hours * 3600 + mins * 60 + secs
        return int(total)
    except Exception:
        return None


def _parse_iso_qdatetime(value: str) -> QDateTime:
    """
    Best-effort ISO parser that tolerates offsets and milliseconds.
    """
    if not value:
        return QDateTime()
    try:
        dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
        dt = dt.astimezone(timezone.utc)
        qdt = QDateTime(dt)
        qdt.setTimeSpec(Qt.UTC)
        return qdt
    except Exception:
        pass
    attempts = [value, value.replace("Z", "+00:00"), value.replace("Z", "")]
    for candidate in attempts:
        for fmt in (Qt.ISODateWithMs, Qt.ISODate):
            qdt = QDateTime.fromString(candidate, fmt)
            if qdt.isValid():
                qdt.setTimeSpec(Qt.UTC)
                return qdt
        if "+" in candidate:
            base, _, offset = candidate.partition("+")
            offset = offset.replace(":", "")
            v_try = f"{base}+{offset}"
            for fmt in (Qt.ISODateWithMs, Qt.ISODate):
                qdt = QDateTime.fromString(v_try, fmt)
                if qdt.isValid():
                    qdt.setTimeSpec(Qt.UTC)
                    return qdt
    return QDateTime()


def list_raster_timeseries(
    base_url: str,
    session: requests.Session,
    *,
    store_id: Optional[str] = None,
) -> list[dict]:
    """
    GET <base_url>/rest/arrayStorage/rasterTimeSeries

    Returns the raw JSON list (each item is ArrayStorage metadata for a raster TS).
    """
    base = base_url.rstrip("/")
    url = f"{base}/rest/arrayStorage/rasterTimeSeries"

    params: dict[str, str] = {}
    if store_id:
        params["store_id"] = store_id

    r = session.get(url, params=params, timeout=20)
    if r.status_code >= 400:
        raise QgsProcessingException(
            f"Failed to list ArrayStorage raster time series: {r.status_code} {r.text}"
        )

    data = r.json()
    if not isinstance(data, list):
        raise QgsProcessingException(
            "Unexpected response for ArrayStorage rasterTimeSeries list."
        )
    return data


def list_forecast_t0_options(
    base_url: str,
    ts_path: str,
    session: requests.Session,
    *,
    store_id: Optional[str] = None,
    depth: str = "member",
) -> list[dict]:
    """
    GET <base_url>/rest/arrayStorage/tensorTimeSeries/data/<ts_path>/ensemble_members
    Returns a list of objects that include 't0' values for forecast runs.
    """
    base = base_url.rstrip("/")
    url = (
        f"{base}/rest/arrayStorage/tensorTimeSeries/data/"
        f"{quote(ts_path, safe='')}/ensemble_members"
    )

    params: dict[str, str] = {"depth": depth}
    if store_id:
        params["store_id"] = store_id

    r = session.get(url, params=params, timeout=20)
    if r.status_code >= 400:
        raise QgsProcessingException(
            f"Failed to list t0 options for ArrayStorage path={ts_path}: "
            f"{r.status_code} {r.text}"
        )

    data = r.json()
    if not isinstance(data, list):
        raise QgsProcessingException(
            "Unexpected response for ArrayStorage ensemble_members list."
        )
    return data


def resolve_path_to_ts_id(
    base_url: str,
    path_identifier: str,
    session: requests.Session,
    *,
    store_id: Optional[str] = None,
) -> str:
    """
    Resolve a human readable path to timeseriesId by listing rasterTimeSeries.
    """
    items = list_raster_timeseries(base_url, session, store_id=store_id)
    for it in items:
        if str(it.get("path", "")).strip() == path_identifier.strip():
            tsid = it.get("timeseriesId") or it.get("timeSeriesId") or it.get("id")
            if tsid:
                return str(tsid)

    raise QgsProcessingException(
        f"Path '{path_identifier}' not found in ArrayStorage rasterTimeSeries."
    )


def fetch_raster_metadata(
    base_url: str,
    ts_id: str,
    session: requests.Session,
    *,
    store_id: Optional[str] = None,
) -> dict:
    """
    GET <base_url>/rest/arrayStorage/rasterTimeSeries/{ts_id}
    """
    base = base_url.rstrip("/")
    url = f"{base}/rest/arrayStorage/rasterTimeSeries/{ts_id}"

    params: dict[str, str] = {}
    if store_id:
        params["store_id"] = store_id

    r = session.get(url, params=params, timeout=20)
    if r.status_code >= 400:
        raise QgsProcessingException(
            f"Failed to fetch ArrayStorage raster metadata for ts_id={ts_id}: "
            f"{r.status_code} {r.text}"
        )

    meta = r.json()
    if not isinstance(meta, dict):
        raise QgsProcessingException(
            "Unexpected metadata payload for ArrayStorage rasterTimeSeries."
        )
    return meta


def list_dimension_values(
    base_url: str,
    ts_id: str,
    dim_name: str,
    session: requests.Session,
    *,
    store_id: Optional[str] = None,
    selection: Optional[dict[str, object]] = None,
) -> list:
    """
    GET <base_url>/rest/arrayStorage/rasterTimeSeries/{ts_id}/dimensions/{dim_name}/data
    Returns the raw JSON list for the given dimension (e.g. z, t0).
    """
    base = base_url.rstrip("/")
    url = (
        f"{base}/rest/arrayStorage/rasterTimeSeries/{ts_id}/dimensions/"
        f"{quote(dim_name, safe='')}/data"
    )

    params: dict[str, str] = {}
    if store_id:
        params["store_id"] = store_id
    if selection:
        params["selection"] = json.dumps(selection)

    r = session.get(url, params=params, timeout=30)
    if r.status_code >= 400:
        raise QgsProcessingException(
            f"Failed to list dimension '{dim_name}' values for ts_id={ts_id}: "
            f"{r.status_code} {r.text}"
        )

    data = r.json()
    if not isinstance(data, list):
        raise QgsProcessingException(
            f"Unexpected response for dimension '{dim_name}' values for ts_id={ts_id}."
        )
    return data


def download_raster_geotiff(
    base_url: str,
    ts_id: str,
    raster_time_iso: str,
    target_file: str,
    session: requests.Session,
    *,
    store_id: Optional[str] = None,
    t0_iso: Optional[str] = None,
    dispatch_info: Optional[str] = None,
    member: Optional[str] = None,
    extra_selection: Optional[dict[str, object]] = None,
    feedback: Optional[Feedback] = None,
) -> str:
    """
    Download a single GeoTIFF snapshot from ArrayStorage.

    Uses:
      GET /rest/arrayStorage/rasterTimeSeries/{ts_id}/data/{timestamp_str}

    Query parameters:
      - format=geotiff
      - extractMode=fill_with_nan
      - selection JSON built from t0/dispatch_info/member and optional extra_selection
        (e.g. {"t0": "<ISO8601 UTC>", "z": {"start": 0, "stop": 5}}).
    """
    base = base_url.rstrip("/")
    time_seg = quote(raster_time_iso, safe="T-+")
    url = f"{base}/rest/arrayStorage/rasterTimeSeries/{ts_id}/data/{time_seg}"

    params: dict[str, str] = {
        "format": "geotiff",
        "extractMode": "fill_with_nan",
    }
    if store_id:
        params["store_id"] = store_id

    # If t0/dispatch_info/member/extra_selection are given, pass them via 'selection'
    selection: dict[str, object] = {}
    if t0_iso:
        selection["t0"] = t0_iso
    if dispatch_info:
        selection["dispatch_info"] = dispatch_info
    if member:
        selection["member"] = member
    if extra_selection:
        selection.update(extra_selection)
    if selection:
        params["selection"] = json.dumps(selection)

    if feedback:
        feedback.pushInfo(f"ArrayStorage download URL: {url}")
        feedback.pushInfo(f"ArrayStorage download params: {params}")

    r = session.get(url, params=params, timeout=60)
    if r.status_code >= 400:
        raise QgsProcessingException(
            f"ArrayStorage raster download failed: {r.status_code} {r.text}"
        )

    os.makedirs(os.path.dirname(target_file), exist_ok=True)
    content_type = r.headers.get("Content-Type", "")

    if "application/json" in content_type:
        payload = r.json()
        # spec: ArrayStorageTimeSeriesData -> has 'data': [ ArrayStorageData, ...]
        data_list = payload.get("data") or []
        if not isinstance(data_list, list) or not data_list:
            raise QgsProcessingException(
                "ArrayStorage JSON response did not contain any raster data."
            )
        first = data_list[0]
        # ArrayStorageData typically exposes base64 payload in 'binary' (or 'data')
        b64 = first.get("binary") or first.get("data")
        if not isinstance(b64, str):
            raise QgsProcessingException(
                "ArrayStorage JSON raster element does not contain a base64 payload."
            )
        raw = base64.b64decode(b64)
        with open(target_file, "wb") as f:
            f.write(raw)
    else:
        # Fallback if backend returns raw GeoTIFF
        with open(target_file, "wb") as f:
            f.write(r.content)

    return target_file


def list_timestamps(
    base_url: str,
    ts_id: str,
    session: requests.Session,
    *,
    store_id: Optional[str] = None,
    from_iso: Optional[str] = None,
    until_iso: Optional[str] = None,
    factor: Optional[int] = None,
    selection: Optional[dict[str, str]] = None,
    period: Optional[str] = None,
    by_field: Optional[str] = None,
) -> list[list[object]]:
    """
    GET <base_url>/rest/arrayStorage/rasterTimeSeries/{ts_id}/timeStamps

    Returns the raw list structure (each entry usually [timestamp_str, completeness]).
    """
    base = base_url.rstrip("/")
    url = f"{base}/rest/arrayStorage/rasterTimeSeries/{ts_id}/timeStamps"

    params: dict[str, str] = {}
    if store_id:
        params["store_id"] = store_id
    if from_iso:
        params["from"] = from_iso
    if until_iso:
        params["until"] = until_iso
    if factor is not None:
        params["factor"] = str(factor)
    if selection:
        params["selection"] = json.dumps(selection)
    if period:
        params["period"] = period
    if by_field:
        params["byField"] = by_field

    r = session.get(url, params=params, timeout=20)
    if r.status_code >= 400:
        raise QgsProcessingException(
            f"Failed to list timestamps for ArrayStorage ts_id={ts_id}: "
            f"{r.status_code} {r.text}"
        )

    data = r.json()
    if not isinstance(data, list):
        raise QgsProcessingException(
            "Unexpected response for ArrayStorage rasterTimeSeries timeStamps."
        )
    return data


# -------------------------------------------------------------------------
# Timeseries -> in-memory layer helper
# -------------------------------------------------------------------------


def _crs_from_item(item: dict) -> QgsCoordinateReferenceSystem:
    """
    Return CRS from projectionIdentifier or projectionProj4, default EPSG:4326.
    """
    ident = str(item.get("projectionIdentifier") or "").strip()
    proj4 = str(item.get("projectionProj4") or "").strip()
    if ident:
        crs = QgsCoordinateReferenceSystem(ident)
        if crs.isValid():
            return crs
    if proj4:
        crs = QgsCoordinateReferenceSystem.fromProj(proj4)
        if crs.isValid():
            return crs
    return QgsCoordinateReferenceSystem("EPSG:4326")


def _geom_from_item(item: dict) -> QgsGeometry | None:
    """
    Build geometry from geometryWkt or boundingBox.
    """
    wkt = str(item.get("geometryWkt") or "").strip()
    if wkt:
        geom = QgsGeometry.fromWkt(wkt)
        if geom.isGeosValid():
            return geom

    bbox = item.get("boundingBox") or {}
    try:
        minx = float(bbox.get("minX"))
        maxx = float(bbox.get("maxX"))
        miny = float(bbox.get("minY"))
        maxy = float(bbox.get("maxY"))
    except Exception:
        return None
    ring = [
        QgsPointXY(minx, miny),
        QgsPointXY(minx, maxy),
        QgsPointXY(maxx, maxy),
        QgsPointXY(maxx, miny),
        QgsPointXY(minx, miny),
    ]
    return QgsGeometry.fromPolygonXY([ring])


def build_timeseries_layer(
    items: list[dict], add_geometry: bool = True
) -> QgsVectorLayer:
    """
    Create an in-memory layer (polygon or table) from rasterTimeSeries items.
    """
    crs_4326 = QgsCoordinateReferenceSystem("EPSG:4326")
    layer_spec = "Polygon?crs=EPSG:4326" if add_geometry else "None"
    vl = QgsVectorLayer(layer_spec, "ArrayStorage rasterTimeSeries", "memory")
    prov = vl.dataProvider()

    fields = QgsFields()
    fields.append(QgsField("timeseriesId", QVariant.String))
    fields.append(QgsField("path", QVariant.String))
    fields.append(QgsField("name", QVariant.String))
    fields.append(QgsField("unitSymbol", QVariant.String))
    fields.append(QgsField("parameterKey", QVariant.String))
    fields.append(QgsField("parameterType", QVariant.String))
    fields.append(QgsField("productType", QVariant.String))
    fields.append(QgsField("valueDistance", QVariant.String))
    fields.append(QgsField("coverage_from", QVariant.String))
    fields.append(QgsField("coverage_to", QVariant.String))
    fields.append(QgsField("rasterWidth", QVariant.Int))
    fields.append(QgsField("rasterHeight", QVariant.Int))
    fields.append(QgsField("projectionIdentifier", QVariant.String))
    fields.append(QgsField("projectionProj4", QVariant.String))
    fields.append(QgsField("t0", QVariant.String))
    fields.append(QgsField("raw_json", QVariant.String))

    prov.addAttributes(fields)
    vl.updateFields()

    feats: list[QgsFeature] = []
    for item in items:
        f = QgsFeature()
        f.setFields(fields)

        tsid = (
            item.get("timeseriesId") or item.get("timeSeriesId") or item.get("id") or ""
        )
        path = item.get("path", "")
        name = item.get("name", "")
        unit = item.get("unitSymbol", "")
        pkey = item.get("parameterKey", "")
        ptype = item.get("parameterType", "")
        product = item.get("productType", "")
        val_dist = item.get("valueDistance", "")

        coverage = item.get("coverage") or {}
        cov_from = coverage.get("from") if isinstance(coverage, dict) else None
        cov_to = coverage.get("until") if isinstance(coverage, dict) else None

        rwidth = item.get("rasterWidth") or item.get("width")
        rheight = item.get("rasterHeight") or item.get("height")
        proj_ident = item.get("projectionIdentifier") or ""
        proj_proj4 = item.get("projectionProj4") or ""

        sel = item.get("selection") or {}
        t0_val = ""
        if isinstance(sel, dict):
            t0_raw = sel.get("t0")
            if isinstance(t0_raw, str):
                t0_val = t0_raw

        f["timeseriesId"] = str(tsid)
        f["path"] = str(path)
        f["name"] = str(name)
        f["unitSymbol"] = str(unit)
        f["parameterKey"] = str(pkey)
        f["parameterType"] = str(ptype)
        f["productType"] = str(product)
        f["valueDistance"] = str(val_dist)
        f["coverage_from"] = str(cov_from or "")
        f["coverage_to"] = str(cov_to or "")
        try:
            f["rasterWidth"] = int(rwidth)
        except Exception:
            f["rasterWidth"] = None
        try:
            f["rasterHeight"] = int(rheight)
        except Exception:
            f["rasterHeight"] = None
        f["projectionIdentifier"] = str(proj_ident)
        f["projectionProj4"] = str(proj_proj4)
        f["t0"] = str(t0_val)
        f["raw_json"] = json.dumps(item, ensure_ascii=False)

        if add_geometry:
            geom = _geom_from_item(item)
            if geom and not geom.isEmpty():
                src_crs = _crs_from_item(item)
                if src_crs.isValid() and src_crs != crs_4326:
                    try:
                        ct = QgsCoordinateTransform(
                            src_crs, crs_4326, QgsProject.instance()
                        )
                        geom.transform(ct)
                    except Exception:
                        pass
                f.setGeometry(geom)

        feats.append(f)

    if feats:
        prov.addFeatures(feats)
        vl.updateExtents()

    if add_geometry:
        try:
            style_path = os.path.join(
                os.path.dirname(__file__),
                "styles",
                "default",
                "_arraystorage_raster_geometries.qml",
            )
            if os.path.exists(style_path):
                vl.loadNamedStyle(style_path)
        except Exception:
            pass

    vl.setCustomProperty("arraystorage:kind", "rasterTimeSeries_list")
    return vl


def delete_metadata(
    base_url: str,
    *,
    ts_paths: Optional[list[str]] = None,
    group_paths: Optional[list[str]] = None,
    delete_groups: bool = False,
    delete_parent_metadata: bool = False,
    mode: str = "strict",
    store_id: Optional[str] = None,
    session: Optional[requests.Session] = None,
) -> dict:
    """
    DELETE /rest/arrayStorage/tensorTimeSeries

    Wrapper for the 'delete_meta' operation: you can delete by ts_paths,
    group_paths or a combination of both. The response is returned as a dict.
    """
    base = base_url.rstrip("/")
    url = f"{base}/rest/arrayStorage/tensorTimeSeries"

    if session is None:
        session = make_session(None, None)

    params: dict[str, str] = {}
    if store_id:
        params["store_id"] = store_id
    if mode:
        params["mode"] = mode

    payload: dict[str, object] = {}
    if ts_paths:
        payload["ts_paths"] = ts_paths
    if group_paths:
        payload["group_paths"] = group_paths
    payload["delete_parent_metadata"] = bool(delete_parent_metadata)
    payload["delete_groups"] = bool(delete_groups)

    r = session.delete(url, params=params, json=payload, timeout=60)
    if r.status_code >= 400:
        raise QgsProcessingException(
            f"ArrayStorage delete_metadata failed: {r.status_code} {r.text}"
        )

    try:
        return r.json()
    except Exception:
        return {"status_code": r.status_code, "text": r.text}


def delete_data(
    base_url: str,
    ts_path: str,
    *,
    store_id: Optional[str] = None,
    from_iso: Optional[str] = None,
    until_iso: Optional[str] = None,
    t0_iso: Optional[str] = None,
    member: Optional[str] = None,
    dispatch_info: Optional[str] = None,
    session: Optional[requests.Session] = None,
) -> dict:
    """
    DELETE /rest/arrayStorage/tensorTimeSeries/data/{ts_path}

    Deletes data slices for a tensor/raster time series, optionally restricted
    by from/until/t0/member/dispatch_info.
    """
    base = base_url.rstrip("/")
    path_seg = quote(ts_path, safe="/")
    url = f"{base}/rest/arrayStorage/tensorTimeSeries/data/{path_seg}"

    if session is None:
        session = make_session(None, None)

    params: dict[str, str] = {}
    if store_id:
        params["store_id"] = store_id
    if from_iso:
        params["from"] = from_iso
    if until_iso:
        params["until"] = until_iso
    if t0_iso:
        params["t0"] = t0_iso
    if member:
        params["member"] = member
    if dispatch_info:
        params["dispatch_info"] = dispatch_info

    r = session.delete(url, params=params, timeout=60)
    if r.status_code >= 400:
        raise QgsProcessingException(
            f"ArrayStorage delete_data failed: {r.status_code} {r.text}"
        )

    try:
        return r.json()
    except Exception:
        return {"status_code": r.status_code, "text": r.text}


# -------------------------
# Multiband helpers
# -------------------------


def _has_z_dimension(meta: Optional[dict]) -> bool:
    """
    Return True when the metadata declares a 'z' dimension (multiband rasters).
    """
    dims = meta.get("dimensions") if isinstance(meta, dict) else []
    for dim in dims or []:
        if not isinstance(dim, dict):
            continue
        name = str(dim.get("name") or "").lower()
        if name == "z":
            return True
    return False


def _min_max_int_values(values: list) -> Optional[tuple[int, int]]:
    """
    Compute min/max from a list of int-like values (strings/numbers).
    Raises if non-integer numeric values are found.
    """
    if not values:
        return None

    ints: list[int] = []
    for v in values:
        # Skip booleans even though they are ints in Python
        if isinstance(v, bool):
            continue
        try:
            num = float(v)
        except Exception:
            try:
                num = float(str(v))
            except Exception:
                continue

        rounded = round(num)
        if abs(num - rounded) > 1e-6:
            raise QgsProcessingException("Z/level values must be integers.")
        ints.append(int(rounded))

    if not ints:
        return None

    return min(ints), max(ints)


# -------------------------
# High-level raster loader
# -------------------------


def load_arraystorage_raster(
    req: ArrayStorageRasterRequest,
    *,
    feedback: Optional[Feedback] = None,
    add_to_project: bool = True,
) -> ArrayStorageRasterLoadResult:
    """
    Resolve ts_id (if needed), download a single GeoTIFF snapshot and
    load it into the current QGIS project.

    Also attaches:
      - arraystorage:ts_id
      - arraystorage:path
      - arraystorage:time  (ISO time)
      - arraystorage:t0    (ISO t0, if given)
      - arraystorage:base_url
      - arraystorage:store_id
      - arraystorage:user
      - arraystorage:metadata  (full JSON from /rasterTimeSeries/{ts_id})
    """
    if req.format != "geotiff":
        raise QgsProcessingException("Only 'geotiff' output is supported currently.")

    if not req.identifier.strip():
        raise QgsProcessingException(
            "Timeseries identifier (ts_id or path) is required."
        )

    if not req.time_dt.isValid():
        raise QgsProcessingException("Raster time is required.")

    time_iso = qdt_to_iso_with_tz(req.time_dt)

    t0_iso: Optional[str] = None
    if (
        req.t0_dt is not None
        and isinstance(req.t0_dt, QDateTime)
        and req.t0_dt.isValid()
    ):
        t0_iso = qdt_to_iso_with_tz(req.t0_dt)
    dispatch_info = (req.dispatch_info or "").strip() or None
    member = (req.member or "").strip() or None
    base_selection: dict[str, object] = {}
    if t0_iso:
        base_selection["t0"] = t0_iso
    if dispatch_info:
        base_selection["dispatch_info"] = dispatch_info
    if member:
        base_selection["member"] = member

    target_file = ensure_tif_extension(req.target_file.strip())

    session = make_session(req.as_user, req.as_pass)

    if req.ident_type == "path":
        ts_id = resolve_path_to_ts_id(
            req.base_url,
            req.identifier,
            session,
            store_id=req.store_id,
        )
    else:
        ts_id = req.identifier.strip()

    meta: Optional[dict] = None
    try:
        meta = fetch_raster_metadata(
            req.base_url, ts_id, session, store_id=req.store_id
        )
    except Exception as e:  # noqa: BLE001
        if feedback:
            feedback.pushInfo(f"ArrayStorage metadata fetch failed: {e}")

    base_name = ""
    if req.ident_type == "path":
        base_name = req.identifier.strip()
    elif isinstance(meta, dict):
        base_name = str(meta.get("path") or "").strip()
    if not base_name:
        base_name = ts_id

    has_z_dim = _has_z_dimension(meta)
    z_selection: Optional[dict[str, object]] = None
    if has_z_dim:
        try:
            z_values = list_dimension_values(
                req.base_url,
                ts_id,
                "z",
                session,
                store_id=req.store_id,
                selection=base_selection if base_selection else None,
            )
            z_range = _min_max_int_values(z_values)
            if not z_range:
                raise QgsProcessingException(
                    f"Z dimension values for ts_id={ts_id} are empty or invalid."
                )
            z_selection = {"z": {"start": z_range[0], "stop": z_range[1]}}
            if feedback:
                feedback.pushInfo(
                    f"Detected multiband z-range for ts_id={ts_id}: "
                    f"{z_range[0]} -> {z_range[1]}"
                )
        except Exception as e:  # noqa: BLE001
            if feedback:
                feedback.pushInfo(f"Fetching z dimension values failed: {e}")
            raise

    mb_tag = "(MB)"
    mb_tag_lower = mb_tag.lower()
    name_base = f"{base_name} {mb_tag}" if has_z_dim else base_name
    if t0_iso:
        layer_name = f"{name_base}_{t0_iso}_{time_iso}"
    else:
        layer_name = f"{name_base}_{time_iso}"

    tif_path = download_raster_geotiff(
        req.base_url,
        ts_id,
        time_iso,
        target_file,
        session,
        store_id=req.store_id,
        t0_iso=t0_iso,
        dispatch_info=dispatch_info,
        member=member,
        extra_selection=z_selection,
        feedback=feedback,
    )

    rlayer = QgsRasterLayer(tif_path, layer_name, "gdal")
    if not rlayer.isValid():
        raise QgsProcessingException(
            f"Downloaded GeoTIFF could not be loaded as raster layer: {tif_path}"
        )

    is_multiband = has_z_dim or rlayer.bandCount() > 1
    if is_multiband and mb_tag_lower not in rlayer.name().lower():
        rlayer.setName(f"{rlayer.name()} {mb_tag}")

    # Attach custom properties so dialogs / tools can rediscover the origin
    rlayer.setCustomProperty("arraystorage:ts_id", ts_id)
    path_value = ""
    if req.ident_type == "path":
        path_value = req.identifier.strip()
    elif isinstance(meta, dict):
        path_value = str(meta.get("path") or "").strip()
    rlayer.setCustomProperty("arraystorage:path", path_value)
    rlayer.setCustomProperty("arraystorage:time", time_iso)
    if t0_iso:
        rlayer.setCustomProperty("arraystorage:t0", t0_iso)
    if dispatch_info:
        rlayer.setCustomProperty("arraystorage:dispatch_info", dispatch_info)
    if member:
        rlayer.setCustomProperty("arraystorage:member", member)
    rlayer.setCustomProperty("arraystorage:base_url", req.base_url)
    if req.store_id:
        rlayer.setCustomProperty("arraystorage:store_id", req.store_id)
    if req.as_user:
        rlayer.setCustomProperty("arraystorage:user", req.as_user)

    # Attach full metadata JSON as a single property and try to apply classification
    if isinstance(meta, dict):
        try:
            rlayer.setCustomProperty("arraystorage:metadata", json.dumps(meta))
            classification_id = (
                meta.get("classification_id")
                or meta.get("classificationId")
                or meta.get("classification")
            )
            if not classification_id:
                attrs = meta.get("attributes")
                if isinstance(attrs, dict):
                    classification_id = (
                        attrs.get("classification")
                        or attrs.get("classification_id")
                        or attrs.get("classificationId")
                    )
            if classification_id:
                rlayer.setCustomProperty(
                    "arraystorage:classification_id", classification_id
                )
                try:
                    # Lazy import to avoid circular dependency
                    from .arraystore_classifications import (
                        fetch_and_apply_classification,
                    )

                    fetch_and_apply_classification(
                        req.base_url, classification_id, rlayer, session=session
                    )
                    if feedback:
                        feedback.pushInfo(
                            f"Applied ArrayStorage classification '{classification_id}' to raster layer"
                        )
                except Exception as inner_e:  # noqa: BLE001
                    if feedback:
                        feedback.pushInfo(
                            f"Classification '{classification_id}' could not be applied: {inner_e}"
                        )
        except Exception as e:  # noqa: BLE001
            if feedback:
                feedback.pushInfo(f"ArrayStorage metadata handling failed: {e}")

    # Enable temporal range if possible
    try:
        tp = rlayer.temporalProperties()
        tp.setIsActive(False)
        start_dt = _parse_iso_qdatetime(time_iso)
        if start_dt.isValid():
            duration_secs: Optional[int] = None
            if isinstance(meta, dict):
                duration_secs = _parse_iso_duration_seconds(
                    str(meta.get("valueDistance") or "")
                )
            if duration_secs and duration_secs > 0:
                end_dt = QDateTime(start_dt)
                # Clamp the end to 1 second before the next valueDistance to avoid overlap
                end_dt = end_dt.addSecs(max(duration_secs - 1, 1))
            else:
                end_dt = QDateTime(start_dt)
                end_dt = end_dt.addSecs(1)

            tp.setMode(QgsRasterLayerTemporalProperties.ModeFixedTemporalRange)
            tp.setFixedTemporalRange(QgsDateTimeRange(start_dt, end_dt))
            tp.setReferenceTime(start_dt)
            tp.setIsActive(bool(req.activate_temporal))
            try:
                rlayer.setTemporalProperties(tp)
            except Exception:
                pass
    except Exception:
        pass

    if add_to_project:
        project = QgsProject.instance()
        group_name = base_name or ts_id
        project.addMapLayer(rlayer, False)
        try:
            root = project.layerTreeRoot()
            group = root.findGroup(group_name)
            if group is None:
                group = root.addGroup(group_name)
            group.addLayer(rlayer)
        except Exception:
            # fallback to default behaviour
            project.addMapLayer(rlayer)

    QgsMessageLog.logMessage(
        f"Loaded ArrayStorage raster ts_id={ts_id} time={time_iso} t0={t0_iso} -> {tif_path}",
        "ArrayStorage",
        Qgis.Info,
    )

    return ArrayStorageRasterLoadResult(
        tif_path=tif_path,
        ts_id=ts_id,
        time_iso=time_iso,
        layer=rlayer,
    )
