package main

import (
	"errors"
	"flag"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"

	"github.com/aiven/go-api-schemas/pkg/dist"
	"github.com/dave/jennifer/jen"
	"golang.org/x/exp/maps"
	"golang.org/x/exp/slices"
	"golang.org/x/tools/imports"
	"gopkg.in/yaml.v3"
)

const (
	userConfigSuffix = "_user_config"
	destPath         = "./internal/sdkprovider/userconfig/"
	localPrefix      = "github.com/aiven/terraform-provider-aiven"
	importSchema     = "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
	importDiff       = "github.com/aiven/terraform-provider-aiven/internal/sdkprovider/userconfig/diff"
	importValidation = "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
	codeGenerated    = "Code generated by user config generator. DO NOT EDIT."
	noResetWarning   = "**Warning:** There's no way to reset advanced configuration options to default. Options that you add cannot be removed later."
)

func main() {
	var serviceList, integrationList, endpointList string
	flag.StringVar(&serviceList, "excludeServices", "", "Comma separated list of names to exclude from generation")
	flag.StringVar(&integrationList, "excludeIntegrations", "", "Comma separated list of names to exclude from generation")
	flag.StringVar(&endpointList, "excludeEndpoints", "", "Comma separated list of names to exclude from generation")
	flag.Parse()

	err := generate("service", dist.ServiceTypes, strings.Split(serviceList, ","))
	if err != nil {
		log.Fatalf("generating services: %s", err)
	}

	err = generate("serviceintegration", dist.IntegrationTypes, strings.Split(integrationList, ","))
	if err != nil {
		log.Fatalf("generating integrations: %s", err)
	}

	err = generate("serviceintegrationendpoint", dist.IntegrationEndpointTypes, strings.Split(endpointList, ","))
	if err != nil {
		log.Fatalf("generating integration endpoints: %s", err)
	}
}

func generate(kind string, data []byte, exclude []string) error {
	// Fixes imports order
	imports.LocalPrefix = localPrefix

	var sch map[string]*object
	err := yaml.Unmarshal(data, &sch)
	if err != nil {
		return err
	}

	dirPath := filepath.Join(destPath, kind)
	err = os.MkdirAll(dirPath, os.ModePerm)
	if err != nil {
		return err
	}

	keys := maps.Keys(sch)
	slices.Sort(keys)
	doneKeys := make([]string, 0, len(keys))
	doneNames := make([]string, 0, len(keys))

	fieldMapping := jen.Dict{}
	for _, key := range keys {
		if slices.Contains(exclude, key) {
			log.Printf("skipping %q: in exclude list", key)
			continue
		}

		o, ok := sch[key]
		if !ok {
			return fmt.Errorf("key %q not found in spec", key)
		}

		if len(o.Properties) == 0 {
			log.Printf("skipping %q: empty root object", key)
			continue
		}

		root := key + userConfigSuffix
		o.isRoot = true
		o.init(root)
		if o.Description == "" {
			o.Description = toUpperFirst(o.camelName) + " user configurable settings"
		}

		o.Description = joinSentences(o.Description, noResetWarning)

		doneKeys = append(doneKeys, key)
		doneNames = append(doneNames, o.camelName)

		f := jen.NewFile(kind)
		f.HeaderComment(codeGenerated)
		f.ImportName(importSchema, "schema")
		f.ImportName(importDiff, "diff")
		f.ImportName(importValidation, "validation")
		err := genSchema(f, o)
		if err != nil {
			return fmt.Errorf("error generating %q: %w", key, err)
		}

		// Some fields have dots and dashes in naming
		thisMapping := jen.Dict{}
		genFieldMapping(thisMapping, o)
		if len(thisMapping) > 0 {
			fieldMapping[jen.Lit(key)] = jen.Values(thisMapping)
		}

		// Sorts imports
		b, err := imports.Process("", []byte(f.GoString()), nil)
		if err != nil {
			return err
		}

		// Saves file
		err = os.WriteFile(filepath.Join(dirPath, key+".go"), b, 0o644)
		if err != nil {
			return err
		}
	}

	cases := make([]jen.Code, 0, len(keys)+1)
	for i, k := range doneKeys {
		cases = append(cases, jen.Case(jen.Lit(k)).Block(
			jen.Return(jen.Id(doneNames[i]).Call()),
		))
	}

	// Panics if unknown kind requested
	cases = append(cases, jen.Default().Block(jen.Return(jen.Nil())))

	f := jen.NewFile(kind)
	f.HeaderComment(codeGenerated)
	f.ImportName(importSchema, "schema")
	f.Func().Id("GetUserConfig").Params(jen.Id("kind").String()).Op("*").Qual(importSchema, "Schema").Block(
		jen.Switch(jen.Id("kind")).Block(cases...),
	)

	mappingFunc := "GetFieldMapping"
	f.Comment(mappingFunc + " returns TF fields to Json fields mapping (in unix-path way)")
	f.Func().Id(mappingFunc).Params(jen.Id("kind").String()).Map(jen.String()).String().Block(
		jen.Return(jen.Map(jen.String()).Map(jen.String()).String().Values(fieldMapping).Index(jen.Id("kind"))),
	)

	configTypes := make([]jen.Code, 0)
	for _, v := range doneKeys {
		configTypes = append(configTypes, jen.Lit(v))
	}
	f.Func().Id("UserConfigTypes").Params().Index().String().Block(
		jen.Return(jen.Index().String().Values(configTypes...)),
	)
	return f.Save(filepath.Join(dirPath, kind+".go"))
}

