// Copyright 2018 The Nakama Authors
//
// Licensed 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.

package main

import (
	"bufio"
	"encoding/json"
	"flag"
	"fmt"
	"io/ioutil"
	"os"
	"strings"
	"text/template"
)

var utilities = map[string]string {
	"ApiAccount":
`

	var _wallet_dict = null
	var wallet_dict : Dictionary:
		get:
			if _wallet_dict == null:
				if _wallet == null:
					return {}
				var json = JSON.new()
				if json.parse(_wallet) != OK:
					return {}
				_wallet_dict = json.get_data()
			return _wallet_dict as Dictionary
`,
}

const codeTemplate string = `### Code generated by codegen/main.go. DO NOT EDIT. ###

extends RefCounted
class_name NakamaAPI
{{- range $defname, $definition := .Definitions }}
{{- $classname := $defname | title }}
{{- if isRefToEnum $classname }}

# {{ enumSummary $definition | stripNewlines }}
{{- range $idx, $val := ($definition | enumDescriptions) }}
# {{ $val }}
{{- end -}}
# {{ $definition | enumDescriptions }}
enum {{ $classname | title }} { {{- range $idx, $enum := $definition.Enum }}{{ $enum }} = {{ $idx }},{{- end -}} }
{{- else }}

# {{ $definition.Description | stripNewlines }}
class {{ $classname }} extends NakamaAsyncResult:

	const _SCHEMA = {
		{{- range $propname, $property := $definition.Properties }}
		{{- $fieldname := $propname | pascalToSnake }}
		{{- $_field := printf "_%s" $fieldname }}
		{{- $gdType := godotType $property.Type $property.Ref $property.Items.Type $property.Items.Ref (isRefToEnum (cleanRef $property.Ref)) }}
		"{{ $fieldname }}": {"name": "{{ $_field }}", "type": {{ $gdType | godotSchemaType }}, "required": false
		{{- if eq $property.Type "array" -}}
			, "content": {{ (godotType $property.Items.Type $property.Items.Ref "" "" false) | godotSchemaType }}
		{{- else if eq $property.Type "object" -}}
			, "content": {{ (godotType $property.AdditionalProperties.Type "" "" "" false) | godotSchemaType  }}
		{{- end -}}
		},
		{{- end }}
	}

        {{- range $propname, $property := $definition.Properties }}
        {{- $fieldname := $propname | pascalToSnake }}
        {{- $_field := printf "_%s" $fieldname }}
        {{- $gdType := godotType $property.Type $property.Ref $property.Items.Type $property.Items.Ref (isRefToEnum (cleanRef $property.Ref)) }}
	{{- $gdDef := $gdType | godotDef }}

	# {{ $property.Description }}
	var {{ $_field }}
	var {{ $fieldname }} : {{ $gdType }}:
		get:
		{{- if $property.Ref }}
			{{- if isRefToEnum (cleanRef $property.Ref) }}{{/* Enums */}}
			return {{ cleanRef $property.Ref }}.values()[0] if not {{ cleanRef $property.Ref }}.values().has({{ $_field }}) else {{ $_field }}
			{{- else }}{{/* Object reference */}}
			return _{{ $fieldname }} as {{ $gdType }}
			{{- end }}
		{{- else if eq $property.Type "object"}}{{/* Dictionaries */}}
			return Dictionary() if not {{ $_field }} is Dictionary else {{ $_field }}.duplicate()
		{{- else }}{{/* Simple type */}}
			return {{ $gdDef }} if not {{ $_field }} is {{ $gdType }} else {{ $gdType }}({{ $_field }})
		{{- end }}
		{{- end }}

	{{- godotClassUtils $classname }}

	func _init(p_exception = null):
		super(p_exception)

	static func create(p_ns : GDScript, p_dict : Dictionary) -> {{ $classname }}:
		return _safe_ret(NakamaSerializer.deserialize(p_ns, "{{ $classname }}", p_dict), {{ $classname }}) as {{ $classname }}

	func serialize() -> Dictionary:
		return NakamaSerializer.serialize(self)

	func _to_string() -> String:
		if is_exception():
			return get_exception()._to_string()
		var output : String = ""
            {{- range $propname, $property := $definition.Properties }}
            {{- $fieldname := $propname | pascalToSnake }}
            {{- $_field := printf "_%s" $fieldname }}
            {{- if eq $property.Type "array" }}
		output += "{{ $fieldname }}: %s, " % [{{ $_field }}]
            {{- else if eq $property.Type "object" }}
		var map_string : String = ""
		if typeof({{ $_field }}) == TYPE_DICTIONARY:
			for k in {{ $_field }}:
				map_string += "{%s=%s}, " % [k, {{ $_field }}[k]]
		output += "{{ $fieldname }}: [%s], " % map_string
            {{- else }}
		output += "{{ $fieldname }}: %s, " % {{ $_field }}
            {{- end }}
            {{- end }}
		return output
    {{- end }}
{{- end }}

# The low level client for the Nakama API.
class ApiClient extends RefCounted:

	var _base_uri : String

	var _http_adapter
	var _namespace : GDScript
	var _server_key : String
	var auto_refresh := true
	var auto_refresh_time := 300

	var auto_retry : bool:
		set(p_value):
			_http_adapter.auto_retry = p_value
		get:
			return _http_adapter.auto_retry

	var auto_retry_count : int:
		set(p_value):
			_http_adapter.auto_retry_count = p_value
		get:
			return _http_adapter.auto_retry_count

	var auto_retry_backoff_base : int:
		set(p_value):
			_http_adapter.auto_retry_backoff_base = p_value
		get:
			return _http_adapter.auto_retry_backoff_base

	var last_cancel_token:
		get:
			return _http_adapter.get_last_token()

	func _init(p_base_uri : String, p_http_adapter, p_namespace : GDScript, p_server_key : String, p_timeout : int = 10):
		_base_uri = p_base_uri
		_http_adapter = p_http_adapter
		_http_adapter.timeout = p_timeout
		_namespace = p_namespace
		_server_key = p_server_key

	func _refresh_session(p_session : NakamaSession):
		if auto_refresh and p_session.is_valid() and p_session.refresh_token and not p_session.is_refresh_expired() and p_session.would_expire_in(auto_refresh_time):
			var request = ApiSessionRefreshRequest.new()
			request._token = p_session.refresh_token
			return await session_refresh_async(_server_key, "", request)
		return null

	func cancel_request(p_token):
		if p_token:
			_http_adapter.cancel_request(p_token)

        {{- range $url, $path := .Paths }}
        {{- range $method, $operation := $path}}

	# {{ $operation.Summary | stripNewlines }}
        {{- if $operation.Responses.Ok.Schema.Ref }}
	func {{ $operation.OperationId | apiFuncName }}_async(
        {{- else }}
	func {{ $operation.OperationId | apiFuncName }}_async(
        {{- end}}

        {{- if $operation.Security }}
        {{- with (index $operation.Security 0) }}
            {{- range $key, $value := . }}
                {{- if eq $key "BasicAuth" }}
		p_basic_auth_username : String
		, p_basic_auth_password : String
                {{- else if eq $key "HttpKeyAuth" }}
		p_bearer_token : String
                {{- end }}
            {{- end }}
        {{- end }}
        {{- else }}
		p_session : NakamaSession
        {{- end }}

        {{- range $parameter := $operation.Parameters }}
        {{- $argument := $parameter.Name | prependParameter }}
	{{- if not $parameter.Required }}{{/* Godot does not support typed optional parameters yet. */}}
		, {{ $argument }} = null # : {{ $parameter.Type }}
        {{- else if eq $parameter.In "body" }}
            {{- if eq $parameter.Schema.Type "string" }}
		, {{ $argument }} : String
            {{- else }}
		, {{ $argument }} : {{ $parameter.Schema.Ref | cleanRef }}
            {{- end }}
        {{- else }}
		, {{ $argument }} : {{ godotType $parameter.Type $parameter.Schema.Ref $parameter.Items.Type "" (isRefToEnum (cleanRef $parameter.Schema.Ref)) }}
        {{- end }}
	{{- end }}
	)
	{{- if $operation.Responses.Ok.Schema.Ref }} -> {{ $operation.Responses.Ok.Schema.Ref | cleanRef }}
	{{- else }} -> NakamaAsyncResult
	{{- end }}:
        {{- $classname := "NakamaAsyncResult" }}
        {{- if $operation.Responses.Ok.Schema.Ref }}
          {{- $classname = $operation.Responses.Ok.Schema.Ref | cleanRef }}
        {{- end }}
        {{- if not $operation.Security }}
		var try_refresh = await _refresh_session(p_session)
		if try_refresh != null:
			if try_refresh.is_exception():
				return {{ $classname }}.new(try_refresh.get_exception())
			await p_session.refresh(try_refresh)
        {{- end }}
		var urlpath : String = "{{- $url }}"
            {{- range $parameter := $operation.Parameters }}
            {{- $argument := $parameter.Name | prependParameter }}
            {{- if eq $parameter.In "path" }}
		urlpath = urlpath.replace("{{- print "{" $parameter.Name "}"}}", NakamaSerializer.escape_http({{ $argument }}))
            {{- end }}
            {{- end }}
		var query_params = ""
            {{- range $parameter := $operation.Parameters }}
            {{- $argument := $parameter.Name | prependParameter }}
            {{- $snakecase := $parameter.Name | pascalToSnake }}
            {{- if eq $parameter.In "query"}}
            {{- if $parameter.Required }}
		if true: # Hack for static checks
            {{- else }}
		if {{ $argument }} != null:
            {{- end }}
                {{- if eq $parameter.Type "integer" }}
			query_params += "{{- $snakecase }}=%d&" % {{ $argument }}
                {{- else if eq $parameter.Type "string" }}
			query_params += "{{- $snakecase }}=%s&" % NakamaSerializer.escape_http({{ $argument }})
                {{- else if eq $parameter.Type "boolean" }}
			query_params += "{{- $snakecase }}=%s&" % str(bool({{ $argument }})).to_lower()
                {{- else if eq $parameter.Type "array" }}
			for elem in {{ $argument }}:
				query_params += "{{- $snakecase }}=%s&" % elem
                {{- else }}
		{{ $parameter }} // ERROR
                {{- end }}
            {{- end }}
            {{- end }}
		var uri = "%s%s%s" % [_base_uri, urlpath, "?" + query_params if query_params else ""]
		var method = "{{- $method | uppercase }}"
		var headers = {}
            {{- if $operation.Security }}
            {{- with (index $operation.Security 0) }}
                {{- range $key, $value := . }}
                    {{- if eq $key "BasicAuth" }}
		var credentials = Marshalls.utf8_to_base64(p_basic_auth_username + ":" + p_basic_auth_password)
		var header = "Basic %s" % credentials
		headers["Authorization"] = header
                    {{- else if eq $key "HttpKeyAuth" }}
		if (p_bearer_token):
			var header = "Bearer %s" % p_bearer_token
			headers["Authorization"] = header
                    {{- end }}
                {{- end }}
            {{- end }}
            {{- else }}
		var header = "Bearer %s" % p_session.token
		headers["Authorization"] = header
            {{- end }}

		var content : PackedByteArray
            {{- range $parameter := $operation.Parameters }}
            {{- $argument := $parameter.Name | prependParameter }}
            {{- if eq $parameter.In "body" }}
                {{- if eq $parameter.Schema.Type "string" }}
		content = JSON.stringify({{ $argument }}).to_utf8_buffer()
                {{- else }}
		content = JSON.stringify({{ $argument }}.serialize()).to_utf8_buffer()
                {{- end }}
            {{- end }}
            {{- end }}

		var result = await _http_adapter.send_async(method, uri, headers, content)
		if result is NakamaException:
			return {{ $classname }}.new(result)

            {{- if $operation.Responses.Ok.Schema.Ref }}
		var out : {{ $classname }} = NakamaSerializer.deserialize(_namespace, "{{ $classname }}", result)
		return out
            {{- else }}
		return NakamaAsyncResult.new()
            {{- end}}
{{- end }}
{{- end }}
`

