#!/usr/bin/env python3
"""Place a call so that a retry can never place it twice.

One Idempotency-Key per intended call: created once, stored with your own record, reused on every
retry. Requires Python 3.9+ and requests. Runs in test mode only: nothing is dialled or charged.

    export RELATIONSAI_API_KEY="rai_test_..."
    python3 idempotent-retry.py
"""
import os
import random
import sys
import time
import uuid
from datetime import datetime, timezone

import requests

api_key = os.environ.get("RELATIONSAI_API_KEY", "")
base_url = os.environ.get("RELATIONSAI_API_BASE", "https://voice-api.relationsai.com/v1").rstrip("/")
session = requests.Session()
session.headers["Authorization"] = f"Bearer {api_key}"


class ApiError(Exception):
    def __init__(self, status, error):
        super().__init__(f"{status} {error['code']} (request {error['request_id']}): {error['message']}")
        self.code = error["code"]


def backoff_seconds(attempt):
    # Exponential backoff with full jitter: a random wait between 0 and min(30 s, 1 s x 2^attempt).
    return random.uniform(0, min(30, 2 ** attempt))


def post_with_retry(path, body, idempotency_key, find_existing=None, max_attempts=5):
    """POST with retries. Every attempt sends the SAME Idempotency-Key, so a stored result is replayed instead of
    running again. A 500 internal_error is NOT stored: a same-key retry would run the request again, so after one
    the helper first asks find_existing() whether the write already happened, and retries only if it did not."""
    for attempt in range(1, max_attempts + 1):
        try:
            r = session.post(f"{base_url}{path}", json=body, headers={"Idempotency-Key": idempotency_key}, timeout=30)
        except requests.RequestException as err:
            # No response (timeout, connection reset): the request may or may not have run. Retry with the same key.
            if attempt == max_attempts:
                raise RuntimeError(f"No response after {attempt} attempts ({err}). Check state before sending again.")
            time.sleep(backoff_seconds(attempt))
            continue
        try:
            data = r.json()
        except ValueError:
            data = None
        if r.ok and data is not None:
            return data, r.headers.get("Idempotent-Replayed") == "true"
        if not isinstance(data, dict) or "error" not in data:
            # Not an API answer (for example a proxy's HTML 502): like no response, it may or may not have run.
            if attempt == max_attempts:
                raise RuntimeError(f"{r.status_code} without an API error body after {attempt} attempts. Check state before sending again.")
            time.sleep(backoff_seconds(attempt))
            continue
        retry = data["error"]["retry"]
        if data["error"]["code"] == "internal_error" and find_existing:
            existing = find_existing()
            if existing:
                return existing, False
        if retry == "no" or attempt == max_attempts:
            raise ApiError(r.status_code, data["error"])
        # retry "after": wait Retry-After seconds. retry "yes": back off exponentially. Same key either way.
        wait = float(r.headers.get("Retry-After", "1")) if retry == "after" else backoff_seconds(attempt)
        print(f"{data['error']['code']}: retrying in {wait:.0f} s with the same Idempotency-Key")
        time.sleep(wait)


def get(path, params=None):
    r = session.get(f"{base_url}{path}", params=params, timeout=30)
    data = r.json()
    if not r.ok:
        raise ApiError(r.status_code, data["error"])
    return data


def find_call(to, since, order_id):
    """After idempotency_outcome_unknown: look for the call before sending it again with a NEW key."""
    page = get("/calls", {"to": to, "created_after": since, "limit": 100})
    return next((c for c in page["data"] if c["metadata"].get("order_id") == order_id), None)


def main():
    if not api_key.startswith("rai_test_"):
        raise RuntimeError("Set RELATIONSAI_API_KEY to a test key (rai_test_...).")
    agents = get("/agents", {"limit": 100})
    agent_id = os.environ.get("RELATIONSAI_AGENT_ID") or next((a["id"] for a in agents["data"] if a["sample"]), None)
    if not agent_id:
        raise RuntimeError("No Sample agent found. Use the id of one of your own agents.")

    # Your own record, for example an order that needs a reminder call. Create the key once and store it
    # with the record before the first attempt, so a restarted worker retries with the same key.
    order = {"id": "ord_1001", "phone": "+12025550101", "idempotency_key": str(uuid.uuid4())}
    payload = {"agent_id": agent_id, "to": order["phone"], "metadata": {"order_id": order["id"]}}
    started_at = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")

    def find_existing():
        return find_call(order["phone"], started_at, order["id"])

    try:
        call, replayed = post_with_retry("/calls", payload, order["idempotency_key"], find_existing)
    except ApiError as err:
        if err.code != "idempotency_outcome_unknown":
            raise
        existing = find_call(order["phone"], started_at, order["id"])
        if existing:
            raise RuntimeError(f"The call exists: {existing['id']}. Do not send it again.")
        raise RuntimeError("No call found: send it again with a NEW key.")
    print(f"Placed {call['id']}, status {call['status']}, replayed: {str(replayed).lower()}")

    # A retry after a lost response: the same request with the same key returns the original call.
    again, replayed = post_with_retry("/calls", payload, order["idempotency_key"], find_existing)
    print(f"Retried: {again['id']}, replayed: {str(replayed).lower()}")
    if again["id"] != call["id"]:
        raise RuntimeError("A retry with the same key must return the same call.")
    print("Same call returned: no second call was placed.")


if __name__ == "__main__":
    try:
        main()
    except (RuntimeError, ApiError, requests.RequestException) as err:
        sys.exit(f"Error: {err}")
