#!/usr/bin/env python3
"""Print a threshold alert for USD/IDR without sending anything externally."""

import json
import os
import sys
import tempfile
import time
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import HTTPRedirectHandler, Request, build_opener


API_URL = "https://api.exchangerate.dev/v1/latest/USD?symbols=IDR"
MIN_POLL_SECONDS = 60
TIMEOUT_SECONDS = 10


class RateLimitedError(RuntimeError):
    def __init__(self, message: str, retry_after: int | None = None):
        super().__init__(message)
        self.retry_after = retry_after


class NoRedirects(HTTPRedirectHandler):
    def redirect_request(self, request, *args, **kwargs):
        return None


OPENER = build_opener(NoRedirects())


def env_decimal(name: str, default: str) -> Decimal:
    value = os.environ.get(name, default)
    try:
        result = Decimal(value)
    except InvalidOperation as exc:
        raise ValueError(f"{name} must be a number") from exc
    if not result.is_finite() or result <= 0:
        raise ValueError(f"{name} must be greater than zero")
    return result


def env_seconds() -> int:
    raw = os.environ.get("FX_ALERT_POLL_SECONDS", "300")
    try:
        seconds = int(raw)
    except ValueError as exc:
        raise ValueError("FX_ALERT_POLL_SECONDS must be an integer") from exc
    if seconds < MIN_POLL_SECONDS:
        raise ValueError(f"FX_ALERT_POLL_SECONDS must be at least {MIN_POLL_SECONDS}")
    return seconds


def max_age_seconds() -> int:
    raw = os.environ.get("FX_ALERT_MAX_AGE_SECONDS", "3600")
    try:
        age = int(raw)
    except ValueError as exc:
        raise ValueError("FX_ALERT_MAX_AGE_SECONDS must be an integer") from exc
    if age <= 0:
        raise ValueError("FX_ALERT_MAX_AGE_SECONDS must be greater than zero")
    return age


def parse_timestamp(value: object) -> datetime:
    if not isinstance(value, str) or not value.strip():
        raise RuntimeError("API response has no valid effective_at timestamp")
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError as exc:
        raise RuntimeError("API response has an invalid effective_at timestamp") from exc
    if parsed.tzinfo is None:
        raise RuntimeError("API response effective_at must include a timezone")
    return parsed.astimezone(timezone.utc)


def state_path() -> Path:
    return Path(os.environ.get("FX_ALERT_STATE_PATH", ".fx-alert-state.json"))


def load_state(path: Path) -> dict:
    try:
        value = json.loads(path.read_text())
    except FileNotFoundError:
        return {"initialized": False, "armed": True}
    except (OSError, json.JSONDecodeError) as exc:
        raise RuntimeError(f"cannot read alert state: {exc}") from exc
    if not isinstance(value, dict):
        raise RuntimeError("alert state must be a JSON object")
    return value


def save_state(path: Path, value: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent), text=True)
    try:
        os.fchmod(fd, 0o600)
        with os.fdopen(fd, "w") as handle:
            json.dump(value, handle, indent=2, sort_keys=True)
            handle.write("\n")
        os.replace(temporary, path)
    except Exception:
        try:
            os.unlink(temporary)
        except OSError:
            pass
        raise


def retry_after_seconds(error: HTTPError) -> int | None:
    value = error.headers.get("Retry-After")
    if not value:
        return None
    try:
        return max(0, int(value))
    except ValueError:
        try:
            return max(0, int(parsedate_to_datetime(value).timestamp() - time.time()))
        except (TypeError, ValueError, OverflowError):
            return None


