package de.softwareapp_hb.privateqrscanner.util

import de.softwareapp_hb.privateqrscanner.domain.UrlRiskResult
import java.net.IDN
import java.net.URI
import java.net.URLDecoder
import java.util.Locale

object UrlRiskScorer {
    fun score(raw: String): UrlRiskResult {
        val trimmed = raw.trim()
        val uri = runCatching { URI(trimmed) }.getOrNull() ?: return UrlRiskResult(0, emptyList())
        val scheme = uri.scheme?.lowercase(Locale.US) ?: return UrlRiskResult(0, emptyList())
        val hostDetails = extractHost(uri)
        val host = hostDetails.asciiHost
        val labels = host.split('.').filter { it.isNotBlank() }
        val reasons = mutableListOf<String>()
        var score = 0

        fun add(points: Int, reason: String) {
            if (reason !in reasons) {
                score += points
                reasons += reason
            }
        }

        if (trimmed.any { it.isISOControl() || it.isWhitespace() }) {
            add(3, "Contains whitespace or control characters")
        }

        when (scheme) {
            "http" -> add(2, "URL uses HTTP")
            "https" -> Unit
            in HIGH_RISK_SCHEMES -> add(5, "Uses a potentially unsafe URL scheme")
            else -> add(2, "Uses a non-web URL scheme")
        }

        if (scheme in WEB_SCHEMES && hostDetails.rawHost.isBlank()) {
            add(3, "Web URL has no host")
        }

        if (!uri.userInfo.isNullOrBlank() || uri.rawAuthority.orEmpty().contains("@")) {
            add(2, "Contains userinfo")
        }

        if (PERCENT_ENCODING.containsMatchIn(hostDetails.rawHost)) {
            add(2, "Host contains percent-encoding")
        }

        if (hostDetails.hasPunycode) {
            add(2, "Host contains punycode")
        }

        if (hostDetails.hasUnicode) {
            add(2, "Host contains non-ASCII characters")
        }

        if (host.length > 40) {
            add(1, "Host is unusually long")
        }

        val ipv4 = parseIpv4(host)
        if (ipv4 != null) {
            add(2, "Host is an IP address")
            if (isPrivateOrReservedIpv4(ipv4)) {
                add(2, "Host is a private or reserved IP address")
            }
        } else if (isIpv6Literal(hostDetails.rawHost)) {
            add(2, "Host is an IP address")
            if (isPrivateOrReservedIpv6(hostDetails.rawHost)) {
                add(2, "Host is a private or reserved IP address")
            }
        } else if (host.isNotBlank()) {
            scoreDomainShape(host, labels, ::add)
            scoreKnownRiskyDomains(host, labels, ::add)
            scoreBrandImpersonation(host, labels, ::add)
        }

        scoreUrlStructure(trimmed, uri, host, ::add)

        return UrlRiskResult(score = score, reasons = reasons)
    }

    private fun scoreDomainShape(
        host: String,
        labels: List<String>,
        add: (Int, String) -> Unit
    ) {
        val registeredDomain = registrableDomain(labels)
        val registeredLabel = registeredDomain?.substringBefore('.').orEmpty()

        if (host == "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
            add(2, "Host points to a local name")
        }

        if (labels.size == 1 && host != "localhost") {
            add(1, "Host has no public suffix")
        }

        if (labels.size > 4) {
            add(1, "Host has many subdomains")
        }

        if (labels.any { it.length > 30 }) {
            add(1, "Host contains an unusually long label")
        }

        if (labels.any { it.contains('_') }) {
            add(1, "Host contains invalid hostname characters")
        }

        if (registeredLabel.count { it == '-' } >= 3) {
            add(1, "Registered domain contains many hyphens")
        }

        if (registeredLabel.length >= 10 && registeredLabel.count(Char::isDigit) * 100 / registeredLabel.length >= 35) {
            add(1, "Registered domain contains many digits")
        }

        if (labels.any { REPEATED_CHARACTER.containsMatchIn(it) }) {
            add(1, "Host contains repeated characters")
        }
    }

