package viaduct.graphql.test

import graphql.ExecutionResult
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import viaduct.invariants.InvariantChecker

/**
 * DSL Extension function for ExecutionResult to assert equality
 * with a map built using a DSL function.
 *
 * Use listOf to assert a list of fixed values
 *
 * Since Arrays are not generated by GraphQL specification, use
 * arrayOf to assert an array of DSL QueryResultBuilderFn
 *
 * @param mapFn The DSL function to build the expected map.
 *
 * Example of use:
 *
 * testApp.execute("query TestQuery { theQuery }")
 *  .assertEquals {
 *      "data" to {
 *          "scalarStringOut" to "tenant1 value"
 *          "listOfStrings" to listOf("a", "b", "c")
 *          "listOfDSLFunctions" to arrayOf(
 *              // Using arrayOf to create a list of nested maps
 *              {
 *                  "value" to "tenant1 value"
 *              }
 *          )
 *      }
 *  }
 */
fun ExecutionResult.assertEquals(mapFn: QueryResultBuilderFn) = assertEquals(queryResultMap(mapFn), this.toSpecification())

/**
 * Assert that an ExecutionResult matches a pattern.  The pattern
 * is created by the same DSL used for [assertEquals], but the
 * input is treated differently:
 *
 * * All leaf values in the patterns should be strings or null.
 * null patterns match the null value.  Otherwise, these
 * strings are interpreted as Kotlin regular expressions.  Actual
 * values are converted to strings using `toString`, and then
 * matched against these regular expressions.
 *
 * * For maps, the fields in the template must exist and match
 * in the actual results, but the actual results may have more
 * fields.
 *
 * * listOf and arrayOf are the same: must be an actual element
 * for every template in the list, and each actual element
 * must match its corresponding template exactly.
 *
 * @param templateFn The DSL function to build the template
 */
fun ExecutionResult.assertMatches(templateFn: QueryResultBuilderFn) = this.toSpecification().assertMatches(templateFn)

/**
 * Assert that an ExecutionResult contains at least one error, and
 * that at least one of those errors has a message that contains
 * [expectedUbMessage].
 *
 * @param expectedSubMessage a string that must be contained by the
 * message of at least one of the error messages.
 */
fun ExecutionResult.assertHasError(expectedSubMessage: String) = this.toSpecification().assertHasError(expectedSubMessage)

// public for testing (testFixtures are outside of test source-set's Kotlin module)
fun Map<String, Any?>.assertHasError(expectedSubMessage: String) {
    val errors = this["errors"] as? List<*>

    assertNotNull(errors, "Expected errors but found none. Result: $this")
    assertTrue(errors.isNotEmpty(), "Expected errors but errors list was empty. Result: $this")

    val errorMessages = errors.mapNotNull { error ->
        (error as? Map<*, *>)?.get("message") as? String
    }

    assertTrue(
        errorMessages.any { it.contains(expectedSubMessage) },
        "Expected error message containing '$expectedSubMessage' but found: $errorMessages"
    )
}

/**
 * Type alias for a function that operates on a MapBuilder.
 * This is used as function parameter on DSL functions.
 */
typealias QueryResultBuilderFn = QueryResultMapBuilder.() -> Unit

/**
 * Function to create a map using a DSL.
 *
 * @param buildAction The DSL function to build the map.
 * @return The built map.
 */
// TODO - Why is this inline?
inline fun queryResultMap(buildAction: QueryResultMapBuilder.() -> Unit): Map<String, Any?> {
    return QueryResultMapBuilder().apply(buildAction).build()
}

/**
 * A builder class for creating maps using a DSL.
 */
class QueryResultMapBuilder {
    private val map = mutableMapOf<String, Any?>()

    /**
     * Infix function to add a key-value pair to the map.
     *
     * @param value The value to associate with the key.
     */
    infix fun String.to(value: Any?) {
        map[this] = value
    }

    infix fun String.to(value: QueryResultBuilderFn) {
        map[this] = queryResultMap(value)
    }

    /**
     * Use arrayOf to check for a list of DSL maps
     * To use list of fixed values, use listOf
     */
    infix fun String.to(values: Array<QueryResultBuilderFn>) {
        map[this] = values.map { queryResultMap(it) }
    }

    /**
     * Builds and returns the map.
     *
     * @return The built map.
     */
    fun build(): Map<String, Any?> = map
}

// Public for testing
fun Map<*, *>.findFailedMatches(templateFn: QueryResultBuilderFn) = InvariantChecker().routeMatcher(queryResultMap(templateFn), this)

// Public for testing
fun Map<*, *>.assertMatches(templateFn: QueryResultBuilderFn) = this.findFailedMatches(templateFn).assertEmpty()

private fun InvariantChecker.routeMatcher(
    template: Any?,
    actual: Any?
): InvariantChecker =
    apply {
        when (template) {
            null -> isNull(actual, "Expecting a null value")
            is Map<*, *> -> matches(template, actual, this)
            is List<*> -> matchesList(template, actual, this)
            is String -> matchesRegex(template, actual, this)
            else -> throw IllegalArgumentException("Template contained bad subtemplate ($template).")
        }
    }

private fun InvariantChecker.matches(
    template: Map<*, *>,
    actual: Any?,
    collector: InvariantChecker
): InvariantChecker {
    collector.isInstanceOf<Map<*, *>>(actual) { actualMap ->
        for ((fieldName, subTemplate) in template.entries) {
            fieldName as String
            collector.withContext(fieldName) {
                if (collector.isTrue(actualMap.containsKey(fieldName), "Actual missing field $fieldName")) {
                    routeMatcher(subTemplate, actualMap[fieldName])
                }
            }
        }
    }
    return this
}

private fun InvariantChecker.matchesList(
    template: List<*>,
    actual: Any?,
    collector: InvariantChecker
): InvariantChecker =
    apply {
        collector.isInstanceOf<Iterable<*>>(actual) { actualList ->
            collector.isEqualTo(template.size, actualList.count(), "Template and actuals lists not same length")
            for ((index, pair) in template.zip(actualList).withIndex()) {
                val (subTemplate, subActual) = pair
                collector.withContext("[$index]") {
                    routeMatcher(subTemplate, subActual)
                }
            }
        }
    }

private fun InvariantChecker.matchesRegex(
    regEx: String,
    actual: Any?,
    collector: InvariantChecker
): InvariantChecker =
    apply {
        if (collector.isNotNull(actual, "Unexpected null value")) {
            val actualString = actual!!.toString()
            val regex = Regex(regEx)
            collector.isTrue(regex.matches(actualString), "Actual ($actualString) does not match /$regEx/")
        }
    }
