/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

@file:Suppress("TooManyFunctions")

package mozilla.components.support.ktx.kotlin

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.InetAddresses
import android.net.Uri
import android.os.Build
import android.util.Base64
import android.util.Patterns
import android.webkit.URLUtil
import androidx.annotation.VisibleForTesting
import androidx.core.net.toUri
import mozilla.components.lib.publicsuffixlist.PublicSuffixList
import mozilla.components.support.base.utils.MAX_URI_LENGTH
import mozilla.components.support.ktx.android.net.commonPrefixes
import mozilla.components.support.ktx.android.net.hostWithoutCommonPrefixes
import mozilla.components.support.ktx.util.RegistrableDomainSpan
import mozilla.components.support.ktx.util.URLStringUtils
import mozilla.components.support.ktx.util.applyRegistrableDomainSpan
import java.io.File
import java.net.IDN
import java.net.MalformedURLException
import java.net.URL
import java.net.URLEncoder
import java.security.MessageDigest
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.text.RegexOption.IGNORE_CASE

/**
 * A collection of regular expressions used in the `is*` methods below.
 */
private val re = object {
    val phoneish = "^\\s*tel:\\S?\\d+\\S*\\s*$".toRegex(IGNORE_CASE)
    val emailish = "^\\s*mailto:\\w+\\S*\\s*$".toRegex(IGNORE_CASE)
    val geoish = "^\\s*geo:\\S*\\d+\\S*\\s*$".toRegex(IGNORE_CASE)
}

private const val MAILTO = "mailto:"

// Number of last digits to be shown when credit card number is obfuscated.
private const val LAST_VISIBLE_DIGITS_COUNT = 4

private const val FILE_PREFIX = "file://"
private const val MAX_VALID_PORT = 65_535
private const val SPACE = " "
private const val UNDERSCORE = "_"

/**
 * Shortens URLs to be more user friendly.
 *
 * The algorithm used to generate these strings is a combination of FF desktop 'top sites',
 * feedback from the security team, and documentation regarding url elision.  See
 * StringTest.kt for details.
 *
 * This method is complex because URLs have a lot of edge cases. Be sure to thoroughly unit
 * test any changes you make to it.
 */
// Unused Parameter: We may resume stripping eTLD, depending on conversations between security and UX
// Return count: This is a complex method, but it would not be more understandable if broken up
// ComplexCondition: Breaking out the complex condition would make this logic harder to follow
@Suppress("UNUSED_PARAMETER", "ReturnCount", "ComplexCondition")
fun String.toShortUrl(publicSuffixList: PublicSuffixList): String {
    val inputString = this
    val uri = inputString.toUri()

    if (
        inputString.isEmpty() ||
        !URLUtil.isValidUrl(inputString) ||
        inputString.startsWith(FILE_PREFIX) ||
        uri.port !in -1..MAX_VALID_PORT
    ) {
        return inputString
    }

    if (uri.host?.isIpv4OrIpv6() == true ||
        // If inputString is just a hostname and not a FQDN, use the entire hostname.
        uri.host?.contains(".") == false
    ) {
        return uri.host ?: inputString
    }

    fun String.stripUserInfo(): String {
        val userInfo = this.toUri().encodedUserInfo
        return if (userInfo != null) {
            val infoIndex = this.indexOf(userInfo)
            this.removeRange(infoIndex..infoIndex + userInfo.length)
        } else {
            this
        }
    }
    fun String.stripPrefixes(): String = this.toUri().hostWithoutCommonPrefixes ?: this
    fun String.toUnicode() = IDN.toUnicode(this)

    return inputString
        .stripUserInfo()
        .lowercase(Locale.getDefault())
        .stripPrefixes()
        .toUnicode()
}

// impl via FFTV https://searchfox.org/mozilla-mobile/source/firefox-echo-show/app/src/main/java/org/mozilla/focus/utils/FormattedDomain.java#129
@Suppress("DEPRECATION")
internal fun String.isIpv4(): Boolean = Patterns.IP_ADDRESS.matcher(this).matches()