func convertRefToClassName(input string) (className string) {
	cleanRef := strings.TrimPrefix(input, "#/definitions/")
	className = strings.Title(cleanRef)
	return
}

func stripNewlines(input string) (output string) {
	output = strings.Replace(input, "\n", " ", -1)
	return
}

func prependParameter(input string) (output string) {
	output = "p_" + pascalToSnake(input)
	return
}

func pascalToSnake(input string) (output string) {
	output = ""
	prev_low := false
	for _, v := range input {
		is_cap := v >= 'A' && v <= 'Z'
		is_low := v >= 'a' && v <= 'z'
		if is_cap && prev_low {
			output = output + "_"
		}
		output += strings.ToLower(string(v))
		prev_low = is_low
	}
	return
}

func apiFuncName(input string) (output string) {
	output = pascalToSnake(input[7:])
	return
}

func godotType(p_type string, p_ref string, p_item_type string, p_extra string, p_is_enum bool) (out string) {

	is_array := false
	is_dict := false
	switch p_type {
		case "integer":
			out = "int"
		case "string":
			out = "String"
		case "boolean":
			out = "bool"
		case "array":
			is_array = true
		case "object":
			is_dict = true
		default:
			if p_is_enum {
				out = "int"
			} else {
				out = convertRefToClassName(p_ref)
			}
	}

	if is_array {
		switch p_item_type {
			case "integer":
				out = "PackedIntArray"
				return
			case "string":
				out = "PackedStringArray"
				return
			case "boolean":
				out = "PackedIntArray"
				return
			default:
				out = "Array"
		}
	}
	if is_dict {
		out = "Dictionary"
	}
	return
}

