// Package store persists dial's app data under ~/.ssh/.dial/ so it rides along
// on the user's existing Syncthing sync of the .ssh tree.
//
// Two files are used:
//   - settings.json — yubikey on/off + slot, theme, sort preference, and the
//     *hashed* backup recovery code (never any secret material).
//   - data.json     — recents/frequency stats and favorites.
//
// Because multiple machines may write concurrently through Syncthing, the
// contract here is deliberately forgiving: last-write-wins on save, and every
// load tolerates a missing, partial, or corrupt file by returning defaults
// rather than failing. Writes are atomic (temp file + rename) so a reader on
// another machine never observes a half-written file from a local write.
package store

import (
	"encoding/json"
	"os"
	"path/filepath"
	"sync"
	"time"
)

// SortMode controls the resting order of the host list.
type SortMode string

const (
	SortRecent   SortMode = "recent"   // most-recently-used first
	SortFrequent SortMode = "frequent" // most-frequently-used first
	SortAlpha    SortMode = "alpha"    // alphabetical by alias
)

// Next returns the following sort mode in the toggle cycle
// recent → frequent → alpha → recent.
func (s SortMode) Next() SortMode {
	switch s {
	case SortRecent:
		return SortFrequent
	case SortFrequent:
		return SortAlpha
	default:
		return SortRecent
	}
}

// Valid reports whether s is a known sort mode.
func (s SortMode) Valid() bool {
	return s == SortRecent || s == SortFrequent || s == SortAlpha
}

// Settings holds user preferences and the hashed recovery code. It contains
// nothing secret: the YubiKey secret never leaves the key in HMAC challenge-
// response mode, and the recovery code is stored only as a salted hash.
type Settings struct {
	YubiKeyEnabled bool     `json:"yubikey_enabled"`
	YubiKeySlot    int      `json:"yubikey_slot"` // 1 or 2 (default 2)
	Theme          string   `json:"theme"`        // "dark" (default) | "light"
	Sort           SortMode `json:"sort"`
	GroupByTag     bool     `json:"group_by_tag"` // section the list by tag

	// YubiKey holds the non-secret challenge and the salted hash of its expected
	// response, used to verify the physical key at launch. It is empty until the
	// gate is enrolled.
	YubiKey YubiKeyConfig `json:"yubikey,omitempty"`

	// Recovery is the salted hash of the one-time backup code. Empty when the
	// gate is disabled or no code is currently active.
	Recovery RecoveryHash `json:"recovery,omitempty"`
}

// YubiKeyConfig captures what dial needs to verify a YubiKey without storing any
// secret. Challenge is random and non-secret; ResponseHash is a salted SHA-256
// of the key's HMAC response to that challenge, which reveals nothing usable
// and cannot reproduce the response without the physical key.
type YubiKeyConfig struct {
	Challenge    string `json:"challenge"`     // hex-encoded random challenge
	ResponseSalt string `json:"response_salt"` // hex-encoded random salt
	ResponseHash string `json:"response_hash"` // hex sha256(salt || response)
}

// IsEnrolled reports whether a YubiKey verification record is present.
func (y YubiKeyConfig) IsEnrolled() bool {
	return y.Challenge != "" && y.ResponseHash != "" && y.ResponseSalt != ""
}

// RecoveryHash is the persisted, non-reversible form of a backup recovery code.
type RecoveryHash struct {
	Salt string `json:"salt"` // hex-encoded random salt
	Hash string `json:"hash"` // hex-encoded sha256(salt || code)
}

// IsSet reports whether an active recovery code hash is present.
func (r RecoveryHash) IsSet() bool { return r.Hash != "" && r.Salt != "" }

// DefaultSettings returns the settings used when none are stored or the stored
// file is unreadable/corrupt.
func DefaultSettings() Settings {
	return Settings{
		YubiKeyEnabled: false,
		YubiKeySlot:    2,
		Theme:          "dark",
		Sort:           SortRecent,
	}
}

// HostStat records usage of a single host alias for MRU/MFU sorting.
type HostStat struct {
	Count    int       `json:"count"`
	LastUsed time.Time `json:"last_used"`
}

// Data holds mutable usage state that changes on nearly every run.
type Data struct {
	Stats     map[string]*HostStat `json:"stats"`
	Favorites []string             `json:"favorites"`

	// LastUnlock records the most recent successful gate unlock. Unused by the
	// every-launch gate policy but persisted so an idle-timeout policy could be
	// added later without a data migration.
	LastUnlock time.Time `json:"last_unlock,omitempty"`
}