func genFieldMapping(mapping jen.Dict, o *object) {
	for _, p := range o.Properties {
		if p.tfName != p.jsonName {
			mapping[jen.Lit(p.path())] = jen.Lit(p.jsonPath())
		}
		if p.isObject() {
			genFieldMapping(mapping, p)
		}
		if p.isArray() && p.ArrayItems.isObject() {
			genFieldMapping(mapping, p.ArrayItems)
		}
	}
}

func genSchema(f *jen.File, o *object) error {
	values, err := getSchemaValues(o)
	if err != nil {
		return err
	}

	f.Func().Id(o.camelName).Params().Op("*").Qual(importSchema, "Schema").Block(
		jen.Return(jen.Op("&").Qual(importSchema, "Schema").Values(values)),
	)
	return nil
}

var errUnknownType = fmt.Errorf("unknown type")

func getSchemaValues(o *object) (jen.Dict, error) {
	values := make(jen.Dict)

	if d := getDescription(o); d != "" {
		for old, n := range replaceDescriptionSubStrings {
			d = strings.ReplaceAll(d, old, n)
		}
		values[jen.Id("Description")] = jen.Lit(toTitle(d))
	}

	var t string
	switch o.Type {
	case objectTypeObject, objectTypeArray:
		if o.isSchemaless() {
			t = "Map"
		} else {
			t = "List"
			if isTypeSet(o) {
				t = "Set"
			}

			if o.MinItems != nil {
				values[jen.Id("MinItems")] = jen.Lit(*o.MinItems)
			}
			if o.MaxItems != nil {
				values[jen.Id("MaxItems")] = jen.Lit(*o.MaxItems)
			}
		}
	case objectTypeBoolean:
		t = "Bool"
	case objectTypeString:
		t = "String"
	case objectTypeInteger:
		t = "Int"
	case objectTypeNumber:
		t = "Float"
	default:
		return nil, fmt.Errorf("%w: %q for %q", errUnknownType, o.Type, o.jsonName)
	}

	values[jen.Id("Type")] = jen.Qual(importSchema, "Type"+t)
	if o.IsDeprecated {
		if o.DeprecationNotice == "" {
			return nil, fmt.Errorf("missing deprecation notice for %q", o.jsonName)
		}
		values[jen.Id("Deprecated")] = jen.Lit(o.DeprecationNotice)
	}

	if o.CreateOnly {
		values[jen.Id("ForceNew")] = jen.True()
	}

	// Doesn't mark with required or optional scalar elements of arrays
	if !o.isScalar() || !o.parent.isArray() {
		if o.Required {
			values[jen.Id("Required")] = jen.True()
		} else {
			values[jen.Id("Optional")] = jen.True()
		}
	}

	if o.isScalar() {
		if o.IsSensitive {
			values[jen.Id("Sensitive")] = jen.True()
		}

		if o.Enum != nil {
			args := make([]jen.Code, 0)
			for _, v := range o.Enum {
				args = append(args, scalarLit(o, v.Value))
			}

			call, err := scalarArrayLit(o, args)
			if err != nil {
				return nil, err
			}

			// todo: allow version validation when we make automatic releases
			if !o.isVersionField() {
				// There are no other types functions.
				// Bool and number won't compile
				switch o.Type {
				case objectTypeString:
					values[jen.Id("ValidateFunc")] = jen.Qual(importValidation, "StringInSlice").Call(call, jen.False())
				case objectTypeInteger:
					values[jen.Id("ValidateFunc")] = jen.Qual(importValidation, "IntInSlice").Call(call)
				}
			}
		}

		return values, nil
	}

	if o.isArray() {
		if o.ArrayItems.isScalar() {
			fields, err := getSchemaValues(o.ArrayItems)
			if err != nil {
				return nil, err
			}

			values[jen.Id("Elem")] = jen.Op("&").Qual(importSchema, "Schema").Values(fields)
			return values, nil
		}

		// Renders the array as an object
		o = o.ArrayItems
	}

	if o.isRoot {
		values[jen.Id("DiffSuppressFunc")] = jen.Qual(importDiff, "SuppressUnchanged")
	}

	fields := make(jen.Dict)
	for _, p := range o.properties {
		vals, err := getSchemaValues(p)
		if errors.Is(err, errUnknownType) {
			log.Printf("%s: unknow type or multiple types, skipping", o.jsonName)
			if p.Required {
				// This is a required field that cannot be skipped since the object depends on it.
				// The error will propagate up the call stack until it reaches an optional field,
				// at which point that entire branch of properties can be safely omitted.
				return nil, fmt.Errorf("%w: %s", errUnknownType, p.jsonName)
			}

			continue
		}

		if err != nil {
			return nil, err
		}

		fields[jen.Lit(p.tfName)] = jen.Values(vals)
	}

	if len(fields) > 0 {
		values[jen.Id("Elem")] = jen.Op("&").Qual(importSchema, "Resource").Values(jen.Dict{
			jen.Id("Schema"): jen.Map(jen.String()).Op("*").Qual(importSchema, "Schema").Values(fields),
		})
	}

	return values, nil
}