func godotDef(p_type string) (out string) {
	switch(p_type) {
		case "bool": out = "false"
		case "int": out = "0"
		case "String": out = "\"\""
		case "PackedIntArray": out = "PackedIntArray()"
		case "PackedStringArray": out = "PackedStringArray()"
		case "Array": out = "Array()"
		case "Dictionary": out = "Dictionary()"
	}
	return
}

func godotLooseType(p_type string) (out string) {
	switch(p_type) {
		case "PackedStringArray", "PackedIntArray":
			out = "Array"
		default:
			out = p_type
	}
	return
}

func godotSchemaType(p_type string) (out string) {
	out = "TYPE_"
	switch(p_type) {
		case "bool": out += "BOOL"
		case "int": out += "INT"
		case "String": out += "STRING"
		case "PackedIntArray": out += "ARRAY"
		case "PackedStringArray": out += "ARRAY"
		case "Array": out += "ARRAY"
		case "Dictionary": out += "DICTIONARY"
		default: out = "\"" + p_type + "\""
	}
	return
}

func pascalToCamel(input string) (camelCase string) {
	if input == "" {
		return ""
	}

	camelCase = strings.ToLower(string(input[0]))
	camelCase += string(input[1:])
	return camelCase
}

func camelToPascal(camelCase string) (pascalCase string) {

	if len(camelCase) <= 0 {
		return ""
	}

	pascalCase = strings.ToUpper(string(camelCase[0])) + camelCase[1:]
	return
}

