// SPDX-License-Identifier: Apache-2.0
//
// The OpenSearch Contributors require contributions made to
// this file be licensed under the Apache-2.0 license or a
// compatible open source license.
//
// Modifications Copyright OpenSearch Contributors. See
// GitHub history for details.
//
//  Licensed to Elasticsearch B.V. under one or more contributor
//  license agreements. See the NOTICE file distributed with
//  this work for additional information regarding copyright
//  ownership. Elasticsearch B.V. licenses this file to you under
//  the Apache License, Version 2.0 (the "License"); you may
//  not use this file except in compliance with the License.
//  You may obtain a copy of the License at
//
// 	http://www.apache.org/licenses/LICENSE-2.0
//
//  Unless required by applicable law or agreed to in writing,
//  software distributed under the License is distributed on an
//  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
//  KIND, either express or implied.  See the License for the
//  specific language governing permissions and limitations
//  under the License.
//

module Tests.YamlRunner.OperationExecutor

open System
open System.Buffers.Text
open System.Collections.Specialized
open System.Linq
open System.IO
open System.Text
open System.Text.RegularExpressions
open System.Text.Unicode
open Microsoft.FSharp.Reflection
open Tests.YamlRunner.DoMapper
open Tests.YamlRunner.Models
open Tests.YamlRunner.Stashes
open ShellProgressBar
open OpenSearch.Net
open Newtonsoft.Json
open Newtonsoft.Json.Linq
open System.Collections.Generic

type ExecutionContext = {
    Version: string
    Suite: string
    Folder: DirectoryInfo
    File: FileInfo
    Section: string
    NthOperation: int
    Operation: Operation
    Stashes: Stashes
    Elapsed: int64 ref
}
    with member __.Throw message = failwithf "%s" message

type Fail =
    | SeenException of ExecutionContext * Exception 
    | ValidationFailure of ExecutionContext * string
    with
        member this.Context = match this with | SeenException (c, _) -> c | ValidationFailure (c, _) -> c
        member this.Log () =
            match this with
            | SeenException (_, e) -> sprintf "Exception: %s" e.Message 
            | ValidationFailure (_, r) -> sprintf "Reason: %s" r 
        static member private FormatFailure op fmt = ValidationFailure (op, sprintf "%s" fmt)
        static member Create op fmt = Printf.kprintf (fun x -> Fail.FormatFailure op x) fmt
        
type ExecutionResult =
    | Succeeded of ExecutionContext
    | Skipped of ExecutionContext * string
    | NotSkipped of ExecutionContext
    | Failed of Fail
    with
        member this.Name = match FSharpValue.GetUnionFields(this, this.GetType()) with | (case, _) -> case.Name
        member this.Context =
            match this with | Succeeded c -> c | NotSkipped c -> c | Skipped (c, _) -> c | Failed f -> f.Context
            
type JTokenOrFailure = Token of JToken | Fail of Fail

