/*
 * This file is part of LibEuFin.
 * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A.
 *
 * LibEuFin is free software; you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation; either version 3, or
 * (at your option) any later version.
 *
 * LibEuFin is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
 * Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public
 * License along with LibEuFin; see the file COPYING.  If not, see
 * <http://www.gnu.org/licenses/>
 */

package tech.libeufin.common

import kotlinx.serialization.json.Json
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.nio.file.*
import java.time.*
import java.time.format.*
import java.time.temporal.ChronoUnit
import kotlin.io.path.*

private val logger: Logger = LoggerFactory.getLogger("libeufin-config")

/** Config error when analyzing and using the taler configuration format */
class TalerConfigError private constructor (m: String, cause: Throwable? = null) : Exception(m, cause) {
    companion object {
        /** Error when a specific option value is missing */
        internal fun missing(type: String, section: String, option: String): TalerConfigError =
            TalerConfigError("Missing $type option '$option' in section '$section'")

        /** Error when a specific option value is invalid */
        internal fun invalid(type: String, section: String, option: String, err: String): TalerConfigError =
            TalerConfigError("Expected $type option '$option' in section '$section': $err")

        /** Generic error not linked to a specific option value */
        fun generic(msg: String, cause: Throwable? = null): TalerConfigError =
            TalerConfigError(msg, cause)
    }
}

/** Configuration error when converting option value */
class ValueError(val msg: String): Exception(msg)

/** Information about how the configuration is loaded */
data class ConfigSource(
    /** Name of the high-level project */
    val projectName: String = "taler",
    /** Name of the component within the package */
    val componentName: String = "taler",
    /**
     * Executable name that will be located on $PATH to
     * find the installation path of the package
     */
    val execName: String = "taler-config"
) {
    /** Load configuration from string */
    fun fromMem(content: String): TalerConfig {
        val loader = ConfigLoader(this)
        loader.loadDefaults()
        loader.loadFromMem(content.lineSequence(), null, 0)
        return loader.finalize()
    }

    /** 
     * Load configuration from [file], if [file] is null load from default configuration file 
     * 
     * The entry point for the default configuration will be the first file from this list:
     * - $XDG_CONFIG_HOME/$componentName.conf
     * - $HOME/.config/$componentName.conf
     * - /etc/$componentName.conf
     * - /etc/$projectName/$componentName.conf
     * */
    fun fromFile(file: Path?): TalerConfig {
        /** Search for the default configuration file path */
        fun defaultConfigPath(): Path? {
            return sequence {
                val xdg = System.getenv("XDG_CONFIG_HOME")
                if (xdg != null) yield(Path(xdg, "$componentName.conf"))
    
                val home = System.getenv("HOME")
                if (home != null) yield(Path(home, ".config/$componentName.conf"))
    
                yield(Path("/etc/$componentName.conf"))
                yield(Path("/etc/$projectName/$componentName.conf"))
            }.firstOrNull { it.exists() }
        }
        val path = file ?: defaultConfigPath()
        val loader = ConfigLoader(this)
        loader.loadDefaults()
        if (path != null) loader.loadFromFile(path, 0, 0)
        return loader.finalize()
    }

    /** Search for the binary installation path in PATH */
    fun installPath(): Path {
        val pathEnv = System.getenv("PATH")
        for (entry in pathEnv.splitToSequence(':')) {
            val path = Path(entry)
            if (path.resolve(execName).exists()) {
                val parent = path.parent
                if (parent != null) {
                    return parent.toRealPath()
                }
            }
        }
        return Path("/usr")
    }
}

/**
 * Reader for Taler-style configuration files
 *
 * The configuration file format is similar to INI files
 * and fully described in the taler.conf man page.
 * 
 * @param source information about where to load configuration defaults from
 **/