func enumSummary(def Definition) string {
	// quirk of swagger generation: if enum doesn't have a title
	// then the title can be found as the first entry in the split description.
	if def.Title != "" {
		return def.Title
	}

	split := strings.Split(def.Description, "\n")

	if len(split) <= 0 {
		panic("No newlines in enum description found.")
	}

	return split[0]
}

func enumDescriptions(def Definition) (output []string) {

	split := strings.Split(def.Description, "\n")

	if len(split) <= 0 {
		panic("No newlines in enum description found.")
	}

	if def.Title != "" {
		return split
	}

	// quirk of swagger generation: if enum doesn't have a title
	// then the title can be found as the first entry in the split description.
	// so ignore for individual enum descriptions.
	return split[2:]
}

type Definition struct {
	Properties map[string]struct {
		Type  string
		Ref   string   `json:"$ref"` // used with object
		Items struct { // used with type "array"
			Type string
			Ref  string `json:"$ref"`
		}
		AdditionalProperties struct {
			Type string // used with type "map"
		}
		Format      string // used with type "boolean"
		Description string
	}
	Enum        []string
	Description string
	// used only by enums
	Title string
}

func godotClassUtils(p_name string) string {
	if val, ok := utilities[p_name]; ok {
		return val
	}
	return ""
}

