// Package secrets implements the age-based at-rest encryption primitive used
// by the rest of the Command Center. Plaintext never touches disk: values are
// encrypted with the age identity loaded at startup and stored as opaque
// blobs in SQLite's `secrets` table.
package secrets

import (
	"bytes"
	"context"
	"database/sql"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"runtime"
	"time"

	"filippo.io/age"
)

// ErrNotFound is returned by Get and Delete when the requested key is absent.
var ErrNotFound = errors.New("secrets: key not found")

// Store is the secrets primitive. Construct one with New; call Set/Get/Delete
// from any goroutine — the underlying *sql.DB handles its own locking.
type Store struct {
	db        *sql.DB
	identity  *age.X25519Identity
	recipient age.Recipient
	keyPath   string
}

// Config controls how the Store locates its age identity. Fields not set fall
// back to the discovery order documented in DECISIONS.md D5.
type Config struct {
	// IdentityFile, if set, is tried first.
	IdentityFile string
	// CandidatePaths is the ordered list of fallback locations consulted when
	// IdentityFile is empty. Defaults to the platform-appropriate list.
	CandidatePaths []string
	// AllowGenerate, when true, permits the Store to create a new identity at
	// the first writable candidate path if none of the candidates exist.
	// Disable this in production deployments that require operator-managed keys.
	AllowGenerate bool
}

// DefaultCandidatePaths returns the discovery list for the current platform,
// matching DECISIONS.md D5.
func DefaultCandidatePaths() []string {
	var out []string
	if runtime.GOOS == "windows" {
		if local := os.Getenv("LOCALAPPDATA"); local != "" {
			out = append(out, filepath.Join(local, "command-center", "age.key"))
		}
		return out
	}
	out = append(out, "/etc/command-center/age.key")
	xdg := os.Getenv("XDG_CONFIG_HOME")
	if xdg == "" {
		if home, err := os.UserHomeDir(); err == nil {
			xdg = filepath.Join(home, ".config")
		}
	}
	if xdg != "" {
		out = append(out, filepath.Join(xdg, "command-center", "age.key"))
	}
	return out
}

// New constructs a Store. It will load (or, if Config.AllowGenerate is true,
// create) an age identity according to Config and the discovery order in
// DECISIONS.md D5. The returned Store is ready to use.
//
// db is the SQLite handle that owns the `secrets` table; the migration runner
// is expected to have created the table already.
func New(db *sql.DB, cfg Config) (*Store, error) {
	if db == nil {
		return nil, errors.New("secrets: nil db handle")
	}

	keyPath, identity, err := resolveIdentity(cfg)
	if err != nil {
		return nil, err
	}
	return &Store{
		db:        db,
		identity:  identity,
		recipient: identity.Recipient(),
		keyPath:   keyPath,
	}, nil
}

// KeyPath returns the on-disk location of the age identity in use. Useful for
// logging at startup so the operator knows where the key lives.
func (s *Store) KeyPath() string { return s.keyPath }

// Set encrypts value with the loaded age recipient and upserts it under key.
// Empty values are permitted (sometimes a "this secret was intentionally
// cleared" sentinel is useful).
func (s *Store) Set(ctx context.Context, key string, value []byte) error {
	if key == "" {
		return errors.New("secrets: empty key")
	}
	enc, err := s.encrypt(value)
	if err != nil {
		return err
	}
	_, err = s.db.ExecContext(ctx,
		`INSERT INTO secrets(key, encrypted_value, updated_at)
		 VALUES (?, ?, ?)
		 ON CONFLICT(key) DO UPDATE SET
		   encrypted_value = excluded.encrypted_value,
		   updated_at      = excluded.updated_at`,
		key, enc, time.Now().Unix(),
	)
	return err
}

// Get decrypts and returns the value previously stored under key.
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
	var enc []byte
	row := s.db.QueryRowContext(ctx,
		`SELECT encrypted_value FROM secrets WHERE key = ?`, key)
	switch err := row.Scan(&enc); {
	case errors.Is(err, sql.ErrNoRows):
		return nil, ErrNotFound
	case err != nil:
		return nil, err
	}
	return s.decrypt(enc)
}