private class ConfigLoader(
    private val source: ConfigSource
) {
    private val sections: MutableMap<String, MutableMap<String, String>> = mutableMapOf()

    /**
     * Load configuration defaults from the file system
     * and populate the PATHS section based on the installation path.
     */
    fun loadDefaults() {
        val installDir = source.installPath()

        val section = sections.getOrPut("PATHS") { mutableMapOf() }
        section["PREFIX"] = "$installDir/"
        section["BINDIR"] = "$installDir/bin/"
        section["LIBEXECDIR"] = "$installDir/${source.projectName}/libexec/"
        section["DOCDIR"] = "$installDir/share/doc/${source.projectName}/"
        section["ICONDIR"] = "$installDir/share/icons/"
        section["LOCALEDIR"] = "$installDir/share/locale/"
        section["LIBDIR"] = "$installDir/lib/${source.projectName}/"
        section["DATADIR"] = "$installDir/share/${source.projectName}/"
        
        val baseConfigDir = installDir.resolve("share/${source.projectName}/config.d")
        try {
            baseConfigDir.useDirectoryEntries {
                for (entry in it) {
                    loadFromFile(entry, 0, 0)
                }
            }
        } catch (e: Exception) {
            when (e) {
                is NotDirectoryException ->
                    logger.warn("Base config directory is not a directory")
                is NoSuchFileException ->
                    logger.warn("Missing base config directory: $baseConfigDir")
                else -> throw e
            }
        }
    }

    fun genericError(source: Path?, lineNum: Int, msg: String, cause: String? = null): TalerConfigError {
        val message = buildString {
            append(msg)
            append(" at '")
            if (source != null) {
                append(source)
            } else {
                append("mem")
            }
            append(':')
            append(lineNum)
            append('\'')
            if (cause != null) {
                append(": ")
                append(cause)
            }
        }
        return TalerConfigError.generic(message)
    }

    fun loadFromFile(file: Path, recursionDepth: Int, lineNum: Int) {
        if (recursionDepth > 128) {
            throw genericError(file, lineNum, "Recursion limit in config inlining")
        }
        logger.trace("load file at $file")
        return try {
            file.useLines {
                loadFromMem(it, file, recursionDepth+1)
            }
        } catch (e: Exception) {
            when (e) {
                is NoSuchFileException -> throw TalerConfigError.generic("Could not read config at '$file': no such file")
                is AccessDeniedException -> throw TalerConfigError.generic("Could not read config at '$file': permission denied")
                is TalerConfigError -> throw e
                else -> throw TalerConfigError.generic("Could not read config at '$file'", e)
            }
        }
    }

    fun loadFromMem(lines: Sequence<String>, source: Path?, recursionDepth: Int) {
        var currentSection: MutableMap<String, String>? = null
        for ((lineNum, line) in lines.withIndex()) {
            if (RE_LINE_OR_COMMENT.matches(line)) {
                continue
            }

            val directiveMatch = RE_DIRECTIVE.matchEntire(line)
            if (directiveMatch != null) {
                if (source == null) throw genericError(source, lineNum, "Directives are only supported when loading from file")
                val (directiveName, directiveArg) = directiveMatch.destructured
                when (directiveName.lowercase()) {
                    "inline" -> loadFromFile(source.resolveSibling(directiveArg), recursionDepth, lineNum)
                    "inline-matching" -> {
                        try {
                            val pathMatcher = FileSystems.getDefault().getPathMatcher("glob:$directiveArg")
                            val entries = source.parent.walk()
                                .filter { pathMatcher.matches(source.parent.relativize(it)) }
                            for (entry in entries) {
                                loadFromFile(entry, recursionDepth, lineNum)
                            }
                        } catch (e: Exception) {
                            when (e) {
                                is java.util.regex.PatternSyntaxException ->
                                    throw genericError(source, lineNum, "Malformed glob regex", e.message)
                                else -> throw e
                            }
                        }
                    }
                    "inline-secret" -> {
                        val sp = directiveArg.split(" ")
                        if (sp.size != 2) {
                            throw genericError(source, lineNum, "invalid configuration, @inline-secret@ directive requires exactly two arguments")
                        }
                        val sectionName = sp[0]
                        val secretFilename = source.resolveSibling(sp[1])

                        if (!secretFilename.isReadable()) {
                            logger.warn("unable to read secrets from $secretFilename")
                        } else {
                            loadFromFile(secretFilename, recursionDepth, lineNum)
                        }
                    }
                    else -> throw genericError(source, lineNum, "unsupported directive '$directiveName'")
                }
                continue
            }

            val secMatch = RE_SECTION.matchEntire(line)
            if (secMatch != null) {
                val (sectionName) = secMatch.destructured
                currentSection = sections.getOrPut(sectionName.uppercase()) { mutableMapOf() }
                continue
            } else if (currentSection == null) {
                throw genericError(source, lineNum, "expected section header")
            }

            val paramMatch = RE_PARAM.matchEntire(line)
            if (paramMatch != null) {
                var (optName, optVal) = paramMatch.destructured
                if (optVal.length != 1 && optVal.startsWith('"') && optVal.endsWith('"')) {
                    optVal = optVal.substring(1, optVal.length - 1)
                }
                currentSection[optName.uppercase()] = optVal
                continue
            }
            throw genericError(source, lineNum, "expected section header, option assignment or directive")
        }
    }

    fun finalize(): TalerConfig {
        return TalerConfig(sections)
    }

    companion object {
        private val RE_LINE_OR_COMMENT = Regex("^\\s*(#.*)?$")
        private val RE_SECTION = Regex("^\\s*\\[\\s*(.*)\\s*\\]\\s*$")
        private val RE_PARAM = Regex("^\\s*([^=]+?)\\s*=\\s*(.*?)\\s*$")
        private val RE_DIRECTIVE = Regex("^\\s*@([a-zA-Z-_]+)@\\s*(.*?)\\s*$")
    }
}

