"""Zabbix service for real-time printer supply lookups.

Ports the classic ASP shopdb Zabbix integration (includes/zabbix.asp and
includes/zabbix_all_supplies.asp) to Python. Key behaviours preserved from
the live integration:

  - Auth via an Authorization: Bearer <token> header (Zabbix 6.0+ / 7.0).
    The old payload "auth" field is rejected by Zabbix 7.0.
  - Hosts are named by IP address, so a host is located with
    host.get filter {host: [ip]}, not by interface address.
  - Supply levels come from items tagged component=supplies AND type=level,
    not from a key_ substring search.
  - Each level item carries a color tag used for display and part lookup.

Configuration (database Setting overrides env var):
  ZABBIX_ENABLED: turn the integration on
  ZABBIX_URL: base URL or full api_jsonrpc.php URL
  ZABBIX_TOKEN: API token
"""

import logging
import time
from typing import Dict, List, Optional

import requests
from flask import current_app

from shopdb.api import cache

logger = logging.getLogger(__name__)


class ZabbixService:
    """Zabbix API client for printer supply and ping lookups."""

    CACHE_TTL = 300  # 5 min, matches the classic Application cache
    REACHABLE_CHECK_TTL = 60

    # quick fail for the reachability probe
    REACHABLE_TIMEOUT = 1.0
    # (connect, read) for real API calls; item.get is slow, give it room
    API_TIMEOUT = (3.0, 5.0)

    # supply-level item tags, mirrors zabbix.asp GetPrinterTonerLevels
    SUPPLY_TAGS = [
        {"tag": "component", "value": "supplies", "operator": 0},
        {"tag": "type", "value": "level", "operator": 0},
    ]

    def __init__(self):
        self._url = None
        self._token = None

    # -- configuration -------------------------------------------------------

    @property
    def isenabled(self) -> bool:
        """Whether the integration is switched on."""
        from shopdb.api import Setting
        db_enabled = Setting.get('zabbix_enabled')
        if db_enabled is not None:
            return bool(db_enabled)
        return current_app.config.get('ZABBIX_ENABLED', False)

    @property
    def isconfigured(self) -> bool:
        """Enabled, and a URL plus token are present."""
        if not self.isenabled:
            return False
        from shopdb.api import Setting
        self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
        self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
        return bool(self._url and self._token)

    @property
    def endpoint(self) -> str:
        """Full JSON-RPC endpoint. Accept a base URL or the full path."""
        url = (self._url or "").rstrip("/")
        if url.endswith("api_jsonrpc.php"):
            return url
        return f"{url}/api_jsonrpc.php"

    @property
    def isreachable(self) -> bool:
        """Cheap connectivity probe, cached for 60s."""
        if not self.isconfigured:
            return False

        cache_key = 'zabbix_reachable'
        cached = cache.get(cache_key)
        if cached is not None:
            return cached

        try:
            response = requests.get(self.endpoint, timeout=self.REACHABLE_TIMEOUT)
            # any non-5xx answer means the web tier responded, so the server is
            # up. Zabbix 7.0 returns 412 to a bare GET on api_jsonrpc.php (it
            # wants a POST with json-rpc content type); that still counts.
            reachable = response.status_code < 500
        except requests.RequestException:
            reachable = False

        cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
        logger.debug("Zabbix reachability: %s", reachable)
        return reachable

    # -- low level call ------------------------------------------------------

    def _apicall(self, method: str, params: Dict) -> Optional[object]:
        """One JSON-RPC call. Returns the result, or None on any error."""
        if not self.isconfigured:
            return None

        payload = {
            "jsonrpc": "2.0",
            "method": method,
            "params": params,
            "id": 1,
        }
        headers = {
            "Content-Type": "application/json-rpc",
            "Authorization": f"Bearer {self._token}",
        }

        try:
            response = requests.post(
                self.endpoint,
                json=payload,
                headers=headers,
                timeout=self.API_TIMEOUT,
            )
            response.raise_for_status()
            data = response.json()
        except (requests.RequestException, ValueError) as exc:
            logger.error("Zabbix %s call failed: %s", method, exc)
            return None

        if "error" in data:
            logger.error("Zabbix %s error: %s", method, data["error"])
            return None

        return data.get("result")

    # -- host / item lookups -------------------------------------------------

    def gethostidbyip(self, ip: str) -> Optional[str]:
        """Host id for a printer. Hosts are named by IP in this Zabbix."""
        result = self._apicall("host.get", {
            "output": ["hostid"],
            "filter": {"host": [ip]},
        })
        if result:
            return result[0].get("hostid")
        return None

    def _extract_color(self, item: Dict) -> str:
        """Pull and normalise the color tag, falling back to the item name."""
        color = ""
        for tag in item.get("tags", []) or []:
            if tag.get("tag") == "color":
                color = (tag.get("value") or "").lower()
                break
        if "black" in color:
            color = "black"
        elif color in ("grey", "gray"):
            color = "gray"

        if not color:
            name = (item.get("name") or "").lower()
            for candidate in ("cyan", "magenta", "yellow", "black"):
                if candidate in name:
                    color = candidate
                    break
            if not color and ("gray" in name or "grey" in name):
                color = "gray"
        return color

    def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
        """Current supply levels for a printer, by IP.

        Returns a list of dicts {name, level, color, itemid, status, state},
        or None if the host is not in Zabbix. Drum/maintenance items are kept
        (callers decide what to surface); only disabled (status=1) and
        unsupported (state=1) items are dropped, matching the classic report.
        """
        hostid = self.gethostidbyip(ip)
        if not hostid:
            logger.debug("No Zabbix host for IP %s", ip)
            return None

        items = self._apicall("item.get", {
            "output": ["itemid", "name", "lastvalue", "lastclock",
                       "units", "status", "state"],
            "hostids": hostid,
            "selectTags": "extend",
            "evaltype": 0,  # and
            "tags": self.SUPPLY_TAGS,
            "sortfield": "name",
            "monitored": True,
        })
        if not items:
            return []

        supplies = []
        for item in items:
            # skip disabled or unsupported items
            if str(item.get("status", "0")) != "0":
                continue
            if str(item.get("state", "0")) != "0":
                continue
            try:
                level = int(float(item.get("lastvalue", 0)))
            except (ValueError, TypeError):
                level = 0
            supplies.append({
                "name": item.get("name", "Unknown"),
                "level": level,
                "color": self._extract_color(item),
                "itemid": item.get("itemid"),
            })
        return supplies

    # Per item, per call. Zabbix applies `limit` to the WHOLE result set, not
    # per item, so the budget is multiplied by the number of items asked for.
    HISTORY_PER_ITEM = 2000

    def gethistory(self, itemids, days=90, peritem=None):
        """Raw level history for supply items: {itemid: [(clock, value), ...]}.

        Ordered oldest-first, which is what the analysis expects.

        Two things about the Zabbix API make the obvious call return the wrong
        rows, and both bit us:

          * `limit` caps the whole answer, not each item. A four-cartridge
            printer polled every five minutes writes over 100k rows in 90 days.
          * combined with `sortorder: ASC`, the cap keeps the OLDEST rows - so a
            "90 day" request came back holding the first few days of the window
            and nothing since. Every rate was computed from history a quarter of
            a year stale.

        So: sort DESC to keep the NEWEST rows, scale the budget by item count,
        and hand back ascending.

        history=3 is the unsigned-integer table, where a percent-remaining item
        lands; a site that types the item as float needs history=0. Items can be
        split across the two, so both are asked and the results merged - an
        earlier version stopped as soon as either answered, which silently lost
        every float-typed supply on a printer that also had an integer one.

        Returns {} when Zabbix is off or unreachable, so callers degrade to
        "no estimate" instead of failing.
        """
        if not itemids:
            return {}
        itemids = [str(i) for i in itemids]
        peritem = peritem or self.HISTORY_PER_ITEM
        timefrom = int(time.time()) - days * 86400
        collected = {}

        for historytype in (3, 0):
            outstanding = [i for i in itemids if i not in collected]
            if not outstanding:
                break
            rows = self._apicall("history.get", {
                "output": "extend",
                "history": historytype,
                "itemids": outstanding,
                "time_from": timefrom,
                "sortfield": "clock",
                "sortorder": "DESC",
                "limit": peritem * len(outstanding),
            }) or []
            for row in rows:
                collected.setdefault(str(row.get("itemid")), []).append(
                    (row.get("clock"), row.get("value")))

        for itemid, points in collected.items():
            points.reverse()          # DESC came back; analysis wants ASC
        return collected

    # Trends are hourly aggregates and are kept far longer than raw history.
    # 90 days of raw 5-minute readings is 26k rows an item; the same window in
    # trends is 2160. For a burn rate over months that is the right table -
    # nothing is gained by fitting a line through every individual poll.
    TRENDS_MIN_DAYS = 7
    TRENDS_PER_ITEM = 3000

    def gettrends(self, itemids, days=90, peritem=None):
        """Hourly level averages: {itemid: [(clock, value), ...]}, oldest first.

        Same `limit` caveat as gethistory - it caps the whole answer, so the
        budget is scaled and the sort is DESC.

        Returns {} if trends are not kept (a site can set the trend period to
        0), which is a real configuration, not an error. Callers fall back to
        raw history.
        """
        if not itemids:
            return {}
        itemids = [str(i) for i in itemids]
        peritem = peritem or self.TRENDS_PER_ITEM
        timefrom = int(time.time()) - days * 86400
        rows = self._apicall("trend.get", {
            "output": ["itemid", "clock", "value_avg"],
            "itemids": itemids,
            "time_from": timefrom,
            "sortfield": "clock",
            "sortorder": "DESC",
            "limit": peritem * len(itemids),
        }) or []

        collected = {}
        for row in rows:
            collected.setdefault(str(row.get("itemid")), []).append(
                (row.get("clock"), row.get("value_avg")))
        for itemid, points in collected.items():
            points.reverse()
        return collected

    def getlevelhistory(self, itemids, days=90):
        """Level history for a forecast, from whichever table serves it best.

        Trends for a long window, raw history for a short one, and raw history
        for any item the trend table has nothing for.
        """
        if not itemids:
            return {}
        if days < self.TRENDS_MIN_DAYS:
            return self.gethistory(itemids, days=days)

        collected = self.gettrends(itemids, days=days)
        missing = [i for i in (str(i) for i in itemids) if not collected.get(i)]
        if missing:
            collected.update(self.gethistory(missing, days=days))
        return collected

    def getpingstatus(self, ip: str) -> str:
        """ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
        hostid = self.gethostidbyip(ip)
        if not hostid:
            return "-1"
        items = self._apicall("item.get", {
            "output": ["lastvalue"],
            "hostids": hostid,
            "search": {"key_": "icmpping"},
        })
        if items:
            return str(items[0].get("lastvalue", "-1"))
        return "-1"

    # -- caching wrappers ----------------------------------------------------

    def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
        """getsuppliesbyip with a 5-minute per-IP cache."""
        cache_key = f"zabbix_supplies_{ip}"
        result = cache.get(cache_key)
        if result is not None:
            return result
        result = self.getsuppliesbyip(ip)
        if result is not None:
            cache.set(cache_key, result, timeout=self.CACHE_TTL)
        return result

    def clearcache(self, ip: str = None):
        """Drop cached supply data for one IP, plus the low-supplies roll-up."""
        if ip:
            cache.delete(f"zabbix_supplies_{ip}")
        cache.delete("printers_low_supplies")
        cache.delete("zabbix_reachable")