def fetch_observation(api_key: str | None, max_age: int) -> dict:
    headers = {"Accept": "application/json"}
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"
    request = Request(API_URL, headers=headers, method="GET")
    try:
        with OPENER.open(request, timeout=TIMEOUT_SECONDS) as response:
            if response.status < 200 or response.status >= 300:
                raise RuntimeError(f"HTTP {response.status}")
            payload = json.load(response, parse_float=Decimal)
    except HTTPError as error:
        if error.code == 429:
            wait = retry_after_seconds(error)
            suffix = f"; retry after {wait}s" if wait is not None else ""
            raise RateLimitedError(f"rate limited (429){suffix}", wait) from error
        raise RuntimeError(f"HTTP {error.code}") from error
    except URLError as error:
        raise RuntimeError(f"network error: {error.reason}") from error
    except (TimeoutError, OSError) as error:
        raise RuntimeError(f"network error: {error}") from error

    if not isinstance(payload, dict) or payload.get("result") not in (None, "success"):
        raise RuntimeError("API returned an invalid response")
    if payload.get("base") != "USD":
        raise RuntimeError("API response has an unexpected base currency")
    rates = payload.get("rates")
    sources = payload.get("sources")
    effective_at = payload.get("effective_at")
    if not isinstance(rates, dict) or not isinstance(sources, dict) or not isinstance(effective_at, dict):
        raise RuntimeError("API response is missing per-currency freshness fields")
    try:
        rate = Decimal(str(rates["IDR"]))
    except (KeyError, InvalidOperation) as exc:
        raise RuntimeError("API response has no valid IDR rate") from exc
    effective = parse_timestamp(effective_at.get("IDR"))
    age = (datetime.now(timezone.utc) - effective).total_seconds()
    if not rate.is_finite() or rate <= 0 or not sources.get("IDR"):
        raise RuntimeError("API response has invalid IDR freshness data")
    if age < -300 or age > max_age:
        raise RuntimeError(f"IDR observation is outside the {max_age}s freshness window")
    return {
        "rate": rate,
        "source": str(sources["IDR"]),
        "effective_at": effective.isoformat(),
        "market_session": str(payload.get("market_session", "unknown")),
    }


def observe(state: dict, current: dict, threshold: Decimal) -> tuple[dict, str | None]:
    # A first observation establishes a baseline. It never alerts.
    if not state.get("initialized"):
        return {
            "initialized": True,
            "armed": True,
            "last_rate": str(current["rate"]),
            "last_effective_at": current["effective_at"],
            "last_source": current["source"],
            "last_market_session": current["market_session"],
        }, None

    previous_effective = parse_timestamp(state.get("last_effective_at"))
    current_effective = parse_timestamp(current["effective_at"])
    if current_effective <= previous_effective:
        # Repeated or older observations must not create an alert or re-arm it.
        return state, None
    try:
        previous_rate = Decimal(str(state["last_rate"]))
    except (KeyError, InvalidOperation) as exc:
        raise RuntimeError("alert state has no valid previous rate") from exc
    if not previous_rate.is_finite() or previous_rate <= 0:
        raise RuntimeError("alert state has an invalid previous rate")

    move = (current["rate"] - previous_rate) / previous_rate * Decimal("100")
    magnitude = abs(move)
    armed = bool(state.get("armed", True))
    alert = None
    if magnitude < threshold:
        armed = True
    elif armed:
        direction = "up" if move > 0 else "down"
        alert = f"ALERT USD/IDR {direction}: {move:+.2f}% ({previous_rate} -> {current['rate']})"
        armed = False
    next_state = {
        **state,
        "initialized": True,
        "armed": armed,
        "last_rate": str(current["rate"]),
        "last_effective_at": current["effective_at"],
        "last_source": current["source"],
        "last_market_session": current["market_session"],
    }
    return next_state, alert


def run() -> None:
    threshold = env_decimal("FX_ALERT_THRESHOLD_PCT", "1")
    interval = env_seconds()
    path = state_path()
    max_age = max_age_seconds()
    api_key = os.environ.get("EXCHANGERATE_API_KEY")
    state = load_state(path)
    print(f"Watching USD/IDR; threshold={threshold}% poll={interval}s max_age={max_age}s", flush=True)
    while True:
        try:
            current = fetch_observation(api_key, max_age)
            next_state, alert = observe(state, current, threshold)
            save_state(path, next_state)
            state = next_state
            if alert:
                print(f"{alert}; source={current['source']} effective_at={current['effective_at']} session={current['market_session']}", flush=True)
            else:
                print(f"OK USD/IDR {current['rate']} effective_at={current['effective_at']} source={current['source']} session={current['market_session']}", flush=True)
        except (RuntimeError, ValueError, OSError) as error:
            print(f"ERROR {error}; retaining prior state", file=sys.stderr, flush=True)
            delay = error.retry_after if isinstance(error, RateLimitedError) and error.retry_after is not None else 0
            time.sleep(max(interval, delay))
            continue
        time.sleep(interval)


if __name__ == "__main__":
    try:
        run()
    except KeyboardInterrupt:
        print("Stopped.", file=sys.stderr)