/** Taler-style configuration */
class TalerConfig internal constructor(
    private val cfg: Map<String, Map<String, String>>
) {
    val sections: Set<String> get() = cfg.keys

    /** Create a string representation of the loaded configuration */
    fun stringify(): String = buildString {
        for ((section, options) in cfg) {
            appendLine("[$section]")
            for ((key, value) in options) {
                appendLine("$key = $value")
            }
            appendLine()
        }
    }

    /**
     * Substitute ${...} and $... placeholders in a string
     * with values from the PATHS section in the
     * configuration and environment variables
     *
     * This substitution is typically only done for paths.
     */
    internal fun pathsub(str: String, recursionDepth: Int = 0): String {
        /** Lookup for variable value from PATHS section in the configuration and environment variables */
        fun lookup(name: String, recursionDepth: Int = 0): String? {
            val pathRes = section("PATHS").string(name).orNull()
            if (pathRes != null) {
                return pathsub(pathRes, recursionDepth + 1)
            }
            return System.getenv(name)
        }

        if (recursionDepth > 128) {
            throw ValueError("recursion limit in path substitution exceeded for '$str'")
        } else if (!str.contains('$')) { // Fast path without variables
            return str
        }
        
        var cursor = 0
        val result = StringBuilder()
        while (true) {
            // Look for next variable
            val dollarIndex = str.indexOf("$", cursor)
            if (dollarIndex == -1) {
                // Reached end of string
                result.append(str, cursor, str.length)
                break
            }

            // Append normal characters
            result.append(str, cursor, dollarIndex)
            cursor = dollarIndex + 1

            // Check if variable is enclosed
            val enclosed = if (str[cursor] == '{') {
                // ${var
                cursor++
                true
            } else false // $var

            // Extract variable name
            val startName = cursor
            while (cursor < str.length && (str[cursor].isLetterOrDigit() || str[cursor] == '_')) {
                cursor++
            }
            val name = str.substring(startName, cursor)

            // Extract variable default if enclosed
            val default = if (!enclosed) null else {
                if (str[cursor] == '}') { 
                    // ${var}
                    cursor++
                    null
                } else if (cursor+1<str.length && str[cursor] == ':' && str[cursor+1] == '-') {
                    // ${var:-default}
                    cursor += 2
                    val startDefault = cursor
                    var depth = 1
                    // Find end of the ${...} expression
                    while (cursor < str.length && depth != 0) {
                        if (str[cursor] == '}') {
                            depth--
                        } else if (cursor+1<str.length && str[cursor] == '$' && str[cursor+1] == '{') {
                            depth++
                        }
                        cursor++
                    }
                    if (depth != 0)
                        throw ValueError("unbalanced variable expression '${str.substring(startDefault)}'")
                    str.substring(startDefault, cursor - 1)
                } else {
                    throw ValueError("bad substitution '${str.substring(cursor-3)}'")
                }
            }
            
            // Value resolution
            val resolved = lookup(name, recursionDepth + 1)
                ?: default?.let { pathsub(it, recursionDepth + 1) }
                ?: throw ValueError("unbound variable '$name'")
            result.append(resolved)
        }
        return result.toString()
    }

    /** Access [section] */
    fun section(section: String): TalerConfigSection {
        val canonSection = section.uppercase()
        return TalerConfigSection(this, cfg[canonSection], section)
    }
}