var (
	reCode       = regexp.MustCompile(`'([^'\s]+)'`)
	reWhitespace = regexp.MustCompile(`\s+`)
)

func getDescription(o *object) string {
	desc := make([]string, 0)
	if o.Enum != nil {
		values := make([]string, 0)
		for _, v := range o.Enum {
			values = append(values, fmt.Sprintf("`%s`", v.Value))
		}

		// todo: remove this block when we make automatic releases
		if o.isVersionField() {
			values = append(values, "and newer")
		}

		desc = append(desc, fmt.Sprintf("Enum: %s.", strings.Join(values, ", ")))
	}

	// Normalizes whitespaces
	d := reWhitespace.ReplaceAllString(o.Description, " ")
	if len(d) < len(o.Title) {
		d = o.Title
	}

	if d != "" {
		// Wraps code chunks with backticks to render them as code blocks
		d = reCode.ReplaceAllString(d, "`$1`")

		// Adds a trailing dot if missing
		desc = append(desc, addDot(d))
	}

	if o.isScalar() {
		switch {
		case o.Default != nil:
			desc = append(desc, fmt.Sprintf("Default: `%v`.", o.Default))
		case o.Example != nil && o.Type != objectTypeBoolean && o.Enum == nil && !strings.Contains(strings.ToLower(d), "defaults to"):
			// Skips boolean (either true or false), enums, or if the field has default value in the description
			desc = append(desc, fmt.Sprintf("Example: `%v`.", o.Example))
		}
	}

	// Trims dot from description, so it doesn't look weird with link to nested schema
	// Example: Databases to expose[dot] (see [below for nested schema]...)
	if len(desc) == 1 && o.isNestedBlock() {
		return strings.Trim(desc[0], ".")
	}

	return strings.Join(desc, " ")
}

// reFixDot some descriptions have issues with multiple dots or ending spaces
var reFixDot = regexp.MustCompile(`[\s.]+$`)

func addDot(s string) string {
	if s != "" {
		s = reFixDot.ReplaceAllString(s, ".")
		switch s[len(s)-1:] {
		case ".", "!", "?":
		default:
			s += "."
		}
	}
	return s
}

func joinSentences(args ...string) string {
	result := make([]string, 0, len(args))
	for _, v := range args {
		if v != "" {
			result = append(result, addDot(v))
		}
	}
	return strings.Join(result, " ")
}

var replaceDescriptionSubStrings = map[string]string{
	"UserConfig":                   "",
	"DEPRECATED: ":                 "",
	"This setting is deprecated. ": "",
	"[seconds]":                    "(seconds)",
}

func scalarLit(o *object, value any) *jen.Statement {
	switch o.Type {
	case objectTypeString:
		return jen.Lit(value.(string))
	case objectTypeBoolean:
		return jen.Lit(value.(bool))
	case objectTypeInteger:
		n, _ := strconv.Atoi(fmt.Sprint(value))
		return jen.Lit(n)
	case objectTypeNumber:
		f, _ := strconv.ParseFloat(fmt.Sprint(value), 64)
		return jen.Lit(f)
	}
	log.Fatalf("unknown scalar %v", o)
	return nil
}

func scalarArrayLit(o *object, args []jen.Code) (*jen.Statement, error) {
	switch o.Type {
	case objectTypeString:
		return jen.Index().String().Values(args...), nil
	case objectTypeInteger:
		return jen.Index().Int().Values(args...), nil
	}
	return nil, fmt.Errorf("unexpected element type of array for default value: %q", o.Type)
}

func isTypeSet(o *object) bool {
	// Allowlist for set types
	// Warning: test each type you add!
	switch o.path() {
	case "ip_filter", "ip_filter_string", "ip_filter_object":
		return true
	}
	return false
}

// toTitleRegex finds words with a leading lowercase letter,
// ignores dotted and underscored literals
var toTitleRegex = regexp.MustCompile(`^[a-z]\w+[\s,$]`)

func toTitle(s string) string {
	if !strings.HasPrefix(s, "fnmatch") && toTitleRegex.MatchString(s) {
		return toUpperFirst(s)
	}
	return s
}
