// Copyright 2021 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"
	"regexp"
	"strings"
	"text/template"
)

const codeTemplate string = `// tslint:disable
/* Code generated by openapi-gen-angular/main.go. DO NOT EDIT. */

import { Injectable, Optional } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';

{{- range $classname, $definition := .Definitions}}
    {{- if isRefToEnum $classname }}
{{ $enumHeader := enumSummary $definition -}}
{{ if exists $enumHeader }}
/** {{ $enumHeader }} */
{{- else }}

{{- end}}
export enum {{ $classname | title }} {
    {{- range $idx, $enum := $definition.Enum }}
  {{ $enum }} = {{ $idx }},
    {{- end }}
}
    {{- else }}
{{ if exists $definition.Description }}
/** {{$definition.Description}} */
{{- else }}

{{- end}}
export interface {{$classname | title}} {
				{{- range $key, $property := $definition.Properties}}
  {{- $fieldname := camelToSnake $key }}
	{{- if exists $property.Description }}
  // {{ $property.Description | removeNewline }}
	{{- else if exists $property.Title }}
  // {{ $property.Title | removeNewline }}
	{{- end }}
	{{$fieldname}}?: {{- $property | convertType -}}
				{{- end}}
}
    {{- end}}
{{- end }}

const DEFAULT_HOST = 'http://127.0.0.1:7120';
const DEFAULT_TIMEOUT_MS = 5000;

export class ConfigParams {
  host!: string
  timeoutMs!: number
}

@Injectable({providedIn: 'root'})
export class {{(index .Tags 0).Name}}Service {
	private readonly config;

  constructor(private httpClient: HttpClient, @Optional() config: ConfigParams) {
    const defaultConfig: ConfigParams = {
      host: DEFAULT_HOST,
      timeoutMs: DEFAULT_TIMEOUT_MS,
    };
    this.config = config || defaultConfig;
  }

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

  /** {{$operation.Summary}} */
	{{- $authFunction := "" }}
  {{ $operation.OperationId | snakeToCamel }}(
	{{- $hasSecurity := true -}}
	{{- if $operation.Security }}
    {{- with (index $operation.Security 0) }}
        {{- range $key, $value := . }}
          {{- if eq $key "BasicAuth" -}}
    		username: string, password: string
				{{- $authFunction = "getBasicAuthHeaders(username, password)" -}}
          {{- else if eq $key "HttpKeyAuth" -}}
    		auth_token: string
				{{- $authFunction = "getTokenAuthHeaders(auth_token)" -}}
					{{- else -}}
				{{- $hasSecurity = false -}}
          {{- end }}
        {{- end }}
    {{- end }}
  {{- else -}}
    auth_token: string
		{{- $authFunction = "getTokenAuthHeaders(auth_token)" -}}
  {{- end }}
	{{- $body := false -}}
  {{- range $index, $parameter := $operation.Parameters}}
		{{- if and (eq $index 0) (eq $hasSecurity true) -}}{{- ", " -}}{{- else if ne $index 0 -}}{{- ", " -}}{{- end -}}
    {{- $parameter.Name }}{{- if not $parameter.Required }}?{{- end -}}{{": "}}
          {{- if eq $parameter.In "path" -}}
    {{ $parameter.Type }}
          {{- else if eq $parameter.In "body" -}}
				{{- $body = true -}}
        {{- if eq $parameter.Schema.Type "string" -}}
    {{ $parameter.Schema.Type }}
				{{- else if eq $parameter.Schema.Type "object" -}}
    {
					{{- $i := 0 -}}
					  {{- range $bodyField, $bodyProp := $parameter.Schema.Properties}}
					{{- if ne $i 0 -}}{{- ", " -}}{{ end -}}
					{{- $bodyField | camelToSnake -}}{{"?: "}}{{- $bodyProp | convertType -}}
				  {{- $i = inc $i -}}
					  {{- end -}}
		}
        {{- else -}}
    {{ $parameter.Schema.Ref | cleanRef }}
        {{- end }}
          {{- else if eq $parameter.Type "array" -}}
    Array<{{$parameter.Items.Type}}>
          {{- else if eq $parameter.Type "object" -}}
    Map<{{$parameter.AdditionalProperties.Type}}>
          {{- else if eq $parameter.Type "integer" -}}
    number
          {{- else -}}
    {{ $parameter.Type }}
        {{- end -}}
  {{- end -}}
      ): Observable<{{- if $operation.Responses.Ok.Schema.Ref -}} {{- $operation.Responses.Ok.Schema.Ref | cleanRef -}} {{- else -}} any {{- end}}> {

		    {{- range $parameter := $operation.Parameters}}
      {{- if eq $parameter.In "path"}}
		{{ $parameter.Name }} = encodeURIComponent(String({{- $parameter.Name}}))
      {{- end}}
        {{- end}}
		const urlPath = {{ $url | convertPathToJs -}};
    let params = new HttpParams();
      {{- range $argument := $operation.Parameters -}}
			  {{if eq $argument.In "query"}}
    if ({{$argument.Name}}) {
      {{if eq $argument.Type "array" -}}
      {{$argument.Name}}.forEach(e => params = params.append('{{$argument.Name}}', String(e)))
      {{- else -}}
      params = params.set('{{$argument.Name}}', {{if eq $argument.Type "string" -}} {{$argument.Name}}{{else}}String({{$argument.Name}}){{- end}});
      {{- end}}
    }{{ end }}
	{{- end }}
    return this.httpClient.{{ $method }}{{- if $operation.Responses.Ok.Schema.Ref }}<{{ $operation.Responses.Ok.Schema.Ref | cleanRef }}>{{- end}}(this.config.host + urlPath{{- if eq $body true}}, body{{- end}}, { params: params{{- if ne $authFunction "" }}, headers: this.{{$authFunction}}{{- end}} })
  }
  {{- end}}
{{- end}}

  private getTokenAuthHeaders(token: string): HttpHeaders {
    return new HttpHeaders().set('Authorization', 'Bearer ' + token);
  }

  private getBasicAuthHeaders(username: string, password: string): HttpHeaders {
    return new HttpHeaders().set('Authorization', 'Basic ' + btoa(username + ':' + password));
  }
}
`