/** Accessor/Converter for Taler-like configuration options */
class TalerConfigOption<T> internal constructor(
    private val raw: String?,
    private val option: String,
    private val type: String,
    private val section: TalerConfigSection,
    private val transform: TalerConfigSection.(String) -> T,
) {
    /** Converted value or null if missing */
    fun orNull(): T? {
        if (raw == null) return null
        try {
            return section.transform(raw)
        } catch (e: ValueError) {
            throw TalerConfigError.invalid(type, section.section, option, e.msg)
        } catch (e: Exception) {
            throw TalerConfigError.invalid(type, section.section, option, e.message ?: e.toString())
        }
    }
    
    /** Converted value or null if missing, log a warning if missing */
    fun orNull(logger: Logger, warn: String): T? {
        val value = orNull()
        if (value == null) {
            val err = TalerConfigError.missing(type, section.section, option).message
            logger.warn("$err$warn")
        }
        return value
    }

    /** Converted value of default if missing */
    fun default(default: T): T = orNull() ?: default

    /** Converted value or default if missing, log a warning if missing */
    fun default(default: T, logger: Logger, warn: String): T = orNull(logger, warn) ?: default

    /** Converted value or throw if missing */
    fun require(): T = orNull() ?: throw TalerConfigError.missing(type, section.section, option)
}