// impl via FFiOS: https://github.com/mozilla-mobile/firefox-ios/blob/deb9736c905cdf06822ecc4a20152df7b342925d/Shared/Extensions/NSURLExtensions.swift#L292
// True IPv6 validation is difficult. This is slightly better than nothing
internal fun String.isIpv6(): Boolean {
    return this.isNotEmpty() && this.contains(":")
}

/**
 * Returns true if the string represents a valid Ipv4 or Ipv6 IP address.
 * Note: does not validate a dual format Ipv6 ( "y:y:y:y:y:y:x.x.x.x" format).
 *
 */
@Suppress("TooManyFunctions")
fun String.isIpv4OrIpv6(): Boolean {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        InetAddresses.isNumericAddress(this)
    } else {
        this.isIpv4() || this.isIpv6()
    }
}

/**
 * Checks if this String is a URL.
 */
fun String.isUrl() = URLStringUtils.isURLLike(this)

/**
 * Checks if this String is a URL of a content resource.
 */
fun String.isContentUrl() = this.startsWith("content://")

/**
 * Checks if this String is an about URL.
 */
fun String.isAboutUrl() = this.startsWith("about:")

/**
 * Checks if this String is a URL of an extension page.
 */
fun String.isExtensionUrl() = this.startsWith("moz-extension://")

/**
 * Checks if this String is a URL of a resource.
 */
fun String.isResourceUrl() = this.startsWith("resource://")

/**
 * Appends `http` scheme if no scheme is present in this String.
 */
fun String.toNormalizedUrl(): String {
    val s = this.sanitizeURL()
    // Most commonly we'll encounter http or https schemes.
    // For these, avoid running through toNormalizedURL as an optimization.
    return if (!s.startsWith("http://") &&
        !s.startsWith("https://")
    ) {
        URLStringUtils.toNormalizedURL(s)
    } else {
        s
    }
}

/**
 * Add a [RegistrableDomainSpan] marker to [url] for evidentiating the registrable domain.
 * When the registrable domain could not be identified the [RegistrableDomainSpan] marker won't be applied.
 *
 * To get the indexes of the domain use the [getRegistrableDomainIndexRange] method.
 *
 * @param url The url to identify the registrable domain in and mark it with [RegistrableDomainSpan].
 * @param publicSuffixList The [PublicSuffixList] to use to identify the registrable domain.
 */
suspend fun String.applyRegistrableDomainSpan(publicSuffixList: PublicSuffixList) =
    applyRegistrableDomainSpan(this, publicSuffixList)

fun String.isPhone() = re.phoneish.matches(this)

fun String.isEmail() = re.emailish.matches(this)

fun String.isGeoLocation() = re.geoish.matches(this)

/**
 * Converts a [String] to a [Date] object.
 * @param format date format used for formatting the this given [String] object.
 * @param locale the locale to use when converting the String, defaults to [Locale.ROOT].
 * @return a [Date] object with the values in the provided in this string, if empty string was provided, a current date
 * will be returned.
 */
fun String.toDate(format: String, locale: Locale = Locale.ROOT): Date {
    val formatter = SimpleDateFormat(format, locale)
    return if (isNotEmpty()) {
        formatter.parse(this) ?: Date()
    } else {
        Date()
    }
}

/**
 * Calculates a SHA1 hash for this string.
 */
@Suppress("MagicNumber")
fun String.sha1(): String {
    val characters = "0123456789abcdef"
    val digest = MessageDigest.getInstance("SHA-1").digest(toByteArray())
    return digest.joinToString(
        separator = "",
        transform = { byte ->
            String(charArrayOf(characters[byte.toInt() shr 4 and 0x0f], characters[byte.toInt() and 0x0f]))
        },
    )
}

/**
 * Tries to convert a [String] to a [Date] using a list of [possibleFormats].
 * @param possibleFormats one ore more possible format.
 * @return a [Date] object with the values in the provided in this string,
 * if the conversion is not possible null will be returned.
 */
