package wang.yaojia.webterm.nav

import wang.yaojia.webterm.wire.Validation
import java.net.URI

/**
 * # DeepLinkRouter (A32) — the ONE deep-link validation surface.
 *
 * Android analogue of iOS `DeepLinkRouter` (`ios/App/WebTerm/DeepLinkRouter.swift`). Three external
 * entry points share ONE whitelist parser so there is never a second, drift-prone regex:
 *
 *  - `webterminal://open?host=<uuid>&join=<uuid>` — the **custom scheme** (registered by the
 *    `<intent-filter>` on the launcher Activity), and
 *  - `https://<APP_LINK_HOST>/open?host=<uuid>&join=<uuid>` — the **verified App Link** (an
 *    `autoVerify=true` `<intent-filter>`, gated by a `assetlinks.json` deploy artifact), and
 *  - the FCM push-tap payload `{sessionId,…}` (A30 reuses [route] — same UUID rules, no second parser).
 *
 * ## Security contract (plan §8 "App Links", §1 deep-links row)
 * A deep link is EXTERNAL, untrusted input. Every field is whitelist-validated: scheme / action /
 * verified host / query keys, and BOTH ids as v4 UUIDs through the frozen [Validation.isValidSessionId]
 * (never re-regexed here — M7). ANY invalid, ambiguous (duplicate key), or missing part collapses the
 * whole link to [DeepLinkRoute.Ignore] — it is **never partially applied** (a valid host with a bad
 * session id does NOT open the host). Ignores are tallied by [DeepLinkTally], never echoed (the URL is
 * untrusted — log the counter only, never the link).
 *
 * ## Purity
 * PURE Kotlin/JVM — parses with `java.net.URI` (stdlib, not an Android import), exactly like
 * [wang.yaojia.webterm.wire.HostEndpoint]. The caller (the launcher Activity / the A30 push tap)
 * converts the platform `android.net.Uri`/`RemoteMessage.data` to a `String`/`Map` and hands it here,
 * so this whole surface is JVM-unit-testable with no device. Host EXISTENCE (is this a paired host?)
 * is resolved LATER against the `HostStore`, never here — the router only proves syntactic validity.
 */
public object DeepLinkRouter {

    /** Custom-scheme name — case-insensitive per RFC 3986, compared lowercased. */
    public const val SCHEME: String = "webterminal"

    /** Custom-scheme action (the URI "host" of `webterminal://open`). */
    public const val ACTION: String = "open"

    /**
     * The verified App-Links domain. `assetlinks.json` must be served over HTTPS (no redirect) at
     * `https://<APP_LINK_HOST>/.well-known/assetlinks.json` carrying the RELEASE signing-cert SHA-256
     * (a deploy artifact, plan §8). The tunnel presents a real LE cert for this host.
     */
    public const val APP_LINK_HOST: String = "terminal.yaojia.wang"

    /** The App-Links path (the https analogue of the custom-scheme [ACTION]). */
    public const val APP_LINK_PATH: String = "/open"

    /** Query key carrying the paired-host id (a v4 UUID resolved against the store later). */
    public const val QUERY_HOST: String = "host"

    /** Query key carrying the session id to join (a v4 UUID). */
    public const val QUERY_JOIN: String = "join"

    /** Push-payload key carrying the gate's session id (payload minimized to `sessionId/cls/token`, §8). */
    public const val PUSH_SESSION_ID: String = "sessionId"

    /** URI paths accepted for the custom scheme (`webterminal://open` with an empty or root path). */
    private val EMPTY_PATHS: Set<String> = setOf("", "/")

    /** URI paths accepted for the App Link (`/open`, with or without a trailing slash). */
    private val APP_LINK_PATHS: Set<String> = setOf(APP_LINK_PATH, "$APP_LINK_PATH/")

    /**
     * Parse an incoming deep-link URI string. Handles BOTH the custom scheme and the verified App Link;
     * returns [DeepLinkRoute.OpenSession] only when the shape is whitelisted AND both ids are v4 UUIDs,
     * else [DeepLinkRoute.Ignore]. Never throws — a malformed URI is caught and ignored.
     */
    public fun route(uri: String): DeepLinkRoute {
        val parsed = try {
            URI(uri)
        } catch (_: Exception) {
            return DeepLinkRoute.Ignore
        }
        if (!isWhitelistedShape(parsed)) return DeepLinkRoute.Ignore

        val query = parseQuery(parsed.rawQuery)
        val hostId = uniqueValidatedId(query, QUERY_HOST) ?: return DeepLinkRoute.Ignore
        val sessionId = uniqueValidatedId(query, QUERY_JOIN) ?: return DeepLinkRoute.Ignore
        return DeepLinkRoute.OpenSession(hostId = hostId, sessionId = sessionId)
    }

