// Package yubikey implements dial's optional launch gate. It performs an
// offline HMAC-SHA1 challenge-response against a YubiKey by shelling out to
// Yubico's official `ykman` CLI (slot configurable, default 2), and manages a
// single-use backup recovery code so the user can't be permanently locked out.
//
// Per the brief, this feature intentionally depends on `ykman` being installed
// separately — it trades the "single static binary" goal for leaning on
// Yubico's own well-tested tool. dial checks for `ykman` on PATH and reports a
// clear error if it is missing.
//
// Nothing secret is persisted: enrollment stores only a random challenge and a
// salted hash of the key's response. The YubiKey secret never leaves the key.
package yubikey

import (
	"bufio"
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"errors"
	"fmt"
	"os/exec"
	"strings"

	"gitea.apointless.space/bsncubed/dial/internal/store"
)

// ErrYkmanMissing is returned when the gate is enabled but `ykman` is not on
// PATH. Callers surface this as an actionable message.
var ErrYkmanMissing = errors.New("ykman not found on PATH: install Yubico's ykman CLI to use the YubiKey gate (https://developers.yubico.com/yubikey-manager/)")

// ErrKeyMismatch means the connected key produced a response that does not
// match the enrolled one (wrong key, wrong slot, or no key present).
var ErrKeyMismatch = errors.New("YubiKey response did not match the enrolled key")

// runYkman executes ykman and returns stdout. It is a package variable so tests
// can stub the external call without a physical key.
var runYkman = func(args ...string) ([]byte, error) {
	cmd := exec.Command("ykman", args...)
	var out, errBuf bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errBuf
	if err := cmd.Run(); err != nil {
		msg := strings.TrimSpace(errBuf.String())
		if msg != "" {
			return nil, fmt.Errorf("ykman: %s", msg)
		}
		return nil, err
	}
	return out.Bytes(), nil
}

// Available reports whether the ykman CLI is installed and on PATH.
func Available() error {
	if _, err := exec.LookPath("ykman"); err != nil {
		return ErrYkmanMissing
	}
	return nil
}

// challenge runs an HMAC-SHA1 challenge-response against the given slot. The
// challenge is passed as hex; ykman blocks for a physical touch if the slot was
// programmed to require one. The response is returned as raw bytes.
func challenge(slot int, chal []byte) ([]byte, error) {
	if slot != 1 && slot != 2 {
		return nil, fmt.Errorf("invalid slot %d (must be 1 or 2)", slot)
	}
	out, err := runYkman("otp", "calculate", fmt.Sprintf("%d", slot), hex.EncodeToString(chal))
	if err != nil {
		return nil, err
	}
	resp, err := parseResponse(out)
	if err != nil {
		return nil, err
	}
	return resp, nil
}

// parseResponse extracts the hex response from ykman's stdout, tolerating extra
// whitespace or trailing lines.
func parseResponse(out []byte) ([]byte, error) {
	var last string
	sc := bufio.NewScanner(bytes.NewReader(out))
	for sc.Scan() {
		if t := strings.TrimSpace(sc.Text()); t != "" {
			last = t
		}
	}
	last = strings.TrimSpace(last)
	if last == "" {
		return nil, errors.New("ykman returned an empty response")
	}
	b, err := hex.DecodeString(last)
	if err != nil {
		return nil, fmt.Errorf("unexpected ykman output %q: %w", last, err)
	}
	return b, nil
}

// Enroll performs a challenge-response now (prompting a touch) and returns a
// YubiKeyConfig that records the challenge and a salted hash of the response.
// This is called when the user turns the gate on.
func Enroll(slot int) (store.YubiKeyConfig, error) {
	chal := make([]byte, 20) // 160-bit challenge, matching HMAC-SHA1's block use
	if _, err := rand.Read(chal); err != nil {
		return store.YubiKeyConfig{}, err
	}
	resp, err := challenge(slot, chal)
	if err != nil {
		return store.YubiKeyConfig{}, err
	}
	salt := make([]byte, 16)
	if _, err := rand.Read(salt); err != nil {
		return store.YubiKeyConfig{}, err
	}
	return store.YubiKeyConfig{
		Challenge:    hex.EncodeToString(chal),
		ResponseSalt: hex.EncodeToString(salt),
		ResponseHash: hex.EncodeToString(hashResponse(salt, resp)),
	}, nil
}

// Verify re-runs the enrolled challenge and checks the response hash matches.
// It returns ErrKeyMismatch if the physical key does not produce the expected
// response. A touch is required if the slot demands one.
func Verify(cfg store.YubiKeyConfig, slot int) error {
	if !cfg.IsEnrolled() {
		return errors.New("no YubiKey enrolled")
	}
	chal, err := hex.DecodeString(cfg.Challenge)
	if err != nil {
		return fmt.Errorf("corrupt stored challenge: %w", err)
	}
	salt, err := hex.DecodeString(cfg.ResponseSalt)
	if err != nil {
		return fmt.Errorf("corrupt stored salt: %w", err)
	}
	want, err := hex.DecodeString(cfg.ResponseHash)
	if err != nil {
		return fmt.Errorf("corrupt stored hash: %w", err)
	}
	resp, err := challenge(slot, chal)
	if err != nil {
		return err
	}
	got := hashResponse(salt, resp)
	if subtle.ConstantTimeCompare(got, want) != 1 {
		return ErrKeyMismatch
	}
	return nil
}

// hashResponse computes sha256(salt || response).
func hashResponse(salt, resp []byte) []byte {
	h := sha256.New()
	h.Write(salt)
	h.Write(resp)
	return h.Sum(nil)
}