type OperationExecutor(client:IOpenSearchLowLevelClient) =

    member private this.OpMap = DoMapper.createDoMap client
    
    static member Set op s (progress:IProgressBar) = 
        let v (prop:ResponseProperty) id =
            let stashes = op.Stashes
            let (ResponseProperty prop) = prop
            match stashes.ResponseOption with
            | Some _ ->
                let v = stashes.GetResponseValue progress prop
                stashes.[id] <- v
                Succeeded op
            | None ->
                Failed <| Fail.Create op "Attempted to look up %s but no response was set prior" prop
        
        s |> Map.map v |> ignore 
        Succeeded op

    static member Do op d (lookup:YamlMap -> FastApiInvoke) progress = async {
        let stashes = op.Stashes
        let (api, data) = d.ApiCall
        try
            let invoke = lookup data
            let resolvedData = stashes.Resolve progress data
            let headers =
                let head (h:string) =
                    match h.Contains("$") with
                    | false -> h
                    | true ->
                        Regex.Replace(h, "\$\{?\w+\}?", fun r -> (stashes.ResolveToken progress r.Value).ToString())
                match d.Headers with
                | Some h ->
                    (h.AllKeys |> Seq.map(fun key -> key, head h.[key]))
                    |> Map.ofSeq
                    |> Map.fold
                      (fun (nv:NameValueCollection) k v ->
                        (nv.[k] <- v)
                        nv
                      )
                      (NameValueCollection())
                    |> Option.Some
                | None -> None
                    
            
            let! r = Async.AwaitTask <| invoke.Invoke resolvedData headers
            
            let responseMimeType = r.ApiCall.ResponseMimeType
            match responseMimeType with
            | RequestData.MimeType -> ignore() //json
            // not json set $body to the response body string
            | _ -> op.Stashes.[StashedId.Body] <- r.Get<String>("body")
            
            op.Stashes.ResponseOption <- Some r
            
            let result =
                let autoFailed = d.AutoFail && not r.Success
                let statusCode = if r.HttpStatusCode.HasValue then Some r.HttpStatusCode.Value else None
                match (autoFailed, d.Catch, statusCode) with
                | true, _, _ -> Failed <| Fail.Create op "AutoFail triggered on api: %s" api
                | false, None, _  -> Succeeded op
                | _, Some catch, statusCode ->
                    match (catch, statusCode) with
                    | BadRequest, Some s when s <> 400 ->
                        Failed <| Fail.Create op "Catch %A: expected 400 received %i" catch s
                    | Unauthorized, Some s when s <> 401 ->
                        Failed <| Fail.Create op "Catch %A: expected 401 received %i" catch s
                    | Forbidden, Some s when s <> 403 ->
                        Failed <| Fail.Create op "Catch %A: expected 403 received %i" catch s
                    | Missing, Some s when s <> 404 ->
                        Failed <| Fail.Create op "Catch %A: expected 403 received %i" catch s
                    | RequestTimeout, Some s when s <> 408 ->
                        Failed <| Fail.Create op "Catch %A: expected 408 received %i" catch s
                    | Conflict, Some s when s <> 409 ->
                        Failed <| Fail.Create op "Catch %A: expected 409 received %i" catch s
                    | Unavailable, Some s when s <> 503 ->
                        Failed <| Fail.Create op "Catch %A: expected 503 received %i" catch s
                    | OtherBadResponse, Some s when s < 400 || s >= 600 ->
                        Failed <| Fail.Create op "Catch %A: expected 4xx-5xx received %i" catch s
                    | (CatchRegex regexp), _ -> 
                        let body = System.Text.Encoding.UTF8.GetString(r.ApiCall.ResponseBodyInBytes)
                        match System.Text.RegularExpressions.Regex.IsMatch(body, regexp) with
                        | true -> Succeeded op
                        | false -> Failed <| Fail.Create op "Catching error failed: %O on server error" d.Catch 
                    | _ -> Succeeded op
            
            //progress.WriteLine <| sprintf "%s %s" (r.ApiCall.HttpMethod.ToString()) (r.Uri.PathAndQuery)
            return result
        with
        | ParamException e ->
            match d.Catch with
            | Some UnknownParameter -> return Succeeded op
            | _ ->
                return Failed <| Fail.Create op "%s %O" e d.Catch
                
    }
    
    static member TransformAndSet op (t:TransformAndSet) (progress:IProgressBar) =
        let call (prop:StashedId) (transform:Transformation) =
            let stashes = op.Stashes
            match transform.Function with
            | "base64EncodeCredentials" ->
                let encoded =
                    transform.Values
                    |> List.map (fun v ->
                        match v with
                        | ResponsePath p -> stashes.GetResponseValue<string> progress p 
                        | _ -> null
                    )
                    |> String.concat ":"
                    |> Encoding.UTF8.GetBytes
                    |> Convert.ToBase64String
                
                stashes.[prop] <- encoded
                
                Succeeded op
            | func ->
                Failed <| Fail.Create op "TransformAndSet unknown function: %s" func
            
        
        t |> Map.map (fun k v -> call k v.Transform) |> ignore 
        Succeeded op

    ///<summary>The specified key exists and has a true value (ie not 0, false, undefined, null or the empty string)</summary>
    static member IsTrue op (t:AssertOn) progress =
        let stashes = op.Stashes
        match t with
        | ResponsePath p ->
            let v = stashes.GetResponseValue progress p :> Object
            match v with
            | null -> Failed <| Fail.Create op "resolved to null"
            | :? string as s when String.IsNullOrEmpty s -> Failed <| Fail.Create op "string is null or empty"
            | :? bool as s when not s -> Failed <| Fail.Create op "returned bool is false"
            | :? int as s when s = 0 -> Failed <| Fail.Create op "int equals 0"
            | :? int64 as s when s = 0L -> Failed <| Fail.Create op "long equals 0"
            | _ -> Succeeded op
        | WholeResponse ->
            let r = stashes.Response()
            match r.HttpMethod, r.ApiCall.Success, r.Dictionary  with
            | (HttpMethod.HEAD, true, _) -> Succeeded op
            | (HttpMethod.HEAD, false, _) -> Failed <| Fail.Create op "HEAD request not successful"
            | (_,_, b) when b = null  -> Failed <| Fail.Create op "no body was returned"
            | _ -> Succeeded op
            
    static member IsFalse op (t:AssertOn) progress =
        let isTrue = OperationExecutor.IsTrue op t progress
        match isTrue with
        | Skipped (op, r) -> Skipped (op, r)
        | NotSkipped c -> NotSkipped c
        | Failed f -> Succeeded f.Context
        | Succeeded op ->
            Failed <| Fail.Create op "Expected is_false but got is_true behavior"
    
    
    static member ToJToken (t:Object) =
        match t with
        | null -> JValue(t) :> JToken
        | :? JToken as j -> j 
        // yaml test framework often compares ints with doubles, does not validate
        // actual numeric types returned
        | :? int 
        | :? int64 -> JValue(Convert.ToDouble(t)) :> JToken
        | :? Dictionary<Object, Object> as d -> JObject.FromObject(d) :> JToken
        | :? IDictionary<String, Object> as d -> JObject.FromObject(d) :> JToken
        | _ -> JToken.FromObject t
    
    static member JTokenDeepEquals op expected actual =
            let expected = OperationExecutor.ToJToken expected
            let actual = OperationExecutor.ToJToken actual
            match JToken.DeepEquals (expected, actual) with
            | false ->
                let a = actual.ToString(Formatting.None)
                let e = expected.ToString(Formatting.None)
                Failed <| Fail.Create op "expected: %s actual: %s" e a
            | _ -> Succeeded op
            
    // inspects `actual` is a list of objects and contains an object with the keys and value of `expected`
    static member JTokenContainsSubSet op expected actual =
            let expected = OperationExecutor.ToJToken expected
            let actual = OperationExecutor.ToJToken actual
            match actual.Type with
            | JTokenType.Array ->
                let array = actual :?> JArray
                let setOfKeys (o:JObject) = o.Properties() |> Seq.map(fun p -> p.Name) |> Set.ofSeq
                let expectedObj = expected :?> JObject
                let expectedKeys = setOfKeys expectedObj
                let misMatchedValues (o:JObject) = 
                    expectedObj.Properties()
                    |> Seq.map(fun prop -> (prop, JToken.DeepEquals (prop.Value, o.Property(prop.Name).Value)))
                    // filter all values that differ
                    |> Seq.filter (fun (_, equals) -> not equals)
                    |> Seq.map (fun (prop, _) -> (prop.Name, prop.Value))
                    |> Seq.toList
                let actualValues =
                    array
                    |> Seq.map(fun v -> v :?> JObject)
                    |> Seq.filter(fun o -> o <> null)
                    |> Seq.filter(fun o -> (setOfKeys o).IsSupersetOf(expectedKeys))
                    |> Seq.map(fun o -> misMatchedValues o)
                    |> List.ofSeq
                    
                match actualValues |> List.tryFind(fun (l) -> l.Length = 0) with
                | None ->
                    let a = actual.ToString(Formatting.None) 
                    let e = expected.ToString(Formatting.None)
                    Failed <| Fail.Create op "No object in actual: %s has proper subset of keys and values from expected: %s" a e
                | Some o ->
                    Succeeded op
            | _ -> 
                Failed <| Fail.Create op "Can not assert contains when actual is not an array: %s" (actual.ToString(Formatting.None))
        
    static member IsNumericMatch op (assertion:NumericAssert) (m:NumericMatch) progress =
        let stashes = op.Stashes
        let doMatch assertOn assertValue = 
            match assertOn with
            | WholeResponse ->
                Failed <| Fail.Create op "Can not do numeric asserts on whole responses" 
            | ResponsePath path ->
                let expected =
                    match assertValue with
                    | Long l -> Token(JValue(Convert.ToDouble(l)))
                    | Double d -> Token(JValue(d))
                    | NumericId id ->
                        let found, expected = stashes.TryGetValue id
                        match found with
                        | true -> Token(OperationExecutor.ToJToken expected)
                        | false -> Fail(Fail.Create op "%A not stashed at this point" id)
                        
                let expectedValue (value:JToken) =
                    match value with
                    | :? JValue as v -> Some(Convert.ChangeType(v.Value, typeof<double>) :?> double)
                    | :? JArray as a -> Some <| Convert.ToDouble(a.Count)
                    | :? JObject as o -> Some <| Convert.ToDouble(o.Properties().Count())
                    | _ -> None
                        
                let actual =
                    match path with 
                    | "$body" ->
                        let dictOrArray = stashes.Response().Dictionary.Count
                        Some <| Convert.ToDouble(dictOrArray)
                    | _ -> 
                        let a = OperationExecutor.ToJToken <| (stashes.GetResponseValue progress path :> Object)
                        expectedValue a
                
                let numMatch a e s c = 
                    let e = expectedValue e
                    match e with
                    | None -> Failed <| Fail.Create op "Can not get numeric value from expected %O" e
                    | Some e when c a e  -> Succeeded op 
                    | Some e -> Failed <| Fail.Create op "Expected %f %s %f " e s a
                
                match (assertion, actual, expected) with
                | (_, _, Fail f) -> Failed <| f
                | (_, None, _) -> Failed <| Fail.Create op "Can not get numeric value from actual %O" actual
                | (Equal, Some a, Token e) -> OperationExecutor.JTokenDeepEquals op e a 
                | (Length, Some a, Token e) -> numMatch a e "=" <| (fun a e -> a = e)
                | (LowerThan, Some a, Token e) -> numMatch a e "<" <| (fun a e -> a < e)
                | (GreaterThan, Some a, Token e)  -> numMatch a e ">" <| (fun a e -> a > e)
                | (GreaterThanOrEqual, Some a, Token e) -> numMatch a e ">=" <| (fun a e -> a >= e)
                | (LowerThanOrEqual, Some a, Token e)  -> numMatch a e "<=" <| (fun a e -> a <= e)
    
        let asserts =
            m
            |> Map.toList
            |> Seq.map (fun (k, v) -> doMatch k v)
            |> Seq.sortBy (fun ex -> match ex with | Succeeded _ -> 4 | Skipped _ -> 3 | NotSkipped _ -> 2 | Failed _ -> 1)
            |> Seq.toList
            
        asserts |> Seq.head
        
    static member IsMatch op (matchOp:Match) progress =
        let stashes = op.Stashes
        let doMatch assertOn assertValue = 
            let value =
                match (assertOn, assertValue) with
                | (ResponsePath "$body", Value _) -> stashes.Response().Dictionary.ToDictionary() :> Object
                | (ResponsePath path, _) -> stashes.GetResponseValue progress path :> Object
                | (WholeResponse, _) -> stashes.Response().Dictionary.ToDictionary() :> Object
            
            match assertValue with
            | Value o -> OperationExecutor.JTokenDeepEquals op o value
            | Id id ->
                let found, expected = stashes.TryGetValue id
                match found with
                | true -> OperationExecutor.JTokenDeepEquals op expected value
                | false -> Failed <| Fail.Create op "%A not stashed at this point" id 
            | RegexAssertion re ->
                match assertOn with
                | WholeResponse -> 
                    Failed <| Fail.Create op "regex can no t be called on the parsed body ('')"
                | ResponsePath _ -> 
                    let body = value.ToString()
                    let reg = 
                        let s = re.Regex.ToString()
                        match stashes.ReplaceStaches progress s with
                        | (false, _) -> re.Regex
                        | (true, s) -> Regex(s, RegexOptions.IgnorePatternWhitespace)
                            
                    let matched = reg.IsMatch(body)
                    match matched with
                    | true -> Succeeded op
                    | false -> Failed <| Fail.Create op "regex did not match body %s" body
                    
        let asserts =
            matchOp
            |> Map.toList
            |> Seq.map (fun (k, v) -> doMatch k v)
            |> Seq.sortBy (fun ex -> match ex with | Succeeded _ -> 4 | Skipped _ -> 3 | NotSkipped _ -> 2 | Failed _ -> 1)
            |> Seq.toList
            
        asserts |> Seq.head
        
    static member IsContains op (matchOp:Match) progress =
        let stashes = op.Stashes
        let doMatch assertOn assertValue = 
            let value =
                match (assertOn, assertValue) with
                | (ResponsePath "$body", Value _) -> stashes.Response().Dictionary.ToDictionary() :> Object
                | (ResponsePath path, _) -> stashes.GetResponseValue progress path :> Object
                | (WholeResponse, _) -> stashes.Response().Dictionary.ToDictionary() :> Object
            
            match assertValue with
            | Value o -> OperationExecutor.JTokenContainsSubSet op o value
            | Id id ->
                let found, expected = stashes.TryGetValue id
                match found with
                | true -> OperationExecutor.JTokenContainsSubSet op expected value
                | false -> Failed <| Fail.Create op "%A not stashed at this point" id 
            | RegexAssertion _ ->
                Failed <| Fail.Create op "regex assertion not supported in `contains`"
                
        let asserts =
            matchOp
            |> Map.toList
            |> Seq.map (fun (k, v) -> doMatch k v)
            |> Seq.sortBy (fun ex -> match ex with | Succeeded _ -> 4 | Skipped _ -> 3 | NotSkipped _ -> 2 | Failed _ -> 1)
            |> Seq.toList
            
        asserts |> Seq.head

    member this.Execute (op:ExecutionContext) (progress:IProgressBar) = async {
        match op.Operation with
        | Unknown u -> return Skipped (op, sprintf "Unknown operation: %s" u)
        | Actions (_, a) ->
            match a (client, op.Suite) with
            | None -> return Succeeded op
            | r ->
                op.Stashes.ResponseOption <- r
                return Failed <| Fail.Create op "%s" op.Section
        | Skip s ->
            let skip reason = Skipped (op, s.Reason |> Option.defaultValue reason)
            let versionRangeCheck (versions:SemanticVersioning.Range list) =
                let anchoredVersion =
                    let x = SemanticVersioning.Version(op.Version)
                    sprintf "%i.%i.%i" x.Major x.Minor x.Patch
                
                let versionInRange =
                    versions
                    |> List.tryFind (fun v -> v.IsSatisfied(op.Version) || v.IsSatisfied(anchoredVersion))
                
                match versionInRange with
                | Some v -> skip (sprintf "version:%s in range:%O" op.Version v)
                | None -> NotSkipped op
            let featureCheck (features:Feature list) =
                let unsupportedFeatures =
                    features
                    |> Seq.filter (fun feature -> not (SupportedFeatures |> List.contains feature))
                    |> Seq.toList
                
                match unsupportedFeatures with
                | [] -> NotSkipped op
                | l -> skip (sprintf "feature %O not supported" l)
            
            let result =
                match (s.Version, s.Features) with
                | (Some v, Some features) ->
                    match (versionRangeCheck v) with
                    | Skipped (op, r) -> Skipped (op, r)
                    | _ ->
                        featureCheck features
                | (Some v, None) -> 
                    versionRangeCheck v
                | (None, Some features) ->
                    featureCheck features
                | _  ->
                    NotSkipped op
    
            return result
        | Do d ->
            let (name, _) = d.ApiCall
            let found, lookup = this.OpMap.TryGetValue name
            if found then 
               return! OperationExecutor.Do op d lookup progress
            else 
                return Failed <| Fail.Create op "Api: %s not found on client" name 
        | Set s -> return OperationExecutor.Set op s progress
        | TransformAndSet t -> return OperationExecutor.TransformAndSet op t progress
        | Assert a ->
            return
                match a with
                | IsTrue t -> OperationExecutor.IsTrue op t progress
                | IsFalse t -> OperationExecutor.IsFalse op t progress
                | Contains m -> OperationExecutor.IsContains op m progress
                | Match m -> OperationExecutor.IsMatch op m progress
                | NumericAssert (a, m) -> OperationExecutor.IsNumericMatch op a m progress
              
    }