// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package totp

import (
	"bytes"
	"context"
	"encoding/base32"
	"encoding/base64"
	"fmt"
	"image/png"
	"net/url"
	"strconv"
	"strings"

	"github.com/hashicorp/vault/sdk/framework"
	"github.com/hashicorp/vault/sdk/logical"
	otplib "github.com/pquerna/otp"
	totplib "github.com/pquerna/otp/totp"
)

func pathListKeys(b *backend) *framework.Path {
	return &framework.Path{
		Pattern: "keys/?$",

		DisplayAttrs: &framework.DisplayAttributes{
			OperationPrefix: operationPrefixTOTP,
			OperationSuffix: "keys",
		},

		Callbacks: map[logical.Operation]framework.OperationFunc{
			logical.ListOperation: b.pathKeyList,
		},

		HelpSynopsis:    pathKeyHelpSyn,
		HelpDescription: pathKeyHelpDesc,
	}
}

func pathKeys(b *backend) *framework.Path {
	return &framework.Path{
		Pattern: "keys/" + framework.GenericNameWithAtRegex("name"),

		DisplayAttrs: &framework.DisplayAttributes{
			OperationPrefix: operationPrefixTOTP,
			OperationSuffix: "key",
		},

		Fields: map[string]*framework.FieldSchema{
			"name": {
				Type:        framework.TypeString,
				Description: "Name of the key.",
			},

			"generate": {
				Type:        framework.TypeBool,
				Default:     false,
				Description: "Determines if a key should be generated by Vault or if a key is being passed from another service.",
			},

			"exported": {
				Type:        framework.TypeBool,
				Default:     true,
				Description: "Determines if a QR code and url are returned upon generating a key. Only used if generate is true.",
			},

			"key_size": {
				Type:        framework.TypeInt,
				Default:     20,
				Description: "Determines the size in bytes of the generated key. Only used if generate is true.",
			},

			"key": {
				Type:        framework.TypeString,
				Description: "The shared master key used to generate a TOTP token. Only used if generate is false.",
			},

			"issuer": {
				Type:        framework.TypeString,
				Description: `The name of the key's issuing organization. Required if generate is true.`,
			},

			"account_name": {
				Type:        framework.TypeString,
				Description: `The name of the account associated with the key. Required if generate is true.`,
			},

			"period": {
				Type:        framework.TypeDurationSecond,
				Default:     30,
				Description: `The length of time used to generate a counter for the TOTP token calculation.`,
			},

			"algorithm": {
				Type:        framework.TypeString,
				Default:     "SHA1",
				Description: `The hashing algorithm used to generate the TOTP token. Options include SHA1, SHA256 and SHA512.`,
			},

			"digits": {
				Type:        framework.TypeInt,
				Default:     6,
				Description: `The number of digits in the generated TOTP token. This value can either be 6 or 8.`,
			},

			"skew": {
				Type:        framework.TypeInt,
				Default:     1,
				Description: `The number of delay periods that are allowed when validating a TOTP token. This value can either be 0 or 1. Only used if generate is true.`,
			},

			"qr_size": {
				Type:        framework.TypeInt,
				Default:     200,
				Description: `The pixel size of the generated square QR code. Only used if generate is true and exported is true. If this value is 0, a QR code will not be returned.`,
			},

			"url": {
				Type:        framework.TypeString,
				Description: `A TOTP url string containing all of the parameters for key setup. Only used if generate is false.`,
			},
		},

		Operations: map[logical.Operation]framework.OperationHandler{
			logical.ReadOperation: &framework.PathOperation{
				Callback: b.pathKeyRead,
				DisplayAttrs: &framework.DisplayAttributes{
					OperationVerb: "read",
				},
			},
			logical.UpdateOperation: &framework.PathOperation{
				Callback: b.pathKeyCreate,
				DisplayAttrs: &framework.DisplayAttributes{
					OperationVerb: "create",
				},
			},
			logical.DeleteOperation: &framework.PathOperation{
				Callback: b.pathKeyDelete,
				DisplayAttrs: &framework.DisplayAttributes{
					OperationVerb: "delete",
				},
			},
		},

		HelpSynopsis:    pathKeyHelpSyn,
		HelpDescription: pathKeyHelpDesc,
	}
}