fun String.toDate(
    vararg possibleFormats: String = arrayOf(
        "yyyy-MM-dd'T'HH:mm",
        "yyyy-MM-dd",
        "yyyy-'W'ww",
        "yyyy-MM",
        "HH:mm",
    ),
): Date? {
    possibleFormats.forEach {
        try {
            return this.toDate(it)
        } catch (pe: ParseException) {
            // move to next possible format
        }
    }
    return null
}

/**
 * Tries to parse and get host part if this [String] is valid URL.
 * Otherwise returns the string.
 */
fun String.tryGetHostFromUrl(): String = try {
    URL(this).host
} catch (e: MalformedURLException) {
    this
}

/**
 * Returns `true` if this string is a valid URL that contains [searchParameters] in its query parameters.
 */
fun String.urlContainsQueryParameters(searchParameters: String): Boolean = try {
    URL(this).query?.split("&")?.any { it == searchParameters } ?: false
} catch (e: MalformedURLException) {
    false
}

/**
 * Compares 2 URLs and returns true if they have the same origin,
 * which means: same protocol, same host, same port.
 * It will return false if either this or [other] is not a valid URL.
 */
fun String.isSameOriginAs(other: String): Boolean {
    fun canonicalizeOrigin(urlStr: String): String {
        val url = URL(urlStr)
        val port = if (url.port == -1) url.defaultPort else url.port
        val canonicalized = URL(url.protocol, url.host, port, "")
        return canonicalized.toString()
    }
    return try {
        canonicalizeOrigin(this) == canonicalizeOrigin(other)
    } catch (e: MalformedURLException) {
        false
    }
}

/**
 * Returns an origin (protocol, host and port) from an URL string.
 */
fun String.getOrigin(): String? {
    return try {
        val url = URL(this)
        val port = if (url.port == -1) url.defaultPort else url.port
        URL(url.protocol, url.host, port, "").toString()
    } catch (e: MalformedURLException) {
        null
    }
}

/**
 * Returns an origin without the default port.
 * For example for an input of "https://mozilla.org:443" you will get "https://mozilla.org".
 */
fun String.stripDefaultPort(): String {
    return try {
        val url = URL(this)
        val port = if (url.port == url.defaultPort) -1 else url.port
        URL(url.protocol, url.host, port, "").toString()
    } catch (e: MalformedURLException) {
        this
    }
}

/**
 * Remove leading and trailing whitespace and eliminate newline characters.
 */
fun String.sanitizeURL(): String {
    return this.trim().replace("\n", "")
}

/**
 * Remove any unwanted character from string containing file name.
 * For example for an input of "/../../../../../../directory/file.txt" you will get "file.txt"
 */
fun String.sanitizeFileName(): String {
    val file = File(this.substringAfterLast(File.separatorChar))
    // Remove unwanted subsequent dots in the file name.
    return if (file.extension.trim().isNotEmpty() && file.nameWithoutExtension.isNotEmpty()) {
        file.name.replace("\\.\\.+".toRegex(), ".")
    } else {
        file.name.replace(".", "")
    }.replaceContinuousSpaces()
        .replaceEscapedCharacters()
        .trim()
}

/**
 * Replaces <, >, *, ", :, ?, \, |, and control characters from ASCII 0 to ASCII 19 with '_' so
 * the file name is valid and is correctly displayed.
 */
private fun String.replaceEscapedCharacters(): String {
    val escapedCharactersRegex = "[\\x00-\\x13*\"?<>:|\\\\]".toRegex()
    return replace(escapedCharactersRegex, UNDERSCORE)
}

/**
 * Replaces continuous spaces with a single space. Here `\s` matches the ASCII whitespace
 * characters and `\p{Z}` matches Unicode whitespace characters. For more information, refer to
 * [Unicode Space Separator Category Docs](https://www.compart.com/en/unicode/category/Zs).
 */
private fun String.replaceContinuousSpaces(): String {
    val escapedCharactersRegex = "[\\p{Z}\\s]+".toRegex()
    return replace(escapedCharactersRegex, SPACE)
}

/**
 * Remove leading mailto from the string.
 * For example for an input of "mailto:example@example.com" you will get "example@example.com"
 */