func main() {
	// Argument flags
	var output = flag.String("output", "", "The output for generated code.")
	flag.Parse()

	inputs := flag.Args()
	if len(inputs) < 1 {
		fmt.Printf("No input file found: %s\n\n", inputs)
		fmt.Println("openapi-gen [flags] inputs...")
		flag.PrintDefaults()
		return
	}

	input := inputs[0]
	content, err := ioutil.ReadFile(input)
	if err != nil {
		fmt.Printf("Unable to read file: %s\n", err)
		return
	}

	var schema struct {
		Paths map[string]map[string]struct {
			Summary     string
			OperationId string
			Responses   struct {
				Ok struct {
					Schema struct {
						Ref string `json:"$ref"`
					}
				} `json:"200"`
			}
			Parameters []struct {
				Name     string
				In       string
				Required bool
				Type     string   // used with primitives
				Items    struct { // used with type "array"
					Type string
				}
				Schema struct { // used with http body
					Type string
					Ref  string `json:"$ref"`
				}
                Format   string // used with type "boolean"
			}
			Security []map[string][]struct {
			}
		}
		Definitions map[string]Definition
	}

	if err := json.Unmarshal(content, &schema); err != nil {
		fmt.Printf("Unable to decode input %s : %s\n", input, err)
		return
	}

	fmap := template.FuncMap{
		"cleanRef": convertRefToClassName,
		"stripNewlines": stripNewlines,
		"title": strings.Title,
		"uppercase": strings.ToUpper,
		"prependParameter": prependParameter,
		"pascalToSnake": pascalToSnake,
		"apiFuncName": apiFuncName,
		"godotType": godotType,
		"godotLooseType": godotLooseType,
		"godotSchemaType": godotSchemaType,
		"godotDef": godotDef,
		"isRefToEnum": func(ref string) bool {
			if len(ref) == 0 {
				return false
			}
			// swagger schema definition keys have inconsistent casing
			var camelOk bool
			var pascalOk bool
			var enums []string

			asCamel := pascalToCamel(ref)
			if _, camelOk = schema.Definitions[asCamel]; camelOk {
				enums = schema.Definitions[asCamel].Enum
			}

			asPascal := camelToPascal(ref)
			if _, pascalOk = schema.Definitions[asPascal]; pascalOk {
				enums = schema.Definitions[asPascal].Enum
			}

			if !pascalOk && !camelOk {
				fmt.Printf("no definition found: %v", ref)
				return false
			}

			return len(enums) > 0
		},
		"enumDescriptions": enumDescriptions,
		"enumSummary":      enumSummary,
		"godotClassUtils": godotClassUtils,
	}
	tmpl, err := template.New(input).Funcs(fmap).Parse(codeTemplate)
	if err != nil {
		fmt.Printf("Template parse error: %s\n", err)
		return
	}

	if len(*output) < 1 {
		tmpl.Execute(os.Stdout, schema)
		return
	}

	f, err := os.Create(*output)
	if err != nil {
		fmt.Printf("Unable to create file: %s\n", err)
		return
	}
	defer f.Close()

	writer := bufio.NewWriter(f)
	tmpl.Execute(writer, schema)
	writer.Flush()
}