    private fun scoreKnownRiskyDomains(
        host: String,
        labels: List<String>,
        add: (Int, String) -> Unit
    ) {
        val tld = labels.lastOrNull().orEmpty()
        val registeredDomain = registrableDomain(labels)

        if (registeredDomain in URL_SHORTENERS) {
            add(2, "Host is a URL shortener")
        }

        if (tld in WATCHLIST_TLDS) {
            add(1, "Host uses a commonly abused top-level domain")
        }
    }

    private fun scoreBrandImpersonation(
        host: String,
        labels: List<String>,
        add: (Int, String) -> Unit
    ) {
        val registeredDomain = registrableDomain(labels)
        val labelsToInspect = labels.filterNot { it in PUBLIC_SUFFIX_PARTS }

        for (brand in BRAND_PROFILES) {
            if (brand.officialDomains.any { host == it || host.endsWith(".$it") }) continue

            val registeredLabel = registeredDomain?.substringBefore('.').orEmpty()
            if (registeredLabel == brand.name) continue

            val exactBrandLabel = labelsToInspect.any { it == brand.name }
            val embeddedBrand = labelsToInspect.any { label ->
                label != brand.name && label.contains(brand.name)
            }
            if (exactBrandLabel || embeddedBrand) {
                add(3, "Host embeds a known brand outside its official domain")
                return
            }

            val candidateLabels = (labelsToInspect + registeredLabel).filter { it.length >= 4 }.distinct()
            if (candidateLabels.any { isLookalikeBrandLabel(it, brand.name) }) {
                add(3, "Host resembles a known brand")
                return
            }
        }
    }

    private fun scoreUrlStructure(
        raw: String,
        uri: URI,
        host: String,
        add: (Int, String) -> Unit
    ) {
        val rawPath = uri.rawPath.orEmpty()
        val rawQuery = uri.rawQuery.orEmpty()
        val lowerPathAndQuery = "$rawPath?$rawQuery".lowercase(Locale.US)
        val lowerRaw = raw.lowercase(Locale.US)

        when {
            raw.length > 500 -> add(2, "URL is extremely long")
            raw.length > 250 -> add(1, "URL is unusually long")
        }

        if (uri.port != -1 && !isStandardPort(uri.scheme.orEmpty(), uri.port)) {
            add(1, "URL uses a non-standard port")
        }

        if (rawPath.length > 120) {
            add(1, "Path is unusually long")
        }

        if ((uri.rawQuery?.length ?: 0) > 120) {
            add(1, "Query is unusually long")
        }

        if (rawPath.split('/').count { it.isNotBlank() } > 8) {
            add(1, "Path has many segments")
        }

        val percentEncodedCount = PERCENT_ENCODING.findAll(raw).count()
        if (percentEncodedCount > 10) {
            add(1, "Many percent-encodings")
        }

        if (ENCODED_CONTROL_CHARACTER.containsMatchIn(raw)) {
            add(3, "URL contains encoded control characters")
        }

        if (raw.contains('\\')) {
            add(2, "URL contains backslashes")
        }

        if (rawPath.contains("//")) {
            add(1, "Path contains nested URL separators")
        }

        if (rawPath.contains('@') || rawQuery.contains('@')) {
            add(1, "Path or query contains an at-sign")
        }

        if (DANGEROUS_FILE_EXTENSIONS.any { lowerPathAndQuery.contains(it) }) {
            add(2, "URL points to a potentially executable file")
        } else if (ARCHIVE_FILE_EXTENSIONS.any { lowerPathAndQuery.contains(it) }) {
            add(1, "URL points to an archive file")
        }

        if (CREDENTIAL_KEYWORDS.any { lowerPathAndQuery.contains(it) }) {
            add(1, "URL contains account or credential keywords")
        }

        if (hasExternalRedirect(rawQuery, host)) {
            add(2, "Query redirects to another domain")
        }

        if (hasLongOpaqueToken(rawPath) || hasLongOpaqueToken(rawQuery)) {
            add(1, "URL contains a long opaque token")
        }

        if (lowerRaw.contains("%2f%2f") || lowerRaw.contains("%5c")) {
            add(1, "URL contains encoded path separators")
        }
    }

