# retoor <retoor@molodetz.nl>

import hashlib
import json
import logging
import string
import time
from typing import Any

import httpx
import jwt

from devplacepy.config import SECONDS_PER_DAY
from devplacepy.database import get_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
THREAD_ID = "devplace-notification"
PUSH_TYPE = "alert"
PRIORITY = "10"
DEAD_REASONS = frozenset(
    {
        "BadDeviceToken",
        "DeviceTokenNotForTopic",
        "ExpiredToken",
        "Unregistered",
        "TopicDisallowed",
    }
)

_token_state: dict[str, Any] = {}


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 HOSTS[_environment()]


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 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"]
    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,
    }
    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 ""


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 parse_registration(self, body: dict[str, Any]) -> dict[str, Any] | None:
        token = body.get("token")
        if not isinstance(token, str):
            return None
        token = token.strip()
        if not TOKEN_MIN_LENGTH <= len(token) <= TOKEN_MAX_LENGTH:
            return None
        if any(character not in string.hexdigits for character in token):
            return None
        return {"token": token}

    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()}/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 == 410 or reason in DEAD_REASONS:
            return Delivery(DEAD, detail)
        return Delivery(REJECTED, detail)