// DefaultData returns empty usage data.
func DefaultData() Data {
	return Data{Stats: map[string]*HostStat{}}
}

// mu serialises local writes so two goroutines in this process can't interleave
// temp files. It does nothing about cross-machine writes (Syncthing) — that is
// intentionally last-write-wins.
var mu sync.Mutex

// Dir returns ~/.ssh/.dial, creating it (0700) if necessary.
func Dir() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", err
	}
	d := filepath.Join(home, ".ssh", ".dial")
	if err := os.MkdirAll(d, 0o700); err != nil {
		return "", err
	}
	return d, nil
}

func settingsPath() (string, error) {
	d, err := Dir()
	if err != nil {
		return "", err
	}
	return filepath.Join(d, "settings.json"), nil
}

func dataPath() (string, error) {
	d, err := Dir()
	if err != nil {
		return "", err
	}
	return filepath.Join(d, "data.json"), nil
}

// LoadSettings reads settings, falling back to defaults on any error including a
// partial/corrupt file mid-sync. It never returns an error for that reason so
// the app always starts.
func LoadSettings() Settings {
	def := DefaultSettings()
	p, err := settingsPath()
	if err != nil {
		return def
	}
	b, err := os.ReadFile(p)
	if err != nil {
		return def
	}
	var s Settings
	if err := json.Unmarshal(b, &s); err != nil {
		return def // partial write / corruption: fall back
	}
	// Repair any nonsensical values that a bad/partial write could leave.
	if s.YubiKeySlot != 1 && s.YubiKeySlot != 2 {
		s.YubiKeySlot = def.YubiKeySlot
	}
	if s.Theme == "" {
		s.Theme = def.Theme
	}
	if !s.Sort.Valid() {
		s.Sort = def.Sort
	}
	return s
}

// SaveSettings writes settings atomically. Errors are returned because a failed
// settings write (e.g. toggling the gate) is meaningful to the caller.
func SaveSettings(s Settings) error {
	p, err := settingsPath()
	if err != nil {
		return err
	}
	return writeAtomic(p, s)
}

// LoadData reads usage data, falling back to empty defaults on any error.
func LoadData() Data {
	def := DefaultData()
	p, err := dataPath()
	if err != nil {
		return def
	}
	b, err := os.ReadFile(p)
	if err != nil {
		return def
	}
	var d Data
	if err := json.Unmarshal(b, &d); err != nil {
		return def
	}
	if d.Stats == nil {
		d.Stats = map[string]*HostStat{}
	}
	return d
}

// SaveData writes usage data atomically. A failed data write is non-fatal to
// the user's flow (they still connected), so callers may choose to ignore it.
func SaveData(d Data) error {
	p, err := dataPath()
	if err != nil {
		return err
	}
	if d.Stats == nil {
		d.Stats = map[string]*HostStat{}
	}
	return writeAtomic(p, d)
}

// Record bumps the usage stats for an alias. It loads, mutates, and saves so a
// connect can persist in one call; the save error is returned for logging but
// is safe to ignore.
func Record(alias string) error {
	d := LoadData()
	st := d.Stats[alias]
	if st == nil {
		st = &HostStat{}
		d.Stats[alias] = st
	}
	st.Count++
	st.LastUsed = time.Now()
	return SaveData(d)
}

// ToggleFavorite adds or removes an alias from favorites and persists.
func ToggleFavorite(alias string) (bool, error) {
	d := LoadData()
	for i, f := range d.Favorites {
		if f == alias {
			d.Favorites = append(d.Favorites[:i], d.Favorites[i+1:]...)
			return false, SaveData(d)
		}
	}
	d.Favorites = append(d.Favorites, alias)
	return true, SaveData(d)
}

// writeAtomic marshals v to JSON and writes it to path via a temp file + rename
// so concurrent readers never see a partial file.
func writeAtomic(path string, v any) error {
	mu.Lock()
	defer mu.Unlock()

	b, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		return err
	}
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, ".tmp-*")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	// Best-effort cleanup if we bail before the rename.
	defer os.Remove(tmpName)

	if _, err := tmp.Write(b); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	if err := os.Chmod(tmpName, 0o600); err != nil {
		return err
	}
	return os.Rename(tmpName, path)
}
