# retoor <retoor@molodetz.nl>

import hashlib
import json
import logging
import string
import time
from datetime import datetime, timezone
from typing import Any

import httpx
import jwt

from devplacepy.config import SECONDS_PER_DAY
from devplacepy.database import get_setting, set_setting
from devplacepy.push.providers.base import (
    ACCEPTED,
    DEAD,
    REJECTED,
    Delivery,
    PushProvider,
)
from devplacepy.services.base import ConfigField
from devplacepy.utils import DEFAULT_PUSH_URL, PUSH_ICON, generate_uid

logger = logging.getLogger(__name__)

TEAM_ID_KEY = "push_apns_team_id"
KEY_ID_KEY = "push_apns_key_id"
AUTH_KEY_KEY = "push_apns_auth_key"
TOPIC_KEY = "push_apns_topic"
ENVIRONMENT_KEY = "push_apns_environment"

PROVIDER_LABEL = "Apple Push (APNs)"
DEFAULT_ENVIRONMENT = "production"
HOSTS = {
    "production": "api.push.apple.com",
    "sandbox": "api.sandbox.push.apple.com",
}
ENVIRONMENT_OPTIONS = [
    {"value": "production", "label": "Production"},
    {"value": "sandbox", "label": "Sandbox"},
]

TOKEN_REFRESH_SECONDS = 45 * 60
TOKEN_MIN_LENGTH = 64
TOKEN_MAX_LENGTH = 200
CLIENT_ID_MAX_LENGTH = 128
THREAD_ID = "devplace-notification"
PUSH_TYPE = "alert"
PRIORITY = "10"
SHARED_TOKEN_KEY = "push_apns_shared_token"

DEAD_REASONS = frozenset({"BadDeviceToken", "ExpiredToken", "Unregistered"})

AUTH_REASONS = frozenset(
    {
        "BadCertificate",
        "BadCertificateEnvironment",
        "ExpiredProviderToken",
        "Forbidden",
        "InvalidProviderToken",
        "MissingProviderToken",
    }
)

_token_state: dict[str, Any] = {}
_client: httpx.AsyncClient | None = None


def _setting(key: str) -> str:
    return get_setting(key, "").strip()


def _environment() -> str:
    value = _setting(ENVIRONMENT_KEY) or DEFAULT_ENVIRONMENT
    return value if value in HOSTS else DEFAULT_ENVIRONMENT


def host() -> str:
    return host_for(_environment())


def host_for(environment: str | None) -> str:
    value = (environment or "").strip() or _environment()
    return HOSTS[value] if value in HOSTS else HOSTS[DEFAULT_ENVIRONMENT]


def gateway_client(timeout: float) -> httpx.AsyncClient:
    return httpx.AsyncClient(
        http2=True,
        timeout=timeout,
        trust_env=False,
        verify=True,
        follow_redirects=False,
    )


def cached_client(timeout: float) -> httpx.AsyncClient:
    global _client
    if _client is None or _client.is_closed:
        _client = gateway_client(timeout)
    return _client


async def close_client() -> None:
    global _client
    if _client is not None and not _client.is_closed:
        await _client.aclose()
    _client = None


def _normalize_token(token: str) -> str:
    return "".join(
        character.lower() for character in token if character in string.hexdigits
    )


def _fingerprint(team_id: str, key_id: str, auth_key: str) -> str:
    return hashlib.sha256(f"{team_id}:{key_id}:{auth_key}".encode("utf-8")).hexdigest()


def _read_shared_token() -> dict[str, Any] | None:
    raw = get_setting(SHARED_TOKEN_KEY, "")
    if not raw:
        return None
    try:
        data = json.loads(raw)
    except ValueError:
        return None
    if (
        not isinstance(data, dict)
        or not isinstance(data.get("fingerprint"), str)
        or not isinstance(data.get("token"), str)
        or not isinstance(data.get("issued_at"), int)
    ):
        return None
    return data


def _write_shared_token(fingerprint: str, token: str, issued_at: int) -> None:
    set_setting(
        SHARED_TOKEN_KEY,
        json.dumps({"fingerprint": fingerprint, "token": token, "issued_at": issued_at}),
    )


def invalidate_provider_token() -> None:
    _token_state.pop("current", None)
    set_setting(SHARED_TOKEN_KEY, "")


def provider_token(team_id: str, key_id: str, auth_key: str) -> str:
    fingerprint = _fingerprint(team_id, key_id, auth_key)
    issued_at = int(time.time())
    state = _token_state.get("current")
    if (
        state
        and state["fingerprint"] == fingerprint
        and issued_at - state["issued_at"] < TOKEN_REFRESH_SECONDS
    ):
        if state["token"] is None:
            raise ValueError(state["error"])
        return state["token"]

    shared = _read_shared_token()
    if (
        shared
        and shared["fingerprint"] == fingerprint
        and issued_at - shared["issued_at"] < TOKEN_REFRESH_SECONDS
    ):
        _token_state["current"] = {
            "token": shared["token"],
            "error": "",
            "issued_at": shared["issued_at"],
            "fingerprint": fingerprint,
        }
        return shared["token"]

    try:
        token = jwt.encode(
            {"iss": team_id, "iat": issued_at},
            auth_key,
            algorithm="ES256",
            headers={"kid": key_id},
        )
    except Exception as exc:
        message = f"APNs auth key is not usable: {exc}"
        _token_state["current"] = {
            "token": None,
            "error": message,
            "issued_at": issued_at,
            "fingerprint": fingerprint,
        }
        logger.error(message)
        raise ValueError(message) from exc
    _token_state["current"] = {
        "token": token,
        "error": "",
        "issued_at": issued_at,
        "fingerprint": fingerprint,
    }
    _write_shared_token(fingerprint, token, issued_at)
    return token