    private fun extractHost(uri: URI): HostDetails {
        val rawAuthority = uri.rawAuthority.orEmpty()
        val rawHostFromAuthority = if (rawAuthority.isBlank()) {
            ""
        } else {
            val withoutUserInfo = rawAuthority.substringAfterLast('@')
            when {
                withoutUserInfo.startsWith("[") -> withoutUserInfo.substringAfter('[').substringBefore(']')
                withoutUserInfo.count { it == ':' } == 1 &&
                    withoutUserInfo.substringAfterLast(':').all(Char::isDigit) ->
                    withoutUserInfo.substringBeforeLast(':')
                else -> withoutUserInfo
            }
        }
        val rawHost = (uri.host ?: rawHostFromAuthority)
            .trim()
            .trim('.')
            .replace('\u3002', '.')
            .replace('\uFF0E', '.')
            .replace('\uFF61', '.')
        val asciiHost = toAsciiHost(rawHost)
        return HostDetails(
            rawHost = rawHost,
            asciiHost = asciiHost,
            hasUnicode = rawHost.any { it.code > 127 },
            hasPunycode = asciiHost.split('.').any { it.startsWith("xn--") } ||
                rawHost.contains("xn--", ignoreCase = true)
        )
    }

    private fun toAsciiHost(rawHost: String): String {
        if (rawHost.isBlank()) return ""
        return rawHost
            .removeSurrounding("[", "]")
            .split('.')
            .filter { it.isNotBlank() }
            .joinToString(".") { label ->
                runCatching { IDN.toASCII(label, IDN.ALLOW_UNASSIGNED).lowercase(Locale.US) }
                    .getOrElse { label.lowercase(Locale.US) }
            }
    }

    private fun parseIpv4(host: String): IntArray? {
        if (!IPV4_PATTERN.matches(host)) return null
        val octets = host.split('.').mapNotNull { it.toIntOrNull() }
        if (octets.size != 4 || octets.any { it !in 0..255 }) return null
        return octets.toIntArray()
    }

    private fun isPrivateOrReservedIpv4(octets: IntArray): Boolean {
        val first = octets[0]
        val second = octets[1]
        val third = octets[2]
        return first == 0 ||
            first == 10 ||
            first == 127 ||
            first >= 224 ||
            (first == 100 && second in 64..127) ||
            (first == 169 && second == 254) ||
            (first == 172 && second in 16..31) ||
            (first == 192 && second == 168) ||
            (first == 192 && second == 0 && third == 0) ||
            (first == 192 && second == 0 && third == 2) ||
            (first == 198 && second in 18..19) ||
            (first == 198 && second == 51 && third == 100) ||
            (first == 203 && second == 0 && third == 113)
    }

    private fun isIpv6Literal(rawHost: String): Boolean {
        val host = rawHost.removeSurrounding("[", "]")
        return host.contains(':') && host.all {
            it == ':' || it == '.' || it.isDigit() || it.lowercaseChar() in 'a'..'f'
        }
    }

    private fun isPrivateOrReservedIpv6(rawHost: String): Boolean {
        val host = rawHost.removeSurrounding("[", "]").lowercase(Locale.US)
        return host == "::" ||
            host == "::1" ||
            host.startsWith("fc") ||
            host.startsWith("fd") ||
            host.startsWith("fe80") ||
            host.startsWith("2001:db8")
    }

    private fun registrableDomain(labels: List<String>): String? {
        if (labels.isEmpty()) return null
        if (labels.size == 1) return labels.first()
        val lastTwo = labels.takeLast(2).joinToString(".")
        return if (lastTwo in COMMON_SECOND_LEVEL_SUFFIXES && labels.size >= 3) {
            labels.takeLast(3).joinToString(".")
        } else {
            lastTwo
        }
    }

