"""Retrieves PV forecast data from the pvnode.com V2 API.

pvnode.com delivers native 15-minute PV power forecasts. Two request modes,
decided by configuration:

* ``site_id`` set  -> ``GET /v2/forecast/{site_id}`` — a saved (and possibly
  calibrated) site managed in the pvnode web app. This is the operator's primary
  path: register the plant once on pvnode.com, then enter the site id + API key.
* ``site_id`` empty -> ``POST /v2/forecast/inline`` — geometry is sent inline from
  the configured ``pvforecast.planes`` (works without any web-app setup).

V2 response timestamps are SITE-LOCAL wall-clock (no offset) accompanied by an
IANA ``timezone`` field. We resolve them to absolute instants here so the rest of
EOS keeps working in its own timezone. ``pv_power`` is nullable (e.g. at night) —
null is treated as 0 W so the optimizer's linear resampling does not interpolate
phantom production across the night.

Notes:
    - Requires ``pvforecast.pvnode.api_key`` (Bearer auth).
    - API: https://api.pvnode.com/v2  (15-minute resolution).
"""

import re
import urllib.parse
from typing import Any, Optional

import pendulum
import requests
from loguru import logger
from pydantic import Field

from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.cache import cache_in_file
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
from akkudoktoreos.utils.datetimeutil import to_datetime

PVNODE_BASE = "https://api.pvnode.com/v2"

_TZ_SUFFIX = re.compile(r"([zZ]|[+-]\d\d:?\d\d)$")


class PVForecastPVNodeCommonSettings(SettingsBaseModel):
    """Common settings for the pvnode.com PV forecast provider."""

    api_key: str = Field(
        default="",
        json_schema_extra={
            "description": "pvnode.com API key (Bearer auth). Required.",
            "examples": ["pvn_live_xxxxxxxxxxxxxxxx"],
        },
    )
    site_id: Optional[str] = Field(
        default=None,
        json_schema_extra={
            "description": (
                "pvnode.com site id of the saved plant ('Anlagen-ID'). When set, the "
                "saved (possibly calibrated) site is used. Leave empty to send the "
                "configured pvforecast.planes inline instead."
            ),
            "examples": ["abcd-1234"],
        },
    )
    forecast_days: int = Field(
        default=2,
        ge=1,
        le=7,
        json_schema_extra={
            "description": "Forecast horizon in days (1-7, capped by the pvnode plan).",
            "examples": [2],
        },
    )