// Delete removes the value under key. Returns ErrNotFound if no row was deleted.
func (s *Store) Delete(ctx context.Context, key string) error {
	res, err := s.db.ExecContext(ctx, `DELETE FROM secrets WHERE key = ?`, key)
	if err != nil {
		return err
	}
	n, _ := res.RowsAffected()
	if n == 0 {
		return ErrNotFound
	}
	return nil
}

func (s *Store) encrypt(plaintext []byte) ([]byte, error) {
	var buf bytes.Buffer
	w, err := age.Encrypt(&buf, s.recipient)
	if err != nil {
		return nil, fmt.Errorf("secrets: age encrypt: %w", err)
	}
	if _, err := w.Write(plaintext); err != nil {
		return nil, fmt.Errorf("secrets: age write: %w", err)
	}
	if err := w.Close(); err != nil {
		return nil, fmt.Errorf("secrets: age close: %w", err)
	}
	return buf.Bytes(), nil
}

func (s *Store) decrypt(ciphertext []byte) ([]byte, error) {
	r, err := age.Decrypt(bytes.NewReader(ciphertext), s.identity)
	if err != nil {
		return nil, fmt.Errorf("secrets: age decrypt: %w", err)
	}
	out, err := io.ReadAll(r)
	if err != nil {
		return nil, fmt.Errorf("secrets: age read: %w", err)
	}
	return out, nil
}

// resolveIdentity walks the discovery order, generating a fresh identity at
// the first writable candidate path if AllowGenerate is true and nothing was
// found.
//
// If cfg.IdentityFile is set, it is the ONLY path consulted: an explicit
// operator choice overrides the discovery fallback. This avoids surprising
// behavior in which a stale key at a default location takes precedence over
// the operator's stated preference.
func resolveIdentity(cfg Config) (string, *age.X25519Identity, error) {
	var candidates []string
	if cfg.IdentityFile != "" {
		candidates = []string{cfg.IdentityFile}
	} else if len(cfg.CandidatePaths) > 0 {
		candidates = cfg.CandidatePaths
	} else {
		candidates = DefaultCandidatePaths()
	}

	for _, p := range candidates {
		if p == "" {
			continue
		}
		if _, err := os.Stat(p); err == nil {
			id, err := loadIdentityFile(p)
			if err != nil {
				return "", nil, fmt.Errorf("secrets: load %s: %w", p, err)
			}
			return p, id, nil
		}
	}

	if !cfg.AllowGenerate {
		return "", nil, fmt.Errorf("secrets: no age identity found in any of: %v (set secrets.age_identity_file or run with dev_mode to auto-generate)", candidates)
	}

	for _, p := range candidates {
		if p == "" {
			continue
		}
		id, err := generateIdentityFile(p)
		if err == nil {
			return p, id, nil
		}
	}
	return "", nil, errors.New("secrets: no writable candidate path for new age identity")
}

func loadIdentityFile(path string) (*age.X25519Identity, error) {
	b, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	// age identity files conventionally hold one identity per non-comment line.
	for _, line := range bytes.Split(b, []byte("\n")) {
		t := bytes.TrimSpace(line)
		if len(t) == 0 || t[0] == '#' {
			continue
		}
		id, err := age.ParseX25519Identity(string(t))
		if err == nil {
			return id, nil
		}
	}
	return nil, errors.New("no AGE-SECRET-KEY-1 line found")
}

func generateIdentityFile(path string) (*age.X25519Identity, error) {
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return nil, err
	}
	id, err := age.GenerateX25519Identity()
	if err != nil {
		return nil, err
	}
	contents := fmt.Sprintf(
		"# created: %s\n# public key: %s\n%s\n",
		time.Now().UTC().Format(time.RFC3339), id.Recipient().String(), id.String(),
	)
	if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
		return nil, err
	}
	return id, nil
}
