// Copyright (C) 2020 Michael J. Fromberger. All Rights Reserved.

// Package otpauth handles the URL format used to specify OTP parameters.
//
// This package conforms to the specification at:
// https://github.com/google/google-authenticator/wiki/Key-Uri-Format
//
// The general form of an OTP URL is:
//
//	otpauth://TYPE/LABEL?PARAMETERS
package otpauth

import (
	"encoding/base32"
	"errors"
	"fmt"
	"net/url"
	"strconv"
	"strings"

	"github.com/creachadair/otp"
)

const (
	defaultAlgorithm = "SHA1"
	defaultDigits    = 6
	defaultPeriod    = 30
)

// A URL contains the parsed representation of an otpauth URL.
type URL struct {
	Type      string // normalized to lowercase, e.g., "totp"
	Issuer    string // also called "provider" in some docs
	Account   string // without provider prefix
	RawSecret string // base32-encoded, no padding
	Algorithm string // normalized to uppercase; default is "SHA1"
	Digits    int    // default is 6
	Period    int    // in seconds; default is 30
	Counter   uint64
}

// Secret parses the contents of the RawSecret field.
func (u *URL) Secret() ([]byte, error) { return otp.ParseKey(u.RawSecret) }

// SetSecret encodes key as base32 and updates the RawSecret field.
func (u *URL) SetSecret(key []byte) {
	enc := base32.StdEncoding.EncodeToString(key)
	u.RawSecret = strings.TrimRight(enc, "=")
}

// String converts u to a URL in the standard encoding.
func (u *URL) String() string {
	var sb strings.Builder
	sb.WriteString("otpauth://")
	typ := strings.ToLower(u.Type)
	sb.WriteString(typ)
	sb.WriteByte('/')
	sb.WriteString(u.labelString())

	// Encode parameters if there are any non-default values.
	var params []string
	if a := strings.ToUpper(u.Algorithm); a != "" && a != "SHA1" {
		params = append(params, "algorithm="+queryEscape(a))
	}
	if c := u.Counter; c > 0 || typ == "hotp" {
		params = append(params, "counter="+strconv.FormatUint(c, 10))
	}
	if d := u.Digits; d > 0 && d != defaultDigits {
		params = append(params, "digits="+strconv.Itoa(d))
	}
	if o := u.Issuer; o != "" {
		params = append(params, "issuer="+queryEscape(o))
	}
	if p := u.Period; p > 0 && p != defaultPeriod {
		params = append(params, "period="+strconv.Itoa(p))
	}
	if s := u.RawSecret; s != "" {
		enc := strings.ToUpper(strings.Join(strings.Fields(strings.TrimRight(s, "=")), ""))
		params = append(params, "secret="+queryEscape(enc))
	}
	if len(params) != 0 {
		sb.WriteByte('?')
		sb.WriteString(strings.Join(params, "&"))
	}
	return sb.String()
}

// UnmarshalText implements the encoding.TextUnmarshaler interface.
// It expects its input to be a URL in the standard encoding.
func (u *URL) UnmarshalText(data []byte) error {
	p, err := ParseURL(string(data))
	if err != nil {
		return err
	}
	*u = *p // a shallow copy is safe, there are no pointers
	return nil
}

// MarshalText implemens the encoding.TextMarshaler interface.
// It emits the same URL string produced by the String method.
func (u *URL) MarshalText() ([]byte, error) {
	return []byte(u.String()), nil
}

func (u *URL) labelString() string {
	label := url.PathEscape(u.Account)
	if u.Issuer != "" {
		return url.PathEscape(u.Issuer) + ":" + label
	}
	return label
}

func (u *URL) parseLabel(s string) error {
	account, err := url.PathUnescape(s)
	if err != nil {
		return err
	}
	if i := strings.Index(account, ":"); i >= 0 {
		u.Issuer = strings.TrimSpace(account[:i])
		if u.Issuer == "" {
			return errors.New("empty issuer")
		}
		account = account[i+1:]
	}
	u.Account = strings.TrimSpace(account)
	if u.Account == "" {
		return errors.New("empty account name")
	}
	return nil
}

// ParseURL parses s as a URL in the otpauth scheme.
//
// The input may omit a scheme, but if present the scheme must be otpauth://.
// The parser will report an error for invalid syntax, including unknown URL
// parameters, but does not otherwise validate the results. In particular, the
// values of the Type and Algorithm fields are not checked.
//
// Fields of the URL corresponding to unset parameters are populated with
// default values as described on the URL struct. If a different issuer is set
// on the label and in the parameters, the parameter takes priority.
func ParseURL(s string) (*URL, error) {
	// A scheme is not required, but if present it must be "otpauth".
	if ps := strings.SplitN(s, "://", 2); len(ps) == 2 {
		if ps[0] != "otpauth" {
			return nil, fmt.Errorf("invalid scheme %q", ps[0])
		}
		s = ps[1] // trim scheme prefix
	}

	// Extract TYPE/LABEL and optional PARAMS.
	var typeLabel, params string
	if ps := strings.SplitN(s, "?", 2); len(ps) == 2 {
		typeLabel, params = ps[0], ps[1]
	} else {
		typeLabel = ps[0]
	}

	// Require that type and label are both present and non-empty.
	// Note that the "//" authority marker is treated as optional.
	ps := strings.SplitN(strings.TrimPrefix(typeLabel, "//"), "/", 2) // [TYPE, LABEL]
	if len(ps) != 2 || ps[0] == "" || ps[1] == "" {
		return nil, errors.New("invalid type/label")
	}

	out := &URL{
		Type:      strings.ToLower(ps[0]),
		Algorithm: defaultAlgorithm,
		Digits:    defaultDigits,
		Period:    defaultPeriod,
	}
	if err := out.parseLabel(ps[1]); err != nil {
		return nil, fmt.Errorf("invalid label: %v", err)
	}
	if params == "" {
		return out, nil
	}

	// Parse URL parameters.
	for _, param := range strings.Split(params, "&") {
		ps := strings.SplitN(param, "=", 2)
		if len(ps) == 1 {
			ps = append(ps, "") // check value below
		}
		value, err := url.QueryUnescape(ps[1])
		if err != nil {
			return nil, fmt.Errorf("invalid value: %v", err)
		}

		// Handle string-valued parameters.
		if ps[0] == "algorithm" {
			out.Algorithm = strings.ToUpper(value)
			continue
		} else if ps[0] == "issuer" {
			out.Issuer = value
			continue
		} else if ps[0] == "secret" {
			out.RawSecret = value
			continue
		}

		// All other valid parameters require an integer argument.
		// Defer error reporting so we report an unknown field first.
		n, err := strconv.ParseUint(value, 10, 64)

		switch ps[0] {
		case "counter":
			out.Counter = n
		case "digits":
			out.Digits = int(n)
		case "period":
			out.Period = int(n)
		default:
			return nil, fmt.Errorf("invalid parameter %q", ps[0])
		}
		if err != nil {
			return nil, fmt.Errorf("invalid integer value %q", value)
		}
	}
	return out, nil
}

func queryEscape(s string) string {
	return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
