package net.hivetechnology.o20010_06.vpn

import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.VpnService
import android.os.IBinder
import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.SystemClock
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.getSystemService
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.hivetechnology.o20010_06.MainActivity
import net.hivetechnology.o20010_06.R
import net.hivetechnology.o20010_06.api.ApiException
import net.hivetechnology.o20010_06.api.MobileVpnApi
import net.hivetechnology.o20010_06.model.SessionCredentials
import net.hivetechnology.o20010_06.model.SessionStatus
import net.hivetechnology.o20010_06.model.VpnConfiguration
import net.hivetechnology.o20010_06.storage.SecureSessionStore
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean

class HtVpnService : VpnService() {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    private val api by lazy { MobileVpnApi(fallbackDeviceName = getString(R.string.generic_device)) }
    private lateinit var store: SecureSessionStore
    private lateinit var connectivity: ConnectivityManager
    private val lifecycleMutex = Mutex()
    private var connectionJob: Job? = null
    private var reconnectJob: Job? = null
    private var tunnel: ParcelFileDescriptor? = null
    private var nativeHandle = 0L
    private var sessionSocket: SessionEventSocket? = null
    private var runtimeSocket: RuntimeSocket? = null
    private var transport: String? = null
    private var transportFloor = 0
    private val stopping = AtomicBoolean(false)
    private val tunnelReady = AtomicBoolean(false)
    private var physicalNetwork: Network? = null
    private val physicalNetworkRequest = NetworkRequest.Builder()
        .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
        .build()