/** Accessor/Converter for Taler-like configuration sections */
class TalerConfigSection internal constructor(
    private val cfg: TalerConfig,
    private val entries: Map<String, String>?,
    val section: String
) {
    /** Setup an accessor/converted for a [type] at [option] using [transform] */
    fun <T> option(option: String, type: String, transform: TalerConfigSection.(String) -> T): TalerConfigOption<T> {
        val canonOption = option.uppercase()
        var raw = entries?.get(canonOption)
        if (raw == "") raw = null
        return TalerConfigOption(raw, option, type, this, transform)
    }

    /** Access [option] as String */
    fun string(option: String) = option(option, "string") { it }

    /** Access [option] as String with variable substitution */
    fun stringsub(option: String) = option(option, "string") {
        cfg.pathsub(it)
    }

    /** Access [option] as Regex */
    fun regex(option: String) = option(option, "regex") { Regex(it) }

    /** Access [option] as hexadecimal bytes */
    fun hex(option: String) = option(option, "hex") { 
        it.replace("\\s".toRegex(), "").decodeUpHex()
    }

    /** Access [option] as BaseURL */
    fun baseURL(option: String) = option(option, "baseURL") { BaseURL.parse(it) }

    /** Access [option] as IBAN */
    fun iban(option: String) = option(option, "IBAN") { IBAN.parse(it) }

    /** Access [option] as Int */
    fun number(option: String) = option(option, "number") {
        it.toIntOrNull() ?: throw ValueError("'$it' not a valid number")
    }

    /** Access [option] as Boolean */
    fun boolean(option: String) = option(option, "boolean") {
        when (it.lowercase()) {
            "yes" -> true
            "no" -> false
            else -> throw ValueError("expected 'YES' or 'NO' got '$it'")
        }
    }

    /** Access [option] as Path */
    fun path(option: String) = option(option, "path") {
        Path(cfg.pathsub(it))
    }

    /** Access [option] as Duration */
    fun duration(option: String) = option(option, "temporal") {
        if (!TEMPORAL_PATTERN.matches(it)) {
            throw ValueError("'$it' not a valid temporal")
        }
        TIME_AMOUNT_PATTERN.findAll(it).map { match ->
            val (rawAmount, unit) = match.destructured
            val amount = rawAmount.toLongOrNull() ?: throw ValueError("'$rawAmount' not a valid temporal amount")
            val value = when (unit) {
                "us" -> 1
                "ms" -> 1000
                "s", "second", "seconds", "\"" -> 1000 * 1000L
                "m", "min", "minute", "minutes", "'" -> 60 * 1000 * 1000L
                "h", "hour", "hours" -> 60 * 60 * 1000 * 1000L
                "d", "day", "days" -> 24 * 60 * 60 * 1000L * 1000L
                "week", "weeks" ->  7 * 24 * 60 * 60 * 1000L * 1000L
                "year", "years", "a" -> 31536000000000L
                else -> throw ValueError("'$unit' not a valid temporal unit")
            }
            Duration.of(amount * value, ChronoUnit.MICROS)
        }.fold(Duration.ZERO) { a, b -> a.plus(b) }
    }

    /** Access [option] as Instant */
    fun date(option: String) = option(option, "date") {
        try {
            dateToInstant(it)
        } catch (e: DateTimeParseException) {
            val indexFmt = if (e.errorIndex != 0) " at index ${e.errorIndex}" else ""
            val causeMsg = e.cause?.message
            val causeFmt = if (causeMsg != null) ": ${causeMsg}" else ""
            throw ValueError("'$it' not a valid date$indexFmt$causeFmt")
        }
    }

    /** Access [option] as time */
    fun time(option: String) = option(option, "time") {
        try {
            LocalTime.parse(it, DateTimeFormatter.ISO_LOCAL_TIME)
        } catch (e: DateTimeParseException) {
            val indexFmt = if (e.errorIndex != 0) " at index ${e.errorIndex}" else ""
            val causeMsg = e.cause?.message
            val causeFmt = if (causeMsg != null) ": ${causeMsg}" else ""
            throw ValueError("'$it' not a valid time$indexFmt$causeFmt")
        }
    }

    /** Access [option] as JSON object [T] */
    inline fun <reified T> json(option: String, type: String) = option(option, type) {
        try {
            Json.decodeFromString<T>(it)
        } catch (e: Exception) {
            throw ValueError("'$it' is malformed")
        }
    }

    /** Access [option] as Map<String, String> */
    fun jsonMap(option: String) = json<Map<String, String>>(option, "json key/value map")

    /** Access [option] as TalerAmount */
    fun amount(option: String, currency: String) = option(option, "amount") {
        val amount = try {
            TalerAmount(it)
        } catch (e: CommonError) {
            throw ValueError("'$it' is malformed: ${e.message}")
        }
    
        if (amount.currency != currency) {
            throw ValueError("expected currency $currency got ${amount.currency}")
        }
        amount
    }

    /** Access a [type] at [option] using a custom [mapper] */
    fun <T> map(option: String, type: String, mapper: Map<String, T>) = option(option, type) {
        mapper[it] ?: throw ValueError("expected ${fmtEntriesChoice(mapper)} got '$it'")
    }

    /** Access a [type] at [option] using a custom [mapper] with code execution */
    fun <T> mapLambda(option: String, type: String, mapper: Map<String, () -> T>) = option(option, type) {
        mapper[it]?.invoke() ?: throw ValueError("expected ${fmtEntriesChoice(mapper)} got '$it'")
    }
    
    companion object {
        private val TIME_AMOUNT_PATTERN = Regex("([0-9]+) ?([a-z'\"]+)")
        private val TEMPORAL_PATTERN = Regex(" *([0-9]+ ?[a-z'\"]+ *)+")
        private val WHITESPACE_PATTERN = Regex("\\s")

        private fun <T> fmtEntriesChoice(mapper: Map<String, T>): String {
            return buildString {
                val iter = mapper.keys.iterator()
                var next = iter.next()
                append("'$next'")
                while (iter.hasNext()) {
                    next = iter.next()
                    if (iter.hasNext()) {
                        append(", '$next'")
                    } else {
                        append(" or '$next'")
                    }
                }
            }
        }
    }
}