def _reason(response: httpx.Response) -> str:
    try:
        body = response.json()
    except ValueError:
        return ""
    if isinstance(body, dict):
        return str(body.get("reason", ""))
    return ""


def _dead_before(response: httpx.Response) -> str | None:
    try:
        body = response.json()
    except ValueError:
        return None
    if not isinstance(body, dict):
        return None
    raw = body.get("timestamp")
    if not isinstance(raw, (int, float)) or isinstance(raw, bool):
        return None
    try:
        return datetime.fromtimestamp(raw / 1000, tz=timezone.utc).isoformat()
    except (OverflowError, OSError, ValueError):
        return None


class ApnsProvider(PushProvider):
    name = "apns"
    label = PROVIDER_LABEL
    config_fields = [
        ConfigField(
            TEAM_ID_KEY,
            "Team ID",
            type="str",
            default="",
            help="Ten character Apple Developer team identifier, used as the token iss claim.",
            group=PROVIDER_LABEL,
        ),
        ConfigField(
            KEY_ID_KEY,
            "Key ID",
            type="str",
            default="",
            help="Ten character identifier of the APNs auth key, sent as the token kid header.",
            group=PROVIDER_LABEL,
        ),
        ConfigField(
            AUTH_KEY_KEY,
            "Auth key (.p8)",
            type="text",
            default="",
            secret=True,
            help="Contents of the APNs .p8 signing key, including the BEGIN and END lines. Leave blank to keep the stored key.",
            group=PROVIDER_LABEL,
        ),
        ConfigField(
            TOPIC_KEY,
            "Topic",
            type="str",
            default="",
            help="Bundle identifier of the receiving app, sent as the apns-topic header.",
            group=PROVIDER_LABEL,
        ),
        ConfigField(
            ENVIRONMENT_KEY,
            "Environment",
            type="select",
            default=DEFAULT_ENVIRONMENT,
            options=ENVIRONMENT_OPTIONS,
            help="Production delivers to App Store builds, sandbox to development builds.",
            group=PROVIDER_LABEL,
        ),
    ]

    def is_configured(self) -> bool:
        return bool(
            _setting(TEAM_ID_KEY)
            and _setting(KEY_ID_KEY)
            and _setting(AUTH_KEY_KEY)
            and _setting(TOPIC_KEY)
        )

    def client_config(self) -> dict[str, Any]:
        return {"environment": _environment()}

    def stamp_registration(self, fields: dict[str, Any]) -> dict[str, Any]:
        return {**fields, "environment": _environment()}

    def delivery_client(self, timeout: float) -> httpx.AsyncClient:
        return cached_client(timeout)

    def closes_delivery_client(self) -> bool:
        return False

    async def aclose(self) -> None:
        await close_client()

    def parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
        token = body.get("token")
        if not isinstance(token, str):
            return None
        token = _normalize_token(token)
        if not TOKEN_MIN_LENGTH <= len(token) <= TOKEN_MAX_LENGTH:
            return None
        fields: dict[str, Any] = {"token": token}
        client_id = body.get("client_id")
        if client_id is None:
            return fields
        if not isinstance(client_id, str):
            return None
        client_id = client_id.strip()
        if not client_id:
            return fields
        if len(client_id) > CLIENT_ID_MAX_LENGTH:
            return None
        fields["client_id"] = client_id
        return fields

    def prepare(self, payload: dict[str, Any]) -> str:
        return json.dumps(
            {
                "aps": {
                    "alert": {
                        "title": payload.get("title") or "DevPlace",
                        "body": payload.get("message") or "",
                    },
                    "sound": "default",
                    "thread-id": THREAD_ID,
                },
                "url": payload.get("url") or DEFAULT_PUSH_URL,
                "icon": payload.get("icon") or PUSH_ICON,
            }
        )

    def headers(self) -> dict[str, str]:
        return {
            "authorization": f"bearer {provider_token(_setting(TEAM_ID_KEY), _setting(KEY_ID_KEY), _setting(AUTH_KEY_KEY))}",
            "apns-topic": _setting(TOPIC_KEY),
            "apns-push-type": PUSH_TYPE,
            "apns-priority": PRIORITY,
            "apns-expiration": str(int(time.time()) + SECONDS_PER_DAY),
            "apns-id": generate_uid(),
            "content-type": "application/json",
        }

    async def deliver(
        self, client: httpx.AsyncClient, registration: dict[str, Any], prepared: str
    ) -> Delivery:
        token = (registration.get("token") or "").strip()
        if not token:
            return Delivery(DEAD, "missing device token")
        try:
            headers = self.headers()
            response = await client.post(
                f"https://{host_for(registration.get('environment'))}/3/device/{token}",
                headers=headers,
                content=prepared.encode("utf-8"),
            )
        except (httpx.HTTPError, ValueError) as exc:
            return Delivery(REJECTED, str(exc))
        if response.status_code == 200:
            return Delivery(ACCEPTED)
        reason = _reason(response)
        detail = f"{response.status_code} {reason}".strip()
        if response.status_code == 403 or reason in AUTH_REASONS:
            invalidate_provider_token()
        if response.status_code == 410 or reason in DEAD_REASONS:
            dead_before = _dead_before(response) if response.status_code == 410 else None
            return Delivery(DEAD, detail, dead_before)
        return Delivery(REJECTED, detail)