    private val networkCallback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) {
            val capabilities = connectivity.getNetworkCapabilities(network) ?: return
            if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return
            val changed = physicalNetwork != null && physicalNetwork != network
            physicalNetwork = network
            if (changed) scheduleReconnect(getString(R.string.network_changed))
        }

        override fun onLost(network: Network) {
            if (physicalNetwork == network) {
                physicalNetwork = null
                scheduleReconnect(getString(R.string.network_unavailable))
            }
        }
    }

    override fun onCreate() {
        super.onCreate()
        store = SecureSessionStore(this)
        connectivity = getSystemService()!!
        createNotificationChannel()
        connectivity.registerNetworkCallback(physicalNetworkRequest, networkCallback)
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        if (intent?.action == ACTION_STOP) {
            stopByUser()
            return START_NOT_STICKY
        }
        val foregroundError = runCatching {
            ServiceCompat.startForeground(
                this,
                NOTIFICATION_ID,
                notification(false, getString(R.string.preparing_vpn)),
                ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST,
            )
        }.exceptionOrNull()
        if (foregroundError != null) {
            failServiceStartup(foregroundError)
            return START_NOT_STICKY
        }
        startOrResume()
        return START_STICKY
    }

    override fun onBind(intent: Intent?): IBinder? = super.onBind(intent)

    private fun startOrResume() {
        if (connectionJob?.isActive == true) return
        val credentials = store.load()
        if (credentials == null) {
            stopSelf()
            return
        }
        stopping.set(false)
        transportFloor = 0
        connectionJob = scope.launch {
            lifecycleMutex.withLock { runSession(credentials) }
        }
    }

    private fun failServiceStartup(error: Throwable) {
        val credentials = store.load()
        val reason = error.localizedMessage?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName
        val detail = getString(R.string.vpn_service_start_failed, reason)
        Log.e(TAG, "Unable to promote VPN service to the foreground", error)
        store.clear()
        VpnState.update(
            VpnUiState(
                "failed",
                credentials?.deviceName.orEmpty(),
                detail,
                errorCode = "ERR_ANDROID_VPN_FOREGROUND_START",
            ),
        )
        stopSelf()
    }

    private suspend fun runSession(credentials: SessionCredentials) {
        var packetJobs = emptyList<Job>()
        var terminalError: String? = null
        VpnState.update(
            VpnUiState("waiting-runtime", credentials.deviceName, getString(R.string.waiting_for_runtime), active = true),
        )
        sessionSocket = SessionEventSocket(credentials) { session ->
            if (session.status in setOf(SessionStatus.STOPPED, SessionStatus.FAILED, SessionStatus.EXPIRED)) {
                scope.launch { finishLocally(session.errorMessage ?: getString(R.string.session_ended)) }
            }
        }.also { it.connect() }

        try {
            api.start(credentials)
            val configuration = awaitConfiguration(credentials)
            api.update(credentials, SessionStatus.CONNECTING, transport = "unknown")
            VpnState.update(
                VpnUiState(
                    "connecting",
                    credentials.deviceName,
                    getString(R.string.connecting_to_device),
                    active = true,
                ),
            )
            updateNotification(false, getString(R.string.connecting_to_named_device, credentials.deviceName))

            tunnel = establishTunnel(configuration)
                ?: throw IOException(getString(R.string.vpn_interface_failed))
            val attempts = transportAttempts(configuration)
                .filter { it.rank >= transportFloor }
            val selection = connectEdge(configuration, attempts, 0)
            nativeHandle = selection.handle
            transport = selection.attempt.mode
            transportFloor = maxOf(transportFloor, selection.attempt.rank)
            packetJobs = startPacketPumps(nativeHandle, tunnel!!)
            monitorRuntime(credentials, configuration, selection)
        } catch (cancelled: CancellationException) {
            throw cancelled
        } catch (error: Throwable) {
            if (!stopping.get()) {
                runCatching {
                    api.update(
                        credentials,
                        SessionStatus.FAILED,
                        transport = transport,
                        errorCode = (error as? ApiException)?.errorCode ?: "ERR_MOBILE_VPN_CLIENT",
                        errorMessage = userMessage(error).take(400),
                    )
                }
                if (stopping.compareAndSet(false, true)) {
                    terminalError = userMessage(error)
                }
            }
        } finally {
            withContext(NonCancellable) {
                packetJobs.forEach { it.cancel() }
                tunnel?.close()
                tunnel = null
                packetJobs.joinAll()
                cleanupTunnel()
            }
        }
        terminalError?.let { detail ->
            store.clear()
            VpnState.update(VpnUiState("failed", detail = detail))
            stopForeground(STOP_FOREGROUND_REMOVE)
            stopSelf()
        }
    }

    private suspend fun awaitConfiguration(credentials: SessionCredentials): VpnConfiguration {
        repeat(120) {
            try {
                return api.configuration(credentials)
            } catch (error: ApiException) {
                if (error.errorCode != "ERR_MOBILE_VPN_CONFIG_NOT_READY") throw error
            }
            delay(1_000)
        }
        throw IOException(getString(R.string.runtime_not_ready))
    }

    private fun establishTunnel(configuration: VpnConfiguration): ParcelFileDescriptor? {
        val builder = Builder()
            .setSession("HT Portal · ${configuration.runtime.host}")
            .setMtu(configuration.overlay.mtu)
            .addAddress(configuration.overlay.address, configuration.overlay.prefixLength)
            .setBlocking(false)
        val routePlan = RoutePlanner.apply(builder, configuration, connectivity)
        val detail = if (routePlan.skipped.isEmpty()) {
            resources.getQuantityString(
                R.plurals.protected_routes,
                routePlan.routes.size,
                routePlan.routes.size,
            )
        } else {
            resources.getQuantityString(
                R.plurals.skipped_local_routes,
                routePlan.skipped.size,
                routePlan.skipped.size,
            )
        }
        VpnState.update(VpnState.current.value.copy(detail = detail))
        return builder.establish()
    }

    private suspend fun connectEdge(
        configuration: VpnConfiguration,
        attempts: List<TransportAttempt>,
        startIndex: Int,
    ): EdgeSelection {
        var registeredWithSupernode = false
        for (index in startIndex until attempts.size) {
            val attempt = attempts[index]
            val mode = attempt.mode
            transport = mode
            val handle = NativeBridge.create(
                this,
                attempt.supernode,
                configuration.n3n.community,
                configuration.n3n.key,
                configuration.overlay.address,
                configuration.overlay.serverAddress,
                configuration.overlay.prefixLength,
                configuration.overlay.mtu,
                attempt.nativeTransport,
            )
            if (handle == 0L || NativeBridge.start(handle) != 0) {
                if (handle != 0L) NativeBridge.destroy(handle)
                continue
            }
            val timeoutMs = if (mode == "tcp-relay") 12_000L else 8_000L
            val started = System.currentTimeMillis()
            while (System.currentTimeMillis() - started < timeoutMs) {
                when (NativeBridge.state(handle)) {
                    NativeBridge.STATE_CONNECTED -> {
                        registeredWithSupernode = true
                        val peerTimeoutMs = if (mode == "udp-p2p" || mode == "udp-adaptive") 3_000 else 5_000
                        VpnState.update(
                            VpnState.current.value.copy(
                                detail = getString(R.string.checking_runtime_connection),
                                transport = mode,
                            ),
                        )
                        val resolutionStarted = SystemClock.elapsedRealtime()
                        val resolution = NativeBridge.resolvePeer(
                            handle,
                            configuration.overlay.serverAddress,
                            peerTimeoutMs,
                        )
                        val resolutionMs = SystemClock.elapsedRealtime() - resolutionStarted
                        Log.i(
                            TAG,
                            "n3n peer resolution mode=$mode result=$resolution elapsed=${resolutionMs}ms",
                        )
                        if (resolution == 1) return EdgeSelection(handle, index, attempt)
                        break
                    }
                    NativeBridge.STATE_FAILED, NativeBridge.STATE_STOPPED -> break
                }
                delay(200)
            }
            NativeBridge.stop(handle)
            NativeBridge.destroy(handle)
        }
        throw IOException(
            if (registeredWithSupernode) {
                getString(R.string.supernode_runtime_unreachable)
            } else {
                getString(R.string.supernode_unreachable)
            },
        )
    }

    private fun transportAttempts(configuration: VpnConfiguration): List<TransportAttempt> =
        configuration.n3n.transportOrder.flatMap { mode ->
            configuration.n3n.supernodes.distinct().map { supernode ->
                TransportAttempt(supernode, mode)
            }
        }

    private fun startPacketPumps(handle: Long, descriptor: ParcelFileDescriptor): List<Job> {
        val input = FileInputStream(descriptor.fileDescriptor)
        val output = FileOutputStream(descriptor.fileDescriptor)
        val fromTun = scope.launch {
            val packet = ByteArray(65_535)
            while (isActive) {
                val length = try {
                    input.read(packet)
                } catch (_: IOException) {
                    if (isActive) delay(5)
                    continue
                }
                if (length <= 0) {
                    delay(5)
                    continue
                }
                if (NativeBridge.send(handle, packet, length) != 0) delay(5)
            }
        }
        val toTun = scope.launch {
            while (isActive) {
                NativeBridge.receive(handle, 65_535, 500)?.let {
                    try {
                        output.write(it)
                    } catch (_: IOException) {
                        if (isActive) delay(5)
                    }
                }
            }
        }
        return listOf(fromTun, toTun)
    }

    private suspend fun monitorRuntime(
        credentials: SessionCredentials,
        configuration: VpnConfiguration,
        selection: EdgeSelection,
    ) {
        while (scope.isActive && !stopping.get()) {
            val started = System.nanoTime()
            val socket = RuntimeSocket(configuration.runtime)
            runtimeSocket = socket
            if (socket.connect()) {
                val latency = (System.nanoTime() - started) / 1_000_000
                if (selection.attempt.mode == "udp-p2p" || selection.attempt.mode == "udp-adaptive") {
                    verifyP2pPath(selection.handle, socket)
                }
                api.update(credentials, SessionStatus.CONNECTED, transport = transport, quality = "good", latencyMs = latency)
                tunnelReady.set(true)
                VpnState.update(
                    VpnUiState(
                        "connected",
                        credentials.deviceName,
                        getString(R.string.secure_connection_active),
                        transport,
                        latency,
                        true,
                    ),
                )
                updateNotification(true, getString(R.string.connected_to_device, credentials.deviceName))
                socket.awaitDisconnect()
            }
            socket.close()
            runtimeSocket = null
            if (stopping.get()) return
            val detail = runtimeSocketReconnectDetail(socket.lastError)
            api.update(credentials, SessionStatus.RECONNECTING, transport = transport, quality = "weak")
            VpnState.update(
                VpnUiState(
                    if (tunnelReady.get()) "connected" else "connecting",
                    credentials.deviceName,
                    detail,
                    transport,
                    active = true,
                ),
            )
            delay(1_000L)
        }
    }

    private suspend fun verifyP2pPath(handle: Long, socket: RuntimeSocket) {
        var confirmations = 0
        var relayObservations = 0
        repeat(7) { check ->
            val localPath = NativeBridge.peerPath(handle)
            val remotePath = socket.pathObservation()
            if (localPath == NativeBridge.PEER_PATH_RELAY || remotePath == "relay") {
                relayObservations += 1
                confirmations = 0
            } else if (localPath == NativeBridge.PEER_PATH_P2P && remotePath == "p2p") {
                confirmations += 1
                if (confirmations >= 2) return
            } else {
                confirmations = 0
            }
            if (check < 6) delay(2_000L)
        }
        if (relayObservations >= 2) {
            transport = "udp-relay"
            transportFloor = maxOf(transportFloor, 1)
            Log.i(TAG, "n3n is carrying the working VPN through its UDP relay")
            return
        }
        Log.w(TAG, "P2P path verification was inconclusive; keeping the working VPN edge")
    }

    private fun runtimeSocketReconnectDetail(error: String?): String {
        val cleanError = error?.trim()?.takeIf { it.isNotEmpty() }?.take(180)
        return if (cleanError == null) {
            getString(R.string.recovering_connection)
        } else {
            getString(R.string.recovering_connection_detail, cleanError)
        }
    }

    private fun scheduleReconnect(reason: String) {
        if (!tunnelReady.get() || connectionJob?.isActive != true || reconnectJob?.isActive == true || stopping.get()) return
        reconnectJob = scope.launch {
            try {
                delay(1_500)
                if (stopping.get()) return@launch
                val credentials = store.load() ?: return@launch
                runCatching { api.update(credentials, SessionStatus.RECONNECTING, transport = transport, quality = "weak") }
                VpnState.update(
                    VpnUiState(
                        "connected",
                        credentials.deviceName,
                        reason,
                        transport,
                        active = true,
                    ),
                )
                connectionJob?.cancel()
                connectionJob?.join()
                if (!stopping.get()) {
                    connectionJob = scope.launch { lifecycleMutex.withLock { runSession(credentials) } }
                }
            } finally {
                reconnectJob = null
            }
        }
    }

    private fun stopByUser() {
        if (!stopping.compareAndSet(false, true)) return
        scope.launch {
            val credentials = store.load()
            reconnectJob?.cancel()
            reconnectJob?.join()
            connectionJob?.cancel()
            connectionJob?.join()
            cleanupTunnel()
            if (credentials != null) runCatching { api.stop(credentials) }
            store.clear()
            VpnState.update(
                VpnUiState(
                    "stopped",
                    credentials?.deviceName.orEmpty(),
                    getString(R.string.vpn_disconnected),
                ),
            )
            stopForeground(STOP_FOREGROUND_REMOVE)
            stopSelf()
        }
    }

    private suspend fun finishLocally(detail: String, cancelConnection: Boolean = true) {
        if (!stopping.compareAndSet(false, true)) return
        reconnectJob?.cancel()
        if (cancelConnection) {
            reconnectJob?.join()
            connectionJob?.cancel()
            connectionJob?.join()
        }
        cleanupTunnel()
        store.clear()
        VpnState.update(VpnUiState("failed", detail = detail))
        stopForeground(STOP_FOREGROUND_REMOVE)
        stopSelf()
    }

    @Synchronized
    private fun cleanupTunnel() {
        tunnelReady.set(false)
        runtimeSocket?.close()
        runtimeSocket = null
        sessionSocket?.close()
        sessionSocket = null
        if (nativeHandle != 0L) {
            NativeBridge.stop(nativeHandle)
            NativeBridge.destroy(nativeHandle)
            nativeHandle = 0L
        }
        tunnel?.close()
        tunnel = null
    }

    @Synchronized
    private fun stopNativeEdge() {
        if (nativeHandle == 0L) return
        NativeBridge.stop(nativeHandle)
        NativeBridge.destroy(nativeHandle)
        nativeHandle = 0L
    }

    private fun transportLabel(mode: String): String = when (mode) {
        "udp-adaptive" -> getString(R.string.transport_udp_adaptive)
        "udp-relay" -> getString(R.string.transport_udp_relay)
        "tcp-relay" -> getString(R.string.transport_tcp_relay)
        else -> getString(R.string.transport_udp_p2p)
    }

    private fun createNotificationChannel() {
        getSystemService<NotificationManager>()?.createNotificationChannel(
            NotificationChannel(CHANNEL_ID, getString(R.string.vpn_notification_channel), NotificationManager.IMPORTANCE_LOW),
        )
    }

    private fun userMessage(error: Throwable): String = when (error) {
        is InvalidOverlayAddressException -> getString(R.string.invalid_overlay_address)
        is VpnNetworkConflictException -> getString(
            R.string.vpn_network_conflict,
            error.address,
            error.prefixLength,
        )
        else -> error.message ?: getString(R.string.connection_failed)
    }

    private fun notification(connected: Boolean, text: String): Notification {
        val openIntent = PendingIntent.getActivity(
            this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
        )
        val stopIntent = PendingIntent.getService(
            this, 1, Intent(this, HtVpnService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
        )
        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_vpn_notification)
            .setContentTitle(if (connected) getString(R.string.vpn_connected) else getString(R.string.vpn_connecting))
            .setContentText(text)
            .setContentIntent(openIntent)
            .setOngoing(true)
            .setOnlyAlertOnce(true)
            .addAction(0, getString(R.string.vpn_disconnect), stopIntent)
            .build()
    }

    private fun updateNotification(connected: Boolean, text: String) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
            ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
        ) return
        runCatching { NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification(connected, text)) }
    }

    override fun onDestroy() {
        runCatching { connectivity.unregisterNetworkCallback(networkCallback) }
        reconnectJob?.cancel()
        connectionJob?.cancel()
        // runSession owns the native edge and tears it down after its packet
        // pumps have stopped, even when this service scope is cancelled.
        scope.cancel()
        super.onDestroy()
    }

    override fun onRevoke() {
        stopByUser()
        super.onRevoke()
    }

    companion object {
        const val ACTION_START = "net.hivetechnology.o20010_06.START"
        const val ACTION_STOP = "net.hivetechnology.o20010_06.STOP"
        private const val TAG = "HtVpnService"
        private const val CHANNEL_ID = "ht_portal_vpn"
        private const val NOTIFICATION_ID = 1401

        fun start(context: Context): Result<Unit> = runCatching {
            ContextCompat.startForegroundService(
                context,
                Intent(context, HtVpnService::class.java).setAction(ACTION_START),
            )
            Unit
        }

        fun stop(context: Context) {
            context.startService(Intent(context, HtVpnService::class.java).setAction(ACTION_STOP))
        }
    }
}

private data class TransportAttempt(
    val supernode: String,
    val mode: String,
) {
    val rank: Int
        get() = when (mode) {
            "udp-relay" -> 1
            "tcp-relay" -> 2
            else -> 0
        }

    val nativeTransport: Int
        get() = when (mode) {
            "udp-relay" -> NativeBridge.TRANSPORT_UDP_RELAY
            "tcp-relay" -> NativeBridge.TRANSPORT_TCP_RELAY
            else -> NativeBridge.TRANSPORT_UDP_P2P
        }
}

private data class EdgeSelection(
    val handle: Long,
    val index: Int,
    val attempt: TransportAttempt,
)
