Strings.kt

package com.hexagonkt.core.text

import com.hexagonkt.core.urlOf
import java.io.ByteArrayInputStream
import java.io.File
import java.io.InputStream
import java.net.InetAddress
import java.net.URI
import java.net.URL
import java.text.Normalizer
import java.text.Normalizer.Form.NFD
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.util.Base64
import kotlin.reflect.KClass

private const val VARIABLE_PREFIX = "{{"
private const val VARIABLE_SUFFIX = "}}"

private val base64Encoder: Base64.Encoder by lazy { Base64.getEncoder().withoutPadding() }
private val base64Decoder: Base64.Decoder by lazy { Base64.getDecoder() }

/** Runtime specific end of line. */
val eol: String by lazy { System.lineSeparator() }

/** Supported types for the [parseOrNull] function. */
val parsedClasses: Set<KClass<*>> by lazy {
    setOf(
        Boolean::class,
        Int::class,
        Long::class,
        Float::class,
        Double::class,
        String::class,
        InetAddress::class,
        URL::class,
        URI::class,
        File::class,
        LocalDate::class,
        LocalTime::class,
        LocalDateTime::class,
    )
}

/**
 * Filter the target string substituting each key by its value. The keys format resembles Mustache's
 * one: `{{key}}` and all occurrences are replaced by the supplied value.
 *
 * If a variable does not have a parameter, it is left as it is.
 *
 * @param parameters The map with the list of key/value tuples.
 * @return The filtered text or the same string if no values are passed or found in the text.
 * @sample com.hexagonkt.core.text.StringsTest.filterVarsExample
 */
fun String.filterVars(parameters: Map<*, *>): String =
    this.filter(
        VARIABLE_PREFIX,
        VARIABLE_SUFFIX,
        parameters
            .filterKeys { it != null }
            .map { (k, v) -> k.toString() to v.toString() }
            .toMap()
    )

/**
 * [TODO](https://github.com/hexagontk/hexagon/issues/271).
 *
 * @receiver .
 * @param prefix .
 * @param suffix .
 * @param parameters .
 * @return .
 */
fun String.filter(prefix: String, suffix: String, parameters: Map<String, *>): String =
    parameters.entries.fold(this) { result, (first, second) ->
        result.replace(prefix + first + suffix, second.toString())
    }

/**
 * Encode the content of this byteArray to base64.
 *
 * @receiver ByteArray to be encoded to base64.
 * @return The base64 encoded string.
 */
fun ByteArray.encodeToBase64(): String =
    base64Encoder.encodeToString(this)

/**
 * Encode this string to base64.
 *
 * @receiver String to be encoded to base64.
 * @return The base64 encoded string.
 */
fun String.encodeToBase64(): String =
    toByteArray().encodeToBase64()

/**
 * Decode this base64 encoded string.
 *
 * @receiver String encoded to base64.
 * @return The ByteArray result of decoding the base64 string.
 */
fun String.decodeBase64(): ByteArray =
    base64Decoder.decode(this)

/**
 * [TODO](https://github.com/hexagontk/hexagon/issues/271).
 *
 * @receiver .
 * @param T .
 * @param type .
 * @return .
 */
@Suppress("UNCHECKED_CAST") // All allowed types are checked at runtime
fun <T : Any> String.parse(type: KClass<T>): T =
    this.let {
        require(type in parsedClasses) { "Unsupported type: ${type.qualifiedName}" }

        when (type) {
            Boolean::class -> this.toBooleanStrictOrNull()
            Int::class -> this.toIntOrNull()
            Long::class -> this.toLongOrNull()
            Float::class -> this.toFloatOrNull()
            Double::class -> this.toDoubleOrNull()
            String::class -> this
            InetAddress::class -> this.let(InetAddress::getByName)
            URL::class -> this.let(::urlOf)
            URI::class -> this.let(::URI)
            File::class -> this.let(::File)
            LocalDate::class -> LocalDate.parse(this)
            LocalTime::class -> LocalTime.parse(this)
            LocalDateTime::class -> LocalDateTime.parse(this)
            else -> error("Unsupported type: ${type.qualifiedName}")
        }
    } as T

/**
 * [TODO](https://github.com/hexagontk/hexagon/issues/271).
 *
 * @receiver .
 * @param T .
 * @param type .
 * @return .
 */
fun <T : Any> String?.parseOrNull(type: KClass<T>): T? =
    this?.let {
        require(type in parsedClasses) { "Unsupported type: ${type.qualifiedName}" }
        try {
            parse(type)
        }
        catch (e: Exception) {
            null
        }
    }

fun String.stripAnsi(): String =
    replace(Ansi.REGEX, "")

/**
 * [TODO](https://github.com/hexagontk/hexagon/issues/271).
 *
 * @receiver .
 * @return .
 */
fun String.toStream(): InputStream =
    ByteArrayInputStream(this.toByteArray())

/**
 * [TODO](https://github.com/hexagontk/hexagon/issues/271).
 *
 * @receiver .
 * @param count .
 * @param pad .
 * @return .
 */
fun String.prependIndent(count: Int = 4, pad: String = " "): String =
    this.prependIndent(pad.repeat(count))

/**
 * [TODO](https://github.com/hexagontk/hexagon/issues/271).
 *
 * @receiver .
 * @param T .
 * @param converter .
 * @return .
 */
fun <T : Enum<*>> String.toEnum(converter: (String) -> T): T =
    uppercase().replace(" ", "_").let(converter)

/**
 * [TODO](https://github.com/hexagontk/hexagon/issues/271).
 *
 * @receiver .
 * @param T .
 * @param converter .
 * @return .
 */
fun <T : Enum<*>> String.toEnumOrNull(converter: (String) -> T): T? =
    try {
        toEnum(converter)
    }
    catch (e: IllegalArgumentException) {
        null
    }

/**
 * [TODO](https://github.com/hexagontk/hexagon/issues/271).
 *
 * @receiver .
 * @param text .
 * @return .
 */
fun Regex.findGroups(text: String): List<MatchGroup> =
    (this.find(text)?.groups ?: emptyList<MatchGroup>())
        .filterNotNull()
        .drop(1)

/**
 * Format the string as a banner with a delimiter above and below text. The character used to
 * render the delimiter is defined.
 *
 * @param bannerDelimiter Delimiter char for banners.
 */
fun String.banner(bannerDelimiter: String = "*"): String =
    bannerDelimiter
        .repeat(this
            .lines()
            .asSequence()
            .map { it.length }
            .maxOrElse(0)
        )
        .let { "$it$eol$this$eol$it" }

// TODO Add `box` (create a rectangle text) and doubleSpace (add a space between letters)
// TODO These and other implemented methods can fit in a Effects.kt file

/**
 * [TODO](https://github.com/hexagontk/hexagon/issues/271).
 *
 * @receiver .
 * @return .
 */
fun String.stripAccents(): String =
    Normalizer.normalize(this, NFD).replace("\\p{M}".toRegex(), "")

/**
 * [TODO](https://github.com/hexagontk/hexagon/issues/271).
 *
 * @param bytes .
 * @return .
 */
fun utf8(vararg bytes: Int): String =
    String(bytes.map(Int::toByte).toByteArray())

fun String.toEnumValue(): String =
    trim().uppercase().replace(" ", "_")

internal fun Sequence<Int>.maxOrElse(fallback: Int): Int =
    this.maxOrNull() ?: fallback