    private fun isLookalikeBrandLabel(label: String, brand: String): Boolean {
        if (label == brand) return false
        val normalized = normalizeLookalikes(label)
        if (normalized == brand) return true
        if (kotlin.math.abs(normalized.length - brand.length) > 1) return false
        return levenshteinDistance(normalized, brand) <= 1
    }

    private fun normalizeLookalikes(value: String): String {
        return value
            .replace('0', 'o')
            .replace('1', 'l')
            .replace('3', 'e')
            .replace('5', 's')
            .replace('7', 't')
            .replace("rn", "m")
    }

    private fun levenshteinDistance(left: String, right: String): Int {
        if (left == right) return 0
        if (left.isEmpty()) return right.length
        if (right.isEmpty()) return left.length

        var previous = IntArray(right.length + 1) { it }
        var current = IntArray(right.length + 1)
        for (i in left.indices) {
            current[0] = i + 1
            for (j in right.indices) {
                val substitutionCost = if (left[i] == right[j]) 0 else 1
                current[j + 1] = minOf(
                    current[j] + 1,
                    previous[j + 1] + 1,
                    previous[j] + substitutionCost
                )
            }
            val swap = previous
            previous = current
            current = swap
        }
        return previous[right.length]
    }

    private fun isStandardPort(scheme: String, port: Int): Boolean {
        return (scheme.equals("http", ignoreCase = true) && port == 80) ||
            (scheme.equals("https", ignoreCase = true) && port == 443)
    }

    private fun hasExternalRedirect(rawQuery: String, sourceHost: String): Boolean {
        if (rawQuery.isBlank()) return false
        return queryParams(rawQuery).any { (key, value) ->
            key in REDIRECT_PARAMETER_NAMES && value.startsWith("http", ignoreCase = true) &&
                runCatching {
                    val destinationHost = extractHost(URI(value)).asciiHost
                    val sourceDomain = registrableDomain(sourceHost.split('.').filter { it.isNotBlank() })
                    val destinationDomain = registrableDomain(destinationHost.split('.').filter { it.isNotBlank() })
                    destinationDomain != null && sourceDomain != null && destinationDomain != sourceDomain
                }.getOrDefault(true)
        }
    }

    private fun queryParams(rawQuery: String): List<Pair<String, String>> {
        return rawQuery
            .split('&', ';')
            .mapNotNull { token ->
                val key = token.substringBefore('=', "").takeIf { it.isNotBlank() } ?: return@mapNotNull null
                val value = token.substringAfter('=', "")
                decodeUrlComponent(key).lowercase(Locale.US) to decodeUrlComponent(value)
            }
    }

    private fun decodeUrlComponent(value: String): String {
        return runCatching { URLDecoder.decode(value, Charsets.UTF_8.name()) }.getOrDefault(value)
    }

    private fun hasLongOpaqueToken(value: String): Boolean {
        return value
            .split('/', '&', '=', '?', '-', '_')
            .any { token ->
                token.length >= 48 &&
                    token.toSet().size >= 8 &&
                    token.count { it.isLetterOrDigit() || it == '+' || it == '/' } * 100 / token.length >= 85
            }
    }

    private data class HostDetails(
        val rawHost: String,
        val asciiHost: String,
        val hasUnicode: Boolean,
        val hasPunycode: Boolean
    )

    private data class BrandProfile(
        val name: String,
        val officialDomains: Set<String>
    )

    private val WEB_SCHEMES = setOf("http", "https")
    private val HIGH_RISK_SCHEMES = setOf("javascript", "data", "file", "content", "intent")
    private val IPV4_PATTERN = Regex("""^\d{1,3}(\.\d{1,3}){3}$""")
    private val PERCENT_ENCODING = Regex("""%[0-9a-fA-F]{2}""")
    private val ENCODED_CONTROL_CHARACTER = Regex("""(?i)%(00|0a|0d|09)""")
    private val REPEATED_CHARACTER = Regex("""(.)\1{3,}""")

