// Command dial is a fast, interactive SSH host picker. It reads ~/.ssh/config
// (read-only), lets you pick a host from a static, high-contrast list, and
// exec's the system ssh client into it. An optional YubiKey gate can be
// required before the picker unlocks.
package main

import (
	"bufio"
	"errors"
	"fmt"
	"os"
	"strings"

	"gitea.apointless.space/bsncubed/dial/internal/connect"
	"gitea.apointless.space/bsncubed/dial/internal/picker"
	"gitea.apointless.space/bsncubed/dial/internal/sshconf"
	"gitea.apointless.space/bsncubed/dial/internal/store"
	"gitea.apointless.space/bsncubed/dial/internal/yubikey"
)

// version is overridden at build time via -ldflags "-X main.version=...".
var version = "dev"

// errAbort signals the user chose to quit at the gate.
var errAbort = errors.New("aborted")

func main() {
	args := os.Args[1:]
	if len(args) > 0 {
		switch args[0] {
		case "-h", "--help", "help":
			usage(os.Stdout)
			return
		case "-v", "--version", "version":
			fmt.Println("dial", version)
			return
		case "yubikey", "yk":
			if err := runYubikeyCmd(args[1:]); err != nil {
				fatal(err)
			}
			return
		case "keygen":
			if err := runKeygen(args[1:]); err != nil {
				fatal(err)
			}
			return
		default:
			fmt.Fprintf(os.Stderr, "dial: unknown command %q\n\n", args[0])
			usage(os.Stderr)
			os.Exit(2)
		}
	}
	if err := run(); err != nil {
		if errors.Is(err, errAbort) {
			os.Exit(1)
		}
		fatal(err)
	}
}

// run is the default flow: gate → pick → connect. Connecting exec's ssh and
// never returns; quitting the picker returns here so main can exit cleanly.
func run() error {
	settings := store.LoadSettings()

	if err := runGate(&settings); err != nil {
		return err
	}

	cfgPath, err := sshconf.DefaultConfigPath()
	if err != nil {
		return err
	}

	hosts, err := sshconf.Parse(cfgPath)
	if err != nil {
		return fmt.Errorf("parsing %s: %w", cfgPath, err)
	}
	if len(hosts) == 0 {
		fmt.Fprintf(os.Stderr, "dial: no hosts found in %s\n", cfgPath)
		return nil
	}

	res, err := picker.Run(hosts, store.LoadData(), settings)
	if err != nil {
		return err
	}

	switch res.Action {
	case picker.ActionConnect:
		// Record usage, then exec ssh (replaces the process on Unix).
		_ = store.Record(res.Alias)
		return connect.SSH(res.Alias).Exec()

	default:
		return nil // user quit
	}
}

// runGate enforces the YubiKey gate when enabled. It runs before any TUI, as
// plain terminal I/O, and returns nil once the user unlocks (via key or backup
// code) or errAbort if they quit.
func runGate(settings *store.Settings) error {
	if !settings.YubiKeyEnabled {
		return nil
	}
	if err := yubikey.Available(); err != nil {
		return err
	}
	if !settings.YubiKey.IsEnrolled() {
		return errors.New("YubiKey gate is enabled but no key is enrolled; run `dial yubikey enable` again")
	}

	in := bufio.NewReader(os.Stdin)
	for {
		fmt.Println()
		fmt.Println("  🔒 dial is locked.")
		fmt.Println("     [1] Unlock with YubiKey (touch it when it blinks)")
		fmt.Println("     [2] Use a one-time backup recovery code")
		fmt.Println("     [q] Quit")
		fmt.Print("  Choose [1]: ")

		choice := strings.TrimSpace(readLine(in))
		switch choice {
		case "", "1":
			fmt.Println("  Touch your YubiKey now…")
			err := yubikey.Verify(settings.YubiKey, settings.YubiKeySlot)
			if err == nil {
				return nil
			}
			fmt.Fprintf(os.Stderr, "  ✗ %v\n", err)
		case "2":
			fmt.Print("  Enter backup recovery code: ")
			code := readLine(in)
			if err := useRecoveryCode(settings, code, in); err != nil {
				fmt.Fprintf(os.Stderr, "  ✗ %v\n", err)
				continue
			}
			return nil
		case "q", "Q":
			return errAbort
		default:
			fmt.Fprintln(os.Stderr, "  ✗ unrecognised choice")
		}
	}
}

// useRecoveryCode verifies a backup code, and on success invalidates it and
// issues a fresh single-use code (shown once) so it can never be reused.
func useRecoveryCode(settings *store.Settings, code string, in *bufio.Reader) error {
	if !settings.Recovery.IsSet() {
		return errors.New("no backup recovery code is set")
	}
	if !yubikey.VerifyRecoveryCode(code, settings.Recovery) {
		return errors.New("incorrect recovery code")
	}
	// Single-use: rotate to a new code immediately.
	newCode, newHash, err := yubikey.GenerateRecoveryCode()
	if err != nil {
		return fmt.Errorf("generating replacement code: %w", err)
	}
	settings.Recovery = newHash
	if err := store.SaveSettings(*settings); err != nil {
		return fmt.Errorf("saving rotated recovery code: %w", err)
	}
	fmt.Println()
	fmt.Println("  ✓ Unlocked with backup code. That code is now used up.")
	printRecoveryCode(newCode, "Your NEW one-time backup recovery code")
	waitAck(in)
	return nil
}