func (b *backend) Key(ctx context.Context, s logical.Storage, n string) (*keyEntry, error) {
	entry, err := s.Get(ctx, "key/"+n)
	if err != nil {
		return nil, err
	}
	if entry == nil {
		return nil, nil
	}

	var result keyEntry
	if err := entry.DecodeJSON(&result); err != nil {
		return nil, err
	}

	return &result, nil
}

func (b *backend) pathKeyDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
	err := req.Storage.Delete(ctx, "key/"+data.Get("name").(string))
	if err != nil {
		return nil, err
	}

	return nil, nil
}

func (b *backend) pathKeyRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
	key, err := b.Key(ctx, req.Storage, data.Get("name").(string))
	if err != nil {
		return nil, err
	}
	if key == nil {
		return nil, nil
	}

	// Translate algorithm back to string
	algorithm := key.Algorithm.String()

	// Return values of key
	return &logical.Response{
		Data: map[string]interface{}{
			"issuer":       key.Issuer,
			"account_name": key.AccountName,
			"period":       key.Period,
			"algorithm":    algorithm,
			"digits":       key.Digits,
		},
	}, nil
}

func (b *backend) pathKeyList(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
	entries, err := req.Storage.List(ctx, "key/")
	if err != nil {
		return nil, err
	}

	return logical.ListResponse(entries), nil
}

