# Reference client for the Hungry Machines API.
# Spec: https://docs.hungrymachines.io/openapi.json
# Guide: https://docs.hungrymachines.io/guides/build-with-an-agent/
#
# Standard library only (urllib) -- copy this file as a starting point, or
# port it to your own HTTP stack. It is not published as a package.
#
# Usage:
#   client = HMClient.log_in("you@example.com", "your-password")
#   client.update_preferences(base_temperature=72.0, optimization_mode="cool")
#   appliance = client.create_appliance("hvac", "Living Room AC", {
#       "entity_id": "climate.living_room",
#   })
#   client.push_readings([{
#       "appliance_id": appliance["appliance_id"],
#       "timestamp": "2026-01-01T00:00:00+00:00",
#       "indoor_temp": 72.0,
#       "hvac_state": "COOL",
#       "outdoor_temp": 85.0,
#   }], client_info={"kind": "custom", "name": "My Thermostat Bridge", "version": "1.0.0"})
#   schedules = client.get_schedules()
#   command = slot_command(schedules["appliances"][0], datetime.now(ZoneInfo("America/Los_Angeles")))

import json
import urllib.error
import urllib.request
from datetime import datetime
from zoneinfo import ZoneInfo

DEFAULT_BASE_URL = "https://api.hungrymachines.io"


class HMAPIError(Exception):
    """Raised for any non-2xx response. `status` and `detail` mirror the
    API's {"detail": "..."} error shape (see the Errors, Limits & Stability
    page)."""

    def __init__(self, status, detail):
        super().__init__(f"HTTP {status}: {detail}")
        self.status = status
        self.detail = detail


class HMClient:
    """A minimal client for the Hungry Machines API. Holds a session (access
    + refresh token) and refreshes once, automatically, on a 401."""

    def __init__(self, access_token, refresh_token, base_url=DEFAULT_BASE_URL):
        self.base_url = base_url.rstrip("/")
        self.access_token = access_token
        self.refresh_token = refresh_token

    @classmethod
    def sign_up(cls, email, password, base_url=DEFAULT_BASE_URL, **fields):
        """POST /auth/signup. Programmatic signup skips billing/onboarding --
        use this only for test accounts. Send timezone (an IANA zone name,
        e.g. "America/Los_Angeles") as a keyword field so the account starts
        on the right local day -- every schedule is keyed to local midnight.
        Returns either an authenticated HMClient, or
        (email_confirmation_required=True, user=<dict>) when the account
        requires email confirmation first (no session yet)."""
        body = {"email": email, "password": password, **fields}
        result = _request(base_url, "POST", "/auth/signup", body=body)
        if "access_token" in result:
            return cls(result["access_token"], result["refresh_token"], base_url)
        return {"email_confirmation_required": True, "user": result.get("user")}

    @classmethod
    def log_in(cls, email, password, base_url=DEFAULT_BASE_URL):
        """POST /auth/login."""
        result = _request(
            base_url, "POST", "/auth/login", body={"email": email, "password": password}
        )
        return cls(result["access_token"], result["refresh_token"], base_url)

    def refresh(self):
        """POST /auth/refresh. Refresh tokens are single-use: the response
        rotates both tokens, so the new refresh_token replaces the old one."""
        result = _request(
            self.base_url,
            "POST",
            "/auth/refresh",
            body={"refresh_token": self.refresh_token},
        )
        self.access_token = result["access_token"]
        self.refresh_token = result["refresh_token"]

    def _authed_request(self, method, path, body=None):
        try:
            return _request(self.base_url, method, path, body=body, token=self.access_token)
        except HMAPIError as exc:
            if exc.status != 401:
                raise
            self.refresh()
            return _request(self.base_url, method, path, body=body, token=self.access_token)

    def get_preferences(self):
        """GET /api/v1/preferences."""
        return self._authed_request("GET", "/api/v1/preferences")

    def update_preferences(self, **fields):
        """PUT /api/v1/preferences. All fields are optional -- send only
        what the person configured (e.g. base_temperature, savings_level,
        time_away, time_home, optimization_mode)."""
        return self._authed_request("PUT", "/api/v1/preferences", body=fields)

    def create_appliance(self, appliance_type, name, config=None):
        """POST /api/v1/appliances. `config` is type-specific; HVAC and
        non-solar types need at least `entity_id`. Returns {"appliance_id": ...}."""
        body = {"appliance_type": appliance_type, "name": name, "config": config or {}}
        return self._authed_request("POST", "/api/v1/appliances", body=body)

    def push_readings(self, readings, client_info=None):
        """POST /api/v1/readings -- HVAC readings only. Non-HVAC appliances
        use POST /api/v1/appliances/{appliance_id}/readings instead (not
        covered by this reference client).

        The optional `client` block identifies your client software. Pass
        {"kind": "custom", "name": "<your client>"} to say what it is;
        `kind` also takes "hacs" or "node", but those name Hungry Machines'
        own software. Leaving `client_info` unset omits the block entirely,
        which is fine too.
        """
        body = {"readings": readings}
        if client_info is not None:
            body["client"] = client_info
        return self._authed_request("POST", "/api/v1/readings", body=body)

    def push_weather(self, hourly_temps_f, **forecast):
        """POST /api/v1/weather -- push the home's own hourly forecast once
        a day, before the account's nightly window. 24-72 values starting at
        hour 0 of the push date; skip it and the optimizer falls back to a
        generic source for the location. Optional same-length arrays:
        hourly_humidity, hourly_wind_mph, hourly_cloud_coverage_pct."""
        body = {"forecast": {"hourly_temps_f": hourly_temps_f, **forecast}}
        return self._authed_request("POST", "/api/v1/weather", body=body)

    def get_schedules(self):
        """GET /api/v1/schedules -- today's schedule for every appliance."""
        return self._authed_request("GET", "/api/v1/schedules")

    def recompute_schedule(self):
        """POST /api/v1/schedule/recompute -- replan now (typically 1-5s)
        instead of waiting for the next scheduled run. Same response shape
        as get_schedules()."""
        return self._authed_request("POST", "/api/v1/schedule/recompute", body={})