    private val COMMON_SECOND_LEVEL_SUFFIXES = setOf(
        "ac.uk",
        "co.in",
        "co.jp",
        "co.nz",
        "co.uk",
        "com.au",
        "com.br",
        "com.cn",
        "com.hk",
        "com.mx",
        "com.sg",
        "com.tr",
        "ne.jp",
        "net.au",
        "org.uk"
    )

    private val PUBLIC_SUFFIX_PARTS = setOf(
        "ac",
        "co",
        "com",
        "edu",
        "gov",
        "mil",
        "ne",
        "net",
        "org"
    )

    private val URL_SHORTENERS = setOf(
        "bit.ly",
        "buff.ly",
        "cutt.ly",
        "goo.gl",
        "is.gd",
        "lnkd.in",
        "ow.ly",
        "rebrand.ly",
        "s.id",
        "t.co",
        "tiny.cc",
        "tinyurl.com",
        "trib.al",
        "v.gd"
    )

    private val WATCHLIST_TLDS = setOf(
        "cam",
        "click",
        "country",
        "download",
        "gq",
        "kim",
        "loan",
        "men",
        "ml",
        "mov",
        "quest",
        "rest",
        "surf",
        "tk",
        "top",
        "work",
        "xyz",
        "zip"
    )

    private val CREDENTIAL_KEYWORDS = setOf(
        "2fa",
        "account",
        "auth",
        "billing",
        "confirm",
        "login",
        "mfa",
        "password",
        "payment",
        "recovery",
        "reset",
        "secure",
        "security",
        "signin",
        "sign-in",
        "unlock",
        "update",
        "verify",
        "wallet"
    )

    private val REDIRECT_PARAMETER_NAMES = setOf(
        "continue",
        "dest",
        "destination",
        "next",
        "redirect",
        "redirect_uri",
        "return",
        "return_url",
        "target",
        "u",
        "url"
    )

    private val DANGEROUS_FILE_EXTENSIONS = setOf(
        ".apk",
        ".bat",
        ".cmd",
        ".dmg",
        ".exe",
        ".jar",
        ".js",
        ".msi",
        ".ps1",
        ".scr",
        ".vbs"
    )

    private val ARCHIVE_FILE_EXTENSIONS = setOf(
        ".7z",
        ".gz",
        ".rar",
        ".tar",
        ".zip"
    )

    private val BRAND_PROFILES = listOf(
        BrandProfile("adobe", setOf("adobe.com")),
        BrandProfile("amazon", setOf("amazon.com")),
        BrandProfile("apple", setOf("apple.com")),
        BrandProfile("binance", setOf("binance.com")),
        BrandProfile("chase", setOf("chase.com")),
        BrandProfile("citibank", setOf("citibank.com")),
        BrandProfile("coinbase", setOf("coinbase.com")),
        BrandProfile("discord", setOf("discord.com", "discord.gg")),
        BrandProfile("docusign", setOf("docusign.com")),
        BrandProfile("dropbox", setOf("dropbox.com")),
        BrandProfile("ebay", setOf("ebay.com")),
        BrandProfile("facebook", setOf("facebook.com", "fb.com")),
        BrandProfile("github", setOf("github.com")),
        BrandProfile("google", setOf("google.com")),
        BrandProfile("instagram", setOf("instagram.com")),
        BrandProfile("microsoft", setOf("microsoft.com", "microsoftonline.com", "office.com", "live.com")),
        BrandProfile("netflix", setOf("netflix.com")),
        BrandProfile("paypal", setOf("paypal.com")),
        BrandProfile("steam", setOf("steampowered.com", "steamcommunity.com")),
        BrandProfile("telegram", setOf("telegram.org", "t.me")),
        BrandProfile("whatsapp", setOf("whatsapp.com", "wa.me")),
        BrandProfile("wellsfargo", setOf("wellsfargo.com"))
    )
}