    /**
     * Parse an FCM push-tap payload (A30). The payload is minimized to `sessionId/cls/token`; only a
     * v4-valid `sessionId` yields [DeepLinkRoute.GateSession] — there is no host id in the payload
     * (A30's notification handler owns host resolution). Anything else → [DeepLinkRoute.Ignore].
     */
    public fun route(pushData: Map<String, String>): DeepLinkRoute {
        val sessionId = pushData[PUSH_SESSION_ID]?.let(::validatedId) ?: return DeepLinkRoute.Ignore
        return DeepLinkRoute.GateSession(sessionId = sessionId)
    }

    /** True when [uri] matches the custom-scheme OR the verified-App-Link shape (scheme/host/path). */
    private fun isWhitelistedShape(uri: URI): Boolean {
        val scheme = uri.scheme?.lowercase() ?: return false
        val host = uri.host?.lowercase() ?: return false
        val path = uri.path ?: ""
        return when (scheme) {
            SCHEME -> host == ACTION && path in EMPTY_PATHS
            "https" -> host == APP_LINK_HOST && path in APP_LINK_PATHS
            else -> false
        }
    }

    /**
     * Exactly ONE occurrence of [key], and its value is a v4 UUID. Zero occurrences (missing key) or
     * duplicates (ambiguous input) → null, so the link is ignored rather than partially applied
     * (mirrors iOS `uniqueValidatedId`).
     */
    private fun uniqueValidatedId(query: Map<String, List<String>>, key: String): String? {
        val values = query[key] ?: return null
        if (values.size != 1) return null
        return validatedId(values.first())
    }

    /**
     * The frozen-contract v4 check FIRST ([Validation.isValidSessionId], M7). Returns the canonical
     * (unchanged) id string when valid, else null. UUIDs never need percent-decoding, so a value that
     * is not a bare v4 UUID (encoded, empty, garbage) simply fails the whitelist → ignored.
     */
    private fun validatedId(raw: String): String? = if (Validation.isValidSessionId(raw)) raw else null

    /**
     * Split a raw query string (`a=1&b=2&a=3`) into name → ordered values. Preserves duplicates so
     * [uniqueValidatedId] can reject ambiguous links. A segment without `=` maps to an empty value; an
     * empty/blank name is dropped. Values are NOT decoded (see [validatedId]).
     */
    private fun parseQuery(rawQuery: String?): Map<String, List<String>> {
        if (rawQuery.isNullOrEmpty()) return emptyMap()
        val out = LinkedHashMap<String, MutableList<String>>()
        for (segment in rawQuery.split("&")) {
            if (segment.isEmpty()) continue
            val eq = segment.indexOf('=')
            val name = if (eq >= 0) segment.substring(0, eq) else segment
            val value = if (eq >= 0) segment.substring(eq + 1) else ""
            if (name.isEmpty()) continue
            out.getOrPut(name) { mutableListOf() }.add(value)
        }
        return out
    }
}

/**
 * A deep-link parse outcome carrying RAW, syntactically-validated ids (host existence is resolved later
 * against the `HostStore`, never here). Mirrors iOS `DeepLinkRouter.Route`.
 */
public sealed interface DeepLinkRoute {
    /** `webterminal://open` / verified App Link with both ids valid v4 → open [sessionId] on [hostId]. */
    public data class OpenSession(val hostId: String, val sessionId: String) : DeepLinkRoute

    /** Push-tap with a valid [sessionId] (no host id in the payload; A30 resolves the host). */
    public data class GateSession(val sessionId: String) : DeepLinkRoute

    /** Non-whitelisted / ambiguous / invalid / missing input. Never partially applied; tallied. */
    public data object Ignore : DeepLinkRoute
}

/**
 * Immutable tally of invalid deep links dropped (the Android analogue of iOS
 * `DeepLinkHandler.ignoredCount`). Kept as a pure reducer so counting stays side-effect-free and
 * JVM-testable; the app holds one instance in its state and swaps in the [record] result. The dropped
 * URL is NEVER stored — only the counter — because the link is untrusted external input (§8).
 */
public data class DeepLinkTally(val ignored: Int = 0) {
    /** Return a new tally: +1 when [route] is [DeepLinkRoute.Ignore], otherwise unchanged. */
    public fun record(route: DeepLinkRoute): DeepLinkTally =
        if (route is DeepLinkRoute.Ignore) copy(ignored = ignored + 1) else this
}