func (b *backend) pathKeyCreate(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
	name := data.Get("name").(string)
	generate := data.Get("generate").(bool)
	exported := data.Get("exported").(bool)
	keyString := data.Get("key").(string)
	issuer := data.Get("issuer").(string)
	accountName := data.Get("account_name").(string)
	period := data.Get("period").(int)
	algorithm := data.Get("algorithm").(string)
	digits := data.Get("digits").(int)
	skew := data.Get("skew").(int)
	qrSize := data.Get("qr_size").(int)
	keySize := data.Get("key_size").(int)
	inputURL := data.Get("url").(string)

	if generate {
		if keyString != "" {
			return logical.ErrorResponse("a key should not be passed if generate is true"), nil
		}
		if inputURL != "" {
			return logical.ErrorResponse("a url should not be passed if generate is true"), nil
		}
	}

	// Read parameters from url if given
	if inputURL != "" {
		// Parse url
		urlObject, err := url.Parse(inputURL)
		if err != nil {
			return logical.ErrorResponse("an error occurred while parsing url string"), err
		}

		// Set up query object
		urlQuery := urlObject.Query()
		path := strings.TrimPrefix(urlObject.Path, "/")
		index := strings.Index(path, ":")

		// Read issuer
		urlIssuer := urlQuery.Get("issuer")
		if urlIssuer != "" {
			issuer = urlIssuer
		} else {
			if index != -1 {
				issuer = path[:index]
			}
		}

		// Read account name
		if index == -1 {
			accountName = path
		} else {
			accountName = path[index+1:]
		}

		// Read key string
		keyString = urlQuery.Get("secret")

		// Read period
		periodQuery := urlQuery.Get("period")
		if periodQuery != "" {
			periodInt, err := strconv.Atoi(periodQuery)
			if err != nil {
				return logical.ErrorResponse("an error occurred while parsing period value in url"), err
			}
			period = periodInt
		}

		// Read digits
		digitsQuery := urlQuery.Get("digits")
		if digitsQuery != "" {
			digitsInt, err := strconv.Atoi(digitsQuery)
			if err != nil {
				return logical.ErrorResponse("an error occurred while parsing digits value in url"), err
			}
			digits = digitsInt
		}

		// Read algorithm
		algorithmQuery := urlQuery.Get("algorithm")
		if algorithmQuery != "" {
			algorithm = algorithmQuery
		}
	}

	// Translate digits and algorithm to a format the totp library understands
	var keyDigits otplib.Digits
	switch digits {
	case 6:
		keyDigits = otplib.DigitsSix
	case 8:
		keyDigits = otplib.DigitsEight
	default:
		return logical.ErrorResponse("the digits value can only be 6 or 8"), nil
	}

	var keyAlgorithm otplib.Algorithm
	switch algorithm {
	case "SHA1":
		keyAlgorithm = otplib.AlgorithmSHA1
	case "SHA256":
		keyAlgorithm = otplib.AlgorithmSHA256
	case "SHA512":
		keyAlgorithm = otplib.AlgorithmSHA512
	default:
		return logical.ErrorResponse("the algorithm value is not valid"), nil
	}

	// Enforce input value requirements
	if period <= 0 {
		return logical.ErrorResponse("the period value must be greater than zero"), nil
	}

	switch skew {
	case 0:
	case 1:
	default:
		return logical.ErrorResponse("the skew value must be 0 or 1"), nil
	}

	// QR size can be zero but it shouldn't be negative
	if qrSize < 0 {
		return logical.ErrorResponse("the qr_size value must be greater than or equal to zero"), nil
	}

	if keySize <= 0 {
		return logical.ErrorResponse("the key_size value must be greater than zero"), nil
	}

	// Period, Skew and Key Size need to be unsigned ints
	uintPeriod := uint(period)
	uintSkew := uint(skew)
	uintKeySize := uint(keySize)

	var response *logical.Response

	switch generate {
	case true:
		// If the key is generated, Account Name and Issuer are required.
		if accountName == "" {
			return logical.ErrorResponse("the account_name value is required for generated keys"), nil
		}

		if issuer == "" {
			return logical.ErrorResponse("the issuer value is required for generated keys"), nil
		}

		// Generate a new key
		keyObject, err := totplib.Generate(totplib.GenerateOpts{
			Issuer:      issuer,
			AccountName: accountName,
			Period:      uintPeriod,
			Digits:      keyDigits,
			Algorithm:   keyAlgorithm,
			SecretSize:  uintKeySize,
			Rand:        b.GetRandomReader(),
		})
		if err != nil {
			return logical.ErrorResponse("an error occurred while generating a key"), err
		}

		// Get key string value
		keyString = keyObject.Secret()

		// Skip returning the QR code and url if exported is set to false
		if exported {
			// Prepare the url and barcode
			urlString := keyObject.String()

			// Don't include QR code if size is set to zero
			if qrSize == 0 {
				response = &logical.Response{
					Data: map[string]interface{}{
						"url": urlString,
					},
				}
			} else {
				barcode, err := keyObject.Image(qrSize, qrSize)
				if err != nil {
					return nil, fmt.Errorf("failed to generate QR code image: %w", err)
				}

				var buff bytes.Buffer
				png.Encode(&buff, barcode)
				b64Barcode := base64.StdEncoding.EncodeToString(buff.Bytes())
				response = &logical.Response{
					Data: map[string]interface{}{
						"url":     urlString,
						"barcode": b64Barcode,
					},
				}
			}
		}
	default:
		if keyString == "" {
			return logical.ErrorResponse("the key value is required"), nil
		}

		if i := len(keyString) % 8; i != 0 {
			keyString += strings.Repeat("=", 8-i)
		}

		_, err := base32.StdEncoding.DecodeString(strings.ToUpper(keyString))
		if err != nil {
			return logical.ErrorResponse(fmt.Sprintf(
				"invalid key value: %s", err)), nil
		}
	}

	// Store it
	entry, err := logical.StorageEntryJSON("key/"+name, &keyEntry{
		Key:         keyString,
		Issuer:      issuer,
		AccountName: accountName,
		Period:      uintPeriod,
		Algorithm:   keyAlgorithm,
		Digits:      keyDigits,
		Skew:        uintSkew,
	})
	if err != nil {
		return nil, err
	}
	if err := req.Storage.Put(ctx, entry); err != nil {
		return nil, err
	}

	return response, nil
}

type keyEntry struct {
	Key         string           `json:"key" mapstructure:"key" structs:"key"`
	Issuer      string           `json:"issuer" mapstructure:"issuer" structs:"issuer"`
	AccountName string           `json:"account_name" mapstructure:"account_name" structs:"account_name"`
	Period      uint             `json:"period" mapstructure:"period" structs:"period"`
	Algorithm   otplib.Algorithm `json:"algorithm" mapstructure:"algorithm" structs:"algorithm"`
	Digits      otplib.Digits    `json:"digits" mapstructure:"digits" structs:"digits"`
	Skew        uint             `json:"skew" mapstructure:"skew" structs:"skew"`
}

const pathKeyHelpSyn = `
Manage the keys that can be created with this backend.
`

const pathKeyHelpDesc = `
This path lets you manage the keys that can be created with this backend.

`