def current_slot(local_dt):
    """Slot 0 is local midnight; each slot is 30 minutes. `local_dt` must
    already be in the home's local timezone (see slot_command below)."""
    return local_dt.hour * 2 + (1 if local_dt.minute >= 30 else 0)


def slot_command(appliance, local_dt):
    """Given one entry from get_schedules()["appliances"] and the current
    local time, return the command for the current slot.

    Returns None when there is nothing to apply yet: an appliance with no
    stored plan comes back from get_schedules() as `"schedule": {}` with
    `"source": "defaults"`. Leave the device as it is and try again after the
    next plan (overnight, after a settings save, or after recompute()).

    HVAC: a target temperature in Fahrenheit, from setpoint_temps. If a plan
    carries a comfort band but no setpoints, this falls back to the band's
    midpoint, (high_temps[slot] + low_temps[slot]) / 2.

    EV charger / home battery / robot / water heater: True/False, read from
    the boolean `intervals` array. Apply the appliance ON when True, OFF when
    False.

    Solar has no per-interval command and is not covered here.
    """
    slot = current_slot(local_dt)
    schedule = appliance.get("schedule") or {}
    if appliance["appliance_type"] == "hvac":
        setpoints = schedule.get("setpoint_temps")
        if setpoints is not None:
            return setpoints[slot]
        highs, lows = schedule.get("high_temps"), schedule.get("low_temps")
        if highs is None or lows is None:
            return None
        return (highs[slot] + lows[slot]) / 2
    intervals = schedule.get("intervals")
    if intervals is None:
        return None
    return bool(intervals[slot])


def _request(base_url, method, path, body=None, token=None):
    url = base_url.rstrip("/") + path
    data = json.dumps(body).encode("utf-8") if body is not None else None
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req) as resp:
            raw = resp.read()
            return json.loads(raw) if raw else {}
    except urllib.error.HTTPError as exc:
        raw = exc.read()
        try:
            detail = json.loads(raw).get("detail", raw.decode("utf-8", "replace"))
        except (json.JSONDecodeError, AttributeError):
            detail = raw.decode("utf-8", "replace")
        raise HMAPIError(exc.code, detail) from None


if __name__ == "__main__":
    # A tiny smoke test against a home's local time, using the schedule
    # shape documented at https://docs.hungrymachines.io/api-reference/schedule/.
    example_schedule = {
        "appliance_type": "hvac",
        # No setpoint_temps key at all -- what the API really sends while
        # an account is still on source: "defaults".
        "schedule": {
            "high_temps": [74.0] * 48,
            "low_temps": [70.0] * 48,
        },
    }
    now = datetime.now(ZoneInfo("America/Los_Angeles"))
    print("slot", current_slot(now), "command", slot_command(example_schedule, now))