func snakeToCamel(input string) (snakeToCamel string) {
	isToUpper := false
	for k, v := range input {
		if k == 0 {
			snakeToCamel = strings.ToLower(string(input[0]))
		} else {
			if isToUpper {
				snakeToCamel += strings.ToUpper(string(v))
				isToUpper = false
			} else {
				if v == '_' {
					isToUpper = true
				} else {
					snakeToCamel += string(v)
				}
			}
		}
	}
	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) {
	return def.Enum
}

func convertRefToClassName(prefixesToRemove []string) func(string) string {
	return func(input string) string {
		cleanRef := strings.TrimPrefix(input, "#/definitions/")
		for _, prefix := range prefixesToRemove {
			if strings.HasPrefix(cleanRef, prefix) {
				cleanRef = strings.TrimPrefix(cleanRef, prefix)
				break
			}
		}

		return strings.Title(cleanRef)
	}
}

// camelToPascal converts a string from camel case to Pascal case.
func camelToPascal(camelCase string) (pascalCase string) {

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

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

func camelToSnake(input string) (output string) {
	var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
	var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")

	snake := matchFirstCap.ReplaceAllString(input, "${1}_${2}")
	snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
	return strings.ToLower(snake)
}

// pascalToCamel converts a Pascal case string to a camel case string.
func pascalToCamel(input string) (camelCase string) {
	if input == "" {
		return ""
	}

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

func main() {
	// Argument flags
	var input = flag.String("i", "", "The input openapi file path.")
	var output = flag.String("o", "", "The output path for generated code.")
	var rm_prefix = flag.String("rm_prefix", "", "The prefixes to remove from names.")
	flag.Parse()

	if *input == "" {
		fmt.Printf("No input file found: %s\n", *input)
		flag.PrintDefaults()
		return
	}

	prefixesToRemove := strings.Split(*rm_prefix, ",")

	var schema = &Swagger{}

	convertRefToClassNameFunc := convertRefToClassName(prefixesToRemove)
	fmap := template.FuncMap{
		"enumDescriptions": enumDescriptions,
		"enumSummary":      enumSummary,
		"snakeToCamel":     snakeToCamel,
		"cleanRef":         convertRefToClassNameFunc,
		"isRefToEnum": func(ref string) bool {
			// 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
		},
		"title":           strings.Title,
		"camelToSnake":    camelToSnake,
		"uppercase":       strings.ToUpper,
		"convertType":     convertType(prefixesToRemove, convertRefToClassNameFunc),
		"convertPathToJs": convertPathToJs,
		"inc":             func(i int) int { return i + 1 },
		"removeNewline":   func(s string) string { return strings.Replace(s, "\n", " / ", -1) },
		"exists":          func(s string) bool { return s != "" },
	}

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

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

	interfacesToRemove := []string{"googlerpcStatus", "rpcStatus", "protobufAny"}
	adjustSchemaData(schema, prefixesToRemove, interfacesToRemove)
	createBodyTypes(schema)

	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 {
		err := tmpl.Execute(os.Stdout, &schema)
		if err != nil {
			fmt.Printf("Template execute error: %s\n", err)
			return
		}
		return
	}

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

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

func convertType(prefixesToRemove []string, convertRefToClassName func(string) string) func(Property) (tsType string) {
	return func(prop Property) (tsType string) {
		switch prop.Type {
		case "string":
			return "string"
		case "integer":
			fallthrough
		case "number":
			return "number"
		case "boolean":
			return "boolean"
		case "array":
			switch prop.Items.Type {
			case "string":
				return "Array<string>"
			case "integer":
				fallthrough
			case "number":
				return "Array<number>"
			case "boolean":
				return "Array<boolean>"
			default:
				return "Array<" + convertRefToClassName(prop.Items.Ref) + ">"
			}
		case "object":
			switch prop.AdditionalProperties.Type {
			case "string":
				return "Map<string, string>"
			case "integer":
				fallthrough
			case "number":
				return "Map<string, number>"
			case "boolean":
				return "Map<string, boolean>"
			default:
				return "Map<string, " + convertRefToClassName(prop.AdditionalProperties.Ref) + ">"
			}
		default:
			return convertRefToClassName(prop.Ref)
		}
	}
}

// Converts a path with params to a JS interpolated string
// E.g.: "/v1/builder/{name}/user/{user_id}" becomes `/v1/builder/${name}/user/${user_id}`
func convertPathToJs(path string) string {
	// Regex to identify variables within brackets e.g.: {foo}/baz/{bar}
	findBracketVarsReg := regexp.MustCompile("{(.+?)}")
	matches := findBracketVarsReg.FindAllStringSubmatch(path, -1)
	jsPath := fmt.Sprintf("`%s`", path)
	if len(matches) > 0 {
		for _, m := range matches {
			jsPath = strings.Replace(jsPath, m[0], fmt.Sprintf("$%s", m[0]), 1)
		}
	}
	return jsPath
}

func createBodyTypes(schema *Swagger) {
	//replace fields with new type
	for _, path := range schema.Paths {
		for _, operation := range path {
			for _, param := range operation.Parameters {
				if param.In == "body" && param.Schema.Type == "object" {
					newType := operation.OperationId + "Request"
					//copy it to main Definitions
					schema.Definitions[newType] = &Definition{
						Properties: param.Schema.Properties,
					}
					//replace it with new reference
					param.Schema = Schema{
						Ref: newType,
					}
				}
			}
		}
	}
}

func adjustSchemaData(schema *Swagger, prefixesToRemove []string, interfacesToRemove []string) {
	adjustProps := func(props map[string]*Property) {
		for _, prop := range props {
			// check field array ref type
			for _, prefix := range prefixesToRemove {
				p := "#/definitions/" + prefix
				if strings.HasPrefix(prop.Items.Ref, p) {
					prop.Items.Ref = strings.TrimPrefix(prop.Items.Ref, p)
					break
				}
			}
			// check field ref type
			for _, prefix := range prefixesToRemove {
				p := "#/definitions/" + prefix
				if strings.HasPrefix(prop.Ref, p) {
					prop.Ref = strings.TrimPrefix(prop.Ref, p)
					break
				}
			}
		}
	}

	for name, def := range schema.Definitions {
		adjustProps(def.Properties)
		for _, prefix := range prefixesToRemove {
			// check interface/enum name
			if strings.HasPrefix(name, prefix) {
				delete(schema.Definitions, name)
				schema.Definitions[strings.TrimPrefix(name, prefix)] = def
				break
			}
		}
		for _, i := range interfacesToRemove {
			if name == i {
				delete(schema.Definitions, name)
				break
			}
		}
	}
	for _, path := range schema.Paths {
		for _, operation := range path {
			// check function names
			for _, prefix := range prefixesToRemove {
				if strings.HasPrefix(operation.OperationId, prefix) {
					operation.OperationId = strings.TrimPrefix(operation.OperationId, prefix)
					break
				}
			}
			// check function return types
			for _, prefix := range prefixesToRemove {
				p := "#/definitions/" + prefix
				if strings.HasPrefix(operation.Responses.Ok.Schema.Ref, p) {
					operation.Responses.Ok.Schema.Ref = strings.TrimPrefix(operation.Responses.Ok.Schema.Ref, p)
					break
				}
			}
			for _, prefix := range prefixesToRemove {
				p := "#/definitions/" + prefix
				for _, param := range operation.Parameters {
					// check properties on body
					adjustProps(param.Schema.Properties)
					// check $ref on body
					if strings.HasPrefix(param.Schema.Ref, p) {
						param.Schema.Ref = strings.TrimPrefix(param.Schema.Ref, p)
						break
					}
				}
			}
		}
	}
}