fun String.stripMailToProtocol(): String {
    return if (this.startsWith(MAILTO)) {
        this.replaceFirst(MAILTO, "")
    } else {
        this
    }
}

/**
 * Translates the string into {@code application/x-www-form-urlencoded} string.
 */
fun String.urlEncode(): String {
    return URLEncoder.encode(this, Charsets.UTF_8.name())
}

/**
 * Decodes '%'-escaped octets in the given string using the UTF-8 scheme.
 * Replaces invalid octets with the unicode replacement character
 * ("\\uFFFD").
 *
 * @see [Uri.decode]
 */
fun String.decode(): String = Uri.decode(this)

/**
 * Returns the string if it's length is not higher than @param[maximumLength] or
 * a @param[replacement] string if String length is higher than @param[maximumLength]
 */
fun String.takeOrReplace(maximumLength: Int, replacement: String): String {
    return if (this.length > maximumLength) replacement else this
}

/**
 * Returns the extension (without ".") declared in the mime type of this data url.
 * In the event that this data url does not contain a mime type or image extension could be read
 * for any reason [defaultExtension] will be returned
 *
 * @param defaultExtension default extension if one could not be read from the mime type. Default is "jpg".
 */
fun String.getDataUrlImageExtension(defaultExtension: String = "jpg"): String {
    return ("data:image\\/([a-zA-Z0-9-.+]+).*").toRegex()
        .find(this)?.groups?.get(1)?.value ?: defaultExtension
}

/**
 * Returns this char sequence if it's not null or empty
 * or the result of calling [defaultValue] function if the char sequence is null or empty.
 */
inline fun <C, R> C?.ifNullOrEmpty(defaultValue: () -> R): C where C : CharSequence, R : C =
    if (isNullOrEmpty()) defaultValue() else this

/**
 * Get the representative part of the URL. Usually this is the host with common prefixes (like "www.") removed.
 *
 * For example this method will return "facebook.com" for "https://www.facebook.com/foobar".
 */
fun String.getRepresentativeSnippet(): String {
    val uri = this.toUri()

    val host = uri.hostWithoutCommonPrefixes
    if (!host.isNullOrEmpty()) {
        return host
    }

    val path = uri.path
    if (!path.isNullOrEmpty()) {
        return path
    }

    return this
}

/**
 * Get a representative character for the given URL.
 *
 * For example this method will return "f" for "https://m.facebook.com/foobar".
 */
fun String.getRepresentativeCharacter(): String {
    val snippet = this.getRepresentativeSnippet()

    snippet.forEach { character ->
        if (character.isLetterOrDigit()) {
            return character.uppercase()
        }
    }

    return "?"
}

/**
 * Strips common mobile subdomains from a [String].
 */
fun String.stripCommonSubdomains(): String {
    for (prefix in commonPrefixes) {
        if (this.startsWith(prefix)) return this.substring(prefix.length)
    }
    return this
}

/**
 * Returns the last 4 digits from a formatted credit card number string.
 */
fun String.last4Digits(): String {
    return this.takeLast(LAST_VISIBLE_DIGITS_COUNT)
}

/**
 * Returns a trimmed string. This is used to prevent extreme cases
 * from slowing down UI rendering with large strings.
 */
fun String.trimmed(): String {
    return this.take(MAX_URI_LENGTH)
}

/**
 * Returns a bitmap from its base64 representation.
 * Returns null if the string is not a valid base64 representation of a bitmap
 */
fun String.base64ToBitmap(): Bitmap? =
    extractBase6RawString()?.let { rawString ->
        val raw = Base64.decode(rawString, Base64.DEFAULT)
        BitmapFactory.decodeByteArray(raw, 0, raw.size)
    }

@VisibleForTesting
internal fun String.extractBase6RawString(): String? {
    // Regex that identifies if the strings starts with:
    // "(data:image/[ANY_FORMAT];base64,"
    // For example, "data:image/png;base64,"
    val base64BitmapRegex = "(data:image/[^;]+;base64,)(.*)".toRegex()
    return base64BitmapRegex.find(this)?.let {
        val (_, contentString) = it.destructured
        contentString
    }
}