func runYubikeyCmd(args []string) error {
	sub := ""
	if len(args) > 0 {
		sub = args[0]
	}
	switch sub {
	case "enable":
		return enableGate()
	case "disable":
		return disableGate()
	case "status", "":
		return gateStatus()
	case "recovery":
		return rotateRecovery()
	default:
		return fmt.Errorf("unknown yubikey subcommand %q (want enable|disable|status|recovery)", sub)
	}
}

func enableGate() error {
	settings := store.LoadSettings()
	if settings.YubiKeyEnabled {
		fmt.Println("YubiKey gate is already enabled. Re-enrolling will replace the current key and recovery code.")
	}
	if err := yubikey.Available(); err != nil {
		return err
	}
	fmt.Printf("Enrolling YubiKey on slot %d.\n", settings.YubiKeySlot)
	fmt.Println("Touch your YubiKey now to register it…")
	cfg, err := yubikey.Enroll(settings.YubiKeySlot)
	if err != nil {
		return fmt.Errorf("enrolling YubiKey: %w", err)
	}
	code, hash, err := yubikey.GenerateRecoveryCode()
	if err != nil {
		return fmt.Errorf("generating recovery code: %w", err)
	}
	settings.YubiKeyEnabled = true
	settings.YubiKey = cfg
	settings.Recovery = hash
	if err := store.SaveSettings(settings); err != nil {
		return fmt.Errorf("saving settings: %w", err)
	}
	fmt.Println("\n✓ YubiKey gate enabled. dial will require your key on every launch.")
	printRecoveryCode(code, "One-time backup recovery code")
	fmt.Println("\n  Store this now — it is shown ONCE and lets you in if the key is unavailable.")
	fmt.Println("  It is single-use: a fresh code is issued after you use it.")
	return nil
}

func disableGate() error {
	settings := store.LoadSettings()
	if !settings.YubiKeyEnabled {
		fmt.Println("YubiKey gate is already disabled.")
		return nil
	}
	settings.YubiKeyEnabled = false
	settings.YubiKey = store.YubiKeyConfig{}
	settings.Recovery = store.RecoveryHash{}
	if err := store.SaveSettings(settings); err != nil {
		return err
	}
	fmt.Println("✓ YubiKey gate disabled.")
	return nil
}

func gateStatus() error {
	settings := store.LoadSettings()
	fmt.Printf("YubiKey gate: %s\n", enabledStr(settings.YubiKeyEnabled))
	fmt.Printf("Slot:         %d\n", settings.YubiKeySlot)
	fmt.Printf("Enrolled:     %v\n", settings.YubiKey.IsEnrolled())
	fmt.Printf("Backup code:  %s\n", presentStr(settings.Recovery.IsSet()))
	if err := yubikey.Available(); err != nil {
		fmt.Printf("ykman:        NOT FOUND (%v)\n", err)
	} else {
		fmt.Printf("ykman:        found on PATH\n")
	}
	return nil
}

// rotateRecovery issues a new backup code after the user proves access (passes
// the gate), so a lost/exposed code can be replaced without disabling the gate.
func rotateRecovery() error {
	settings := store.LoadSettings()
	if !settings.YubiKeyEnabled {
		return errors.New("YubiKey gate is not enabled")
	}
	if err := runGate(&settings); err != nil {
		return err
	}
	code, hash, err := yubikey.GenerateRecoveryCode()
	if err != nil {
		return err
	}
	settings.Recovery = hash
	if err := store.SaveSettings(settings); err != nil {
		return err
	}
	printRecoveryCode(code, "New one-time backup recovery code")
	return nil
}

// --- small terminal helpers ---

func printRecoveryCode(code, label string) {
	line := strings.Repeat("─", len(code)+6)
	fmt.Println()
	fmt.Println("  " + label + ":")
	fmt.Println("  ┌" + line + "┐")
	fmt.Println("  │   " + code + "   │")
	fmt.Println("  └" + line + "┘")
}

func waitAck(in *bufio.Reader) {
	fmt.Print("  Press Enter once you've saved it… ")
	readLine(in)
}

func readLine(r *bufio.Reader) string {
	s, _ := r.ReadString('\n')
	return strings.TrimRight(s, "\r\n")
}

func enabledStr(b bool) string {
	if b {
		return "ENABLED"
	}
	return "disabled"
}

func presentStr(b bool) string {
	if b {
		return "set"
	}
	return "none"
}

func usage(w *os.File) {
	fmt.Fprint(w, `dial — interactive SSH host picker

Usage:
  dial                       Pick a host and connect
  dial keygen [--rsa]        Generate an SSH key (ed25519 default) + optional Host entry
  dial yubikey enable        Enable the YubiKey launch gate (enrolls key + backup code)
  dial yubikey disable       Disable the YubiKey gate
  dial yubikey status        Show gate status
  dial yubikey recovery      Issue a new one-time backup code (requires unlock)
  dial version               Print version
  dial help                  Show this help

In the picker:
  ↑/↓ or j/k   move          enter connect
  1-9          jump          s     cycle sort      g   group by tag
  /            search        f     favourite       q/esc quit
`)
}

func fatal(err error) {
	fmt.Fprintln(os.Stderr, "dial:", err)
	os.Exit(1)
}
