// Package secrets persists the agent's restic repo credentials in an
// AEAD-encrypted file. Phase 1 stores them at rest under a 32-byte
// key kept in agent.yaml (same 0600 root-only trust boundary as the
// bearer token); Phase 2 will swap that for the OS keyring on
// platforms that have one (DPAPI / Secret Service / kwallet).
//
// The wire push path (server → agent over WS as `config.update`) is
// unchanged; this package owns only on-disk persistence.
package secrets

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
)

// additionalData binds ciphertexts to the agent-secrets context, so a
// blob lifted from one role's file can't be replayed into another's
// row in some unrelated table that uses the same key. (Defence in
// depth — the key is per-host today, but cheap to be careful.)
const additionalData = "rm-agent-repo-creds-v1"

// ErrNoAdmin is returned by LoadAdmin when no admin slot has been
// written yet. Callers must distinguish this from a hard error: the
// agent simply hasn't received an admin config.update push yet.
var ErrNoAdmin = errors.New("secrets: admin slot not configured")

// Repo is the plaintext shape persisted inside the AEAD blob.
type Repo struct {
	URL      string `json:"repo_url,omitempty"`
	Username string `json:"repo_username,omitempty"`
	Password string `json:"repo_password,omitempty"`
}

// Empty reports whether the credential set is missing the bare
// minimum (URL + password) needed to run a backup.
func (r Repo) Empty() bool { return r.URL == "" || r.Password == "" }

// bundle is the on-disk JSON shape as of secrets v2. It holds the
// everyday repo slot and an optional admin slot (prune / unlock).
// Legacy files (pre-v2) contain a flat Repo object; loadBundle
// transparently upgrades those on the next Save.
type bundle struct {
	Repo  Repo  `json:"repo,omitempty"`
	Admin *Repo `json:"admin,omitempty"`
}

// Store reads and writes the encrypted secrets file at Path, sealed
// under the 32-byte key Key.
type Store struct {
	path string
	a    *crypto.AEAD
}

// New opens a Store. The key must be exactly crypto.KeyLen bytes
// (32). The file at path is not read here — call Load.
func New(path string, key []byte) (*Store, error) {
	if path == "" {
		return nil, errors.New("secrets: empty path")
	}
	a, err := crypto.NewAEAD(key)
	if err != nil {
		return nil, fmt.Errorf("secrets: %w", err)
	}
	return &Store{path: path, a: a}, nil
}

// loadBundle reads and decrypts the on-disk blob, returning a bundle.
// It handles back-compat decode: legacy flat Repo blobs are detected
// by the presence of a top-level "repo_url" key and re-wrapped into
// the bundle shape transparently. Returns an empty bundle when the
// file does not exist yet.
func (s *Store) loadBundle() (bundle, error) {
	body, err := os.ReadFile(s.path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return bundle{}, nil
		}
		return bundle{}, fmt.Errorf("secrets: read %q: %w", s.path, err)
	}
	plain, err := s.a.Decrypt(string(body), []byte(additionalData))
	if err != nil {
		return bundle{}, fmt.Errorf("secrets: decrypt %q: %w", s.path, err)
	}

	// Try the new bundle shape first.
	var b bundle
	if err := json.Unmarshal(plain, &b); err != nil {
		return bundle{}, fmt.Errorf("secrets: parse %q: %w", s.path, err)
	}

	// If the bundle has an empty Repo slot but the raw JSON contains
	// a top-level "repo_url" key, this is a legacy flat blob —
	// re-unmarshal it as a Repo and slot it in.
	if b.Repo == (Repo{}) && bytes.Contains(plain, []byte(`"repo_url"`)) {
		var legacy Repo
		if err := json.Unmarshal(plain, &legacy); err == nil {
			b.Repo = legacy
		}
	}

	return b, nil
}

// saveBundle marshals b, encrypts it and writes it atomically at
// mode 0600. Parent directory must already exist.
func (s *Store) saveBundle(b bundle) error {
	body, err := json.Marshal(b)
	if err != nil {
		return fmt.Errorf("secrets: marshal: %w", err)
	}
	ct, err := s.a.Encrypt(body, []byte(additionalData))
	if err != nil {
		return fmt.Errorf("secrets: encrypt: %w", err)
	}
	dir := filepath.Dir(s.path)
	tmp, err := os.CreateTemp(dir, ".secrets-*.tmp")
	if err != nil {
		return fmt.Errorf("secrets: create tmp: %w", err)
	}
	tmpPath := tmp.Name()
	defer func() {
		_ = tmp.Close()
		_ = os.Remove(tmpPath) // no-op once Rename succeeds
	}()
	if err := tmp.Chmod(0o600); err != nil {
		return fmt.Errorf("secrets: chmod tmp: %w", err)
	}
	if _, err := tmp.WriteString(ct); err != nil {
		return fmt.Errorf("secrets: write tmp: %w", err)
	}
	if err := tmp.Sync(); err != nil {
		return fmt.Errorf("secrets: fsync tmp: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("secrets: close tmp: %w", err)
	}
	if err := os.Rename(tmpPath, s.path); err != nil {
		return fmt.Errorf("secrets: rename: %w", err)
	}
	return nil
}

// Load returns the persisted Repo (the everyday repo slot), or a
// zero-value Repo (with no error) if the file does not exist yet —
// first-run agents have nothing on disk until the server pushes a
// config.update.
func (s *Store) Load() (Repo, error) {
	b, err := s.loadBundle()
	if err != nil {
		return Repo{}, err
	}
	return b.Repo, nil
}

// Save replaces the repo slot on disk atomically, preserving the
// admin slot. Mode is 0600. Parent directory must already exist.
func (s *Store) Save(r Repo) error {
	b, err := s.loadBundle()
	if err != nil {
		return fmt.Errorf("secrets: load before save: %w", err)
	}
	b.Repo = r
	return s.saveBundle(b)
}

// LoadAdmin returns the admin slot, or (Repo{}, ErrNoAdmin) when no
// admin slot has been set. All other errors are hard failures.
func (s *Store) LoadAdmin() (Repo, error) {
	b, err := s.loadBundle()
	if err != nil {
		return Repo{}, err
	}
	if b.Admin == nil {
		return Repo{}, ErrNoAdmin
	}
	return *b.Admin, nil
}

// SaveAdmin replaces the admin slot on disk atomically, preserving
// the repo slot. Mode is 0600.
func (s *Store) SaveAdmin(r Repo) error {
	b, err := s.loadBundle()
	if err != nil {
		return fmt.Errorf("secrets: load before save: %w", err)
	}
	b.Admin = &r
	return s.saveBundle(b)
}
