package action

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"time"

	"github.com/gopasspw/gopass/internal/action/exit"
	"github.com/gopasspw/gopass/internal/config"
	"github.com/gopasspw/gopass/internal/out"
	"github.com/gopasspw/gopass/internal/store"
	"github.com/gopasspw/gopass/pkg/clipboard"
	"github.com/gopasspw/gopass/pkg/ctxutil"
	"github.com/gopasspw/gopass/pkg/debug"
	"github.com/gopasspw/gopass/pkg/otp"
	"github.com/gopasspw/gopass/pkg/termio"
	"github.com/mattn/go-tty"
	"github.com/pquerna/otp/hotp"
	"github.com/pquerna/otp/totp"
	"github.com/urfave/cli/v2"
)

// OTP implements OTP token handling for TOTP and HOTP.
func (s *Action) OTP(c *cli.Context) error {
	ctx := ctxutil.WithGlobalFlags(c)
	name := c.Args().First()
	if name == "" {
		return exit.Error(exit.Usage, nil, "Usage: %s otp <NAME>", s.Name)
	}

	qrf := c.String("qr")
	clip := config.Bool(ctx, "otp.onlyclip")
	if c.IsSet("clip") {
		clip = c.Bool("clip")
	}
	alsoClip := config.Bool(ctx, "otp.autoclip")
	if c.IsSet("alsoclip") {
		alsoClip = c.Bool("alsoclip")
	}
	chained := c.Bool("chained")
	pw := c.Bool("password")
	snip := c.Bool("snip")

	if snip {
		qr, err := otp.ParseScreen(ctx)
		if err != nil || len(qr) == 0 {
			return err
		}

		choice, err := termio.AskForBool(ctx, "(Over)writing otpauth URL in key 'otpauth'?", true)
		if err != nil || !choice {
			return err
		}
		err = s.insertYAML(ctxutil.WithInteractive(ctx, false), name, "otpauth", []byte(qr), nil)
		if err != nil {
			return err
		}

		out.Print(ctx, "Value written, carrying on to display OTP value from it.")
	}

	return s.otp(ctx, name, qrf, clip, pw, true, chained, alsoClip)
}

func tickingBar(ctx context.Context, expiresAt time.Time, bar *termio.ProgressBar) {
	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()
	for tt := range ticker.C {
		select {
		case <-ctx.Done():
			return // returning not to leak the goroutine.
		default:
			// we don't want to block if not cancelled.
		}
		if tt.After(expiresAt) {
			return
		}
		bar.Inc()
	}
}

func waitForKeyPress(ctx context.Context, cancel context.CancelFunc) (func(), func()) {
	tty1, err := tty.Open()
	if err != nil {
		out.Errorf(ctx, "Unexpected error opening tty: %v", err)
		cancel()
	}

	return func() {
			for {
				select {
				case <-ctx.Done():
					return // returning not to leak the goroutine.
				default:
				}

				r, err := tty1.ReadRune()
				if err != nil {
					out.Errorf(ctx, "Unexpected error opening tty: %v", err)
				}

				if r == 'q' || r == 'x' || err != nil {
					cancel()

					return
				}
			}
		}, func() {
			_ = tty1.Close()
		}
}