class PVForecastPVNode(PVForecastProvider):
    """Fetch and process PV forecast data from the pvnode.com V2 API."""

    @classmethod
    def provider_id(cls) -> str:
        """Return the unique identifier for the PV-Forecast-Provider."""
        return "PVForecastPVNode"

    @property
    def _settings(self) -> PVForecastPVNodeCommonSettings:
        settings = self.config.pvforecast.pvnode
        if settings is None:
            settings = PVForecastPVNodeCommonSettings()
        return settings

    def _to_utc_datetime(self, local_ts: Any, iana_tz: Optional[str]) -> Any:
        """Resolve a pvnode V2 wall-clock timestamp to a timezone-aware datetime.

        V2 timestamps are local wall-clock without offset (e.g. "2026-06-22T14:00:00")
        plus a response-level IANA ``timezone``. If the string already carries an
        explicit offset or 'Z' it is trusted as-is.
        """
        s = str(local_ts).strip()
        if _TZ_SUFFIX.search(s):
            # Already absolute (offset or Z present) — parse as-is.
            return to_datetime(s)
        tz = iana_tz or str(self.config.general.timezone)
        # Interpret the naive wall-clock string AS local time in tz, then resolve.
        dt = pendulum.parse(s, tz=tz)
        return to_datetime(dt.isoformat())

    def _extract_values(self, body: Any) -> list[tuple[Any, float]]:
        """Extract (datetime, power_w) rows from a pvnode V2 response body.

        Canonical shape: ``{"timezone": ..., "values": [{"timestamp", "pv_power"}, ...]}``.
        Tolerant of edge/legacy shapes (mirrors the production DVhub client).
        """
        tz: Optional[str] = None
        arr: Any = None
        if isinstance(body, list):
            arr = body
        elif isinstance(body, dict):
            tz = body.get("timezone") if isinstance(body.get("timezone"), str) else None
            for key in ("values", "forecasts", "data", "forecast"):
                if isinstance(body.get(key), list):
                    arr = body[key]
                    break
        if not isinstance(arr, list):
            return []

        rows: list[tuple[Any, float]] = []
        for entry in arr:
            if not isinstance(entry, dict):
                continue
            ts = (
                entry.get("timestamp")
                or entry.get("time")
                or entry.get("ts")
                or entry.get("ts_utc")
                or entry.get("datetime")
            )
            if ts is None:
                continue
            # pv_power is nullable (night) -> treat missing as 0 W, not a gap, so the
            # optimizer's linear resampling does not interpolate across the night.
            raw_power = entry.get("pv_power")
            if raw_power is None:
                raw_power = entry.get("power_w")
            if raw_power is None:
                raw_power = entry.get("power")
            if raw_power is None:
                raw_power = entry.get("watts")
            power = 0.0 if raw_power is None else float(raw_power)
            try:
                date = self._to_utc_datetime(ts, tz)
            except Exception as e:  # noqa: BLE001 - skip unparseable rows
                logger.warning(f"pvnode: skipping unparseable timestamp {ts!r}: {e}")
                continue
            rows.append((date, round(power, 1)))
        return rows

    @cache_in_file(with_ttl="1 hour")
    def _request_forecast(self) -> Any:
        """Fetch the PV forecast from pvnode.com (saved site or inline planes)."""
        settings = self._settings
        api_key = settings.api_key
        if not api_key:
            raise ValueError("PVForecastPVNode requires pvforecast.pvnode.api_key")

        headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json"}
        params = {"forecast_days": str(settings.forecast_days)}
        site_id = (settings.site_id or "").strip()

        try:
            if site_id:
                url = f"{PVNODE_BASE}/forecast/{urllib.parse.quote(site_id, safe='')}"
                response = requests.get(url, headers=headers, params=params, timeout=30)
            else:
                body = self._inline_body()
                url = f"{PVNODE_BASE}/forecast/inline"
                headers["Content-Type"] = "application/json"
                response = requests.post(url, headers=headers, params=params, json=body, timeout=30)
            logger.debug(f"Requesting pvnode forecast: {url}")
            response.raise_for_status()
        except requests.RequestException as e:
            logger.error(f"Failed to fetch pvforecast from pvnode: {e}")
            raise RuntimeError("Failed to fetch pvforecast from pvnode API") from e

        self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
        return response.json()

    def _inline_body(self) -> dict:
        """Build the inline-mode request body from latitude/longitude + planes."""
        latitude = self.config.general.latitude
        longitude = self.config.general.longitude
        if latitude is None or longitude is None:
            raise ValueError(
                "PVForecastPVNode inline mode needs general.latitude/longitude "
                "(or set pvforecast.pvnode.site_id)"
            )
        planes = self.config.pvforecast.planes or []
        strings = []
        for plane in planes:
            tilt = getattr(plane, "surface_tilt", None)
            azimuth = getattr(plane, "surface_azimuth", None)
            peakpower = getattr(plane, "peakpower", None)
            if peakpower is None or tilt is None or azimuth is None:
                continue
            strings.append(
                {
                    "slope": float(tilt),
                    # pvnode V2 azimuth convention (0=N, 90=E, 180=S, 270=W) matches
                    # EOS surface_azimuth, so it is forwarded unchanged.
                    "orientation": float(azimuth),
                    "power_kw": float(peakpower),
                }
            )
        if not strings:
            raise ValueError(
                "PVForecastPVNode inline mode needs at least one pvforecast.planes "
                "entry with peakpower, surface_tilt and surface_azimuth"
            )
        return {"latitude": float(latitude), "longitude": float(longitude), "strings": strings}

    async def _update_data(self, force_update: Optional[bool] = False) -> None:
        """Update forecast data in the PVForecastDataRecord format."""
        if not self.enabled():
            logger.info("PVForecastPVNode is disabled, skipping update.")
            return

        body = self._request_forecast(force_update=force_update)  # type: ignore[call-arg]
        rows = self._extract_values(body)

        for date, power_w in rows:
            # pvnode returns the plant's expected output power; feed it as AC power
            # (the key the optimizer reads) and mirror it to DC for reporting.
            await self.update_value(
                date,
                {"pvforecast_ac_power": power_w, "pvforecast_dc_power": power_w},
            )

        logger.debug(f"Updated pvforecast from pvnode with {len(rows)} entries.")
        self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)


# Example usage
if __name__ == "__main__":
    import asyncio

    pv = PVForecastPVNode()
    asyncio.run(pv._update_data())
