package wang.yaojia.webterm.push

import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wiring.ApiClientFactory
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.cancellation.CancellationException

/**
 * The narrow per-host push-registration surface [PushRegistrar] drives (plan §5 A31, §4.5). Backed
 * in production by [ApiClient] (`registerFcmToken`/`unregisterFcmToken` → `POST`/`DELETE
 * /push/fcm-token`, whose loose validator + `Origin` guard live server-side, A33); a hand fake stands
 * in for the JVM test. Keeping this a two-method seam — instead of coupling the registrar to
 * [ApiClient]'s construction (`HostEndpoint` + OkHttp) — is what lets the fan-out / self-heal logic be
 * unit-tested with no device or transport.
 */
public interface HostPushApi {
    /** `POST /push/fcm-token {token}` — idempotent upsert on the server (re-registering is safe). */
    public suspend fun register(token: String)

    /** `DELETE /push/fcm-token {token}` — idempotent even for a token the host never knew. */
    public suspend fun unregister(token: String)
}

/** Production [HostPushApi] over one host's [ApiClient]. The token is passed straight through to the
 *  POST/DELETE and is NEVER logged or retained here (plan §8 push-token discipline). */
public class ApiClientHostPushApi(private val api: ApiClient) : HostPushApi {
    override suspend fun register(token: String): Unit = api.registerFcmToken(token)

    override suspend fun unregister(token: String): Unit = api.unregisterFcmToken(token)
}

/**
 * Registers this device's FCM registration token with EVERY paired host so a walked-away phone is
 * reachable no matter which host is running the session that needs input (plan §5 A31, §4.5).
 *
 * ### Self-heal (the token must always be current on every host)
 * The token can rotate and the host set can change, so registration is re-driven on every relevant
 * edge — the caller (FcmService.onNewToken, the pairing flow, host-list removal, app start) invokes:
 *  - [registerAllHosts] — **app start** and **token rotation** (re-POST the current token to all hosts);
 *  - [registerHost] — a **newly paired host** (POST the current token to just that host);
 *  - [unregisterHost] — a **removed host** (DELETE the token from just that host).
 *
 * The current token is supplied by the caller (from `FirebaseMessaging.getToken()` or the
 * `onNewToken` callback) rather than held here — the registrar stays a stateless, immutable
 * coordinator that re-reads [HostStore] on every pass (no cached host list to drift).
 *
 * ### Best-effort fan-out
 * One host being unreachable (a walked-away device, a host that is off) must NOT block registering the
 * others. Each host is attempted independently; a non-cancellation failure is routed to [onError]
 * (host + throwable, **never the token**) and the loop continues. `CancellationException` is always
 * rethrown so structured-concurrency cancellation is honoured.
 *
 * ### `POST_NOTIFICATIONS` (API 33+) — a dependency, not a gate here
 * Registration succeeds regardless of the runtime notification permission: the token is what makes the
 * device *reachable*; the permission only gates whether an arriving push can be *shown*. So the
 * runtime prompt (with rationale) lives in the UI (device-QA), and this class never blocks on it. The
 * `POST_NOTIFICATIONS` manifest permission is already declared.
 *
 * ### Token discipline
 * The token is passed only to the POST/DELETE call and is never logged or persisted here; [onError]
 * deliberately receives no token so an error sink can never leak it (plan §8).
 */
@Singleton
public class PushRegistrar internal constructor(
    private val hosts: HostStore,
    private val apiFor: (Host) -> HostPushApi,
    private val onError: (Host, Throwable) -> Unit,
) {
    /**
     * Production constructor: mints a per-host [ApiClient] over the shared transport ([ApiClientFactory])
     * for each paired host. Failures are swallowed at the registrar boundary by default (best-effort
     * fan-out); a caller that wants observability supplies its own [onError] via the internal ctor.
     */
    @Inject
    public constructor(
        hosts: HostStore,
        apiClientFactory: ApiClientFactory,
    ) : this(
        hosts = hosts,
        apiFor = { host -> ApiClientHostPushApi(apiClientFactory.create(host.endpoint)) },
        onError = { _, _ -> },
    )

    /** POST the current [token] to EVERY paired host (app start + token rotation). Best-effort. */
    public suspend fun registerAllHosts(token: String) {
        for (host in hosts.loadAll()) {
            attempt(host) { register(token) }
        }
    }

    /** POST the current [token] to a single newly-paired [host]. Best-effort. */
    public suspend fun registerHost(host: Host, token: String) {
        attempt(host) { register(token) }
    }

    /** DELETE the [token] from a single [host] being removed. Best-effort. */
    public suspend fun unregisterHost(host: Host, token: String) {
        attempt(host) { unregister(token) }
    }

    private suspend fun attempt(host: Host, call: suspend HostPushApi.() -> Unit) {
        try {
            apiFor(host).call()
        } catch (cancellation: CancellationException) {
            throw cancellation
        } catch (error: Exception) {
            onError(host, error)
        }
    }
}