// nolint: cyclop
func (s *Action) otp(ctx context.Context, name, qrf string, clip, pw, recurse, chained, alsoClip bool) error {
	sec, err := s.Store.Get(ctx, name)
	if err != nil {
		return s.otpHandleError(ctx, name, qrf, clip, pw, recurse, chained, alsoClip, err)
	}

	outerCtx := ctx
	ctx = config.WithMount(ctx, s.Store.MountPoint(name))
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	skip := ctxutil.IsHidden(ctx) || pw || qrf != "" || !ctxutil.IsTerminal(ctx) || !ctxutil.IsInteractive(ctx) || clip
	if !skip {
		// let us monitor key presses for cancellation:.
		runFn, cleanupFn := waitForKeyPress(ctx, cancel)
		go runFn()
		defer cleanupFn()
	}

	// only used for the HOTP case as a fallback
	var counter uint64 = 1
	if sv, found := sec.Get("counter"); found && sv != "" {
		if iv, err := strconv.ParseUint(sv, 10, 64); iv != 0 && err == nil {
			counter = iv
		}
	}
	for {
		select {
		case <-ctx.Done():
			return nil
		default:
		}

		two, err := otp.Calculate(name, sec)
		if err != nil {
			return exit.Error(exit.Unknown, err, "No OTP entry found for %s: %s", name, err)
		}

		var token string
		switch two.Type() {
		case "totp":
			token, err = totp.GenerateCodeCustom(two.Secret(), time.Now(), totp.ValidateOpts{
				Period:    uint(two.Period()),
				Skew:      1,
				Digits:    two.Digits(),
				Algorithm: two.Algorithm(),
				Encoder:   two.Encoder(),
			})
			if err != nil {
				return exit.Error(exit.Unknown, err, "Failed to compute OTP token for %s: %s", name, err)
			}
		case "hotp":
			token, err = hotp.GenerateCodeCustom(two.Secret(), counter, hotp.ValidateOpts{
				Digits:    two.Digits(),
				Algorithm: two.Algorithm(),
				Encoder:   two.Encoder(),
			})
			if err != nil {
				return exit.Error(exit.Unknown, err, "Failed to compute OTP token for %s: %s", name, err)
			}
			counter++
			_ = sec.Set("counter", strconv.Itoa(int(counter)))
			// using outerCtx here because we want to save the counter even if the user cancels.
			if err := s.Store.Set(outerCtx, name, sec); err != nil {
				out.Errorf(outerCtx, "Failed to persist counter value: %s", err)
			}
			debug.Log("Saved counter as %d", counter)
		}

		now := time.Now()
		expiresAt := now.Add(time.Duration(two.Period()) * time.Second).Truncate(time.Duration(two.Period()) * time.Second)
		secondsLeft := int(time.Until(expiresAt).Seconds())
		bar := termio.NewProgressBar(int64(secondsLeft))
		bar.Hidden = skip

		debug.Log("OTP period: %ds", two.Period())

		if chained {
			token = fmt.Sprintf("%s%s", sec.Password(), token)
		}
		if clip || alsoClip {
			if err := clipboard.CopyTo(ctx, fmt.Sprintf("token for %s", name), []byte(token), config.AsInt(s.cfg.Get("core.cliptimeout"))); err != nil {
				return exit.Error(exit.IO, err, "failed to copy to clipboard: %s", err)
			}
			if clip {
				return nil
			}
		}

		out.Printf(ctx, "%s", token)

		// In "QR-Code" mode just create the image file and then exit.
		if qrf != "" {
			return otp.WriteQRFile(two, qrf)
		}

		// If we are in "password mode", not interacting with a terminal or Stdout is attached to a pipe,
		// we are done.
		if skip {
			return nil
		}

		// if not then we want to print a progress bar with the expiry time.
		out.Warningf(ctx, "([q] to stop. -o flag to avoid.) This OTP password still lasts for:", nil)

		if bar.Hidden {
			cancel()
		} else {
			bar.Set(0)
			go tickingBar(ctx, expiresAt, bar)
		}

		// let us wait until next OTP code:.
		for {
			select {
			case <-ctx.Done():
				bar.Done()
				cancel()

				return nil
			default:
				time.Sleep(time.Millisecond * 500)
			}
			if time.Now().After(expiresAt) {
				bar.Done()

				break
			}
		}
	}
}

func (s *Action) otpHandleError(ctx context.Context, name, qrf string, clip, pw, recurse, chained, alsoClip bool, err error) error {
	if !errors.Is(err, store.ErrNotFound) || !recurse || !ctxutil.IsTerminal(ctx) {
		return exit.Error(exit.Unknown, err, "failed to retrieve secret %q: %s", name, err)
	}

	out.Printf(ctx, "Entry %q not found. Starting search...", name)
	cb := func(ctx context.Context, c *cli.Context, name string, recurse bool) error {
		return s.otp(ctx, name, qrf, clip, pw, false, chained, alsoClip)
	}
	if err := s.find(ctx, nil, name, cb, false); err != nil {
		return exit.Error(exit.NotFound, err, "%s", err)
	}

	return nil
}
