// Package config loads the agent's persistent configuration. After
// enrollment, the file holds the bearer token + server URL; it is
// only ever written via Save (which replaces atomically).
//
// Restic repo credentials live in a separate AEAD-encrypted file
// (see internal/agent/secrets) and never appear in this config.
package config

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"io"
	"os"
	"path/filepath"

	"gopkg.in/yaml.v3"

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

// Config is the on-disk shape of the agent's config file.
type Config struct {
	// ServerURL is the base URL of the control plane, e.g.
	// https://restic.lab.example. The agent appends /ws/agent and
	// /api/agents/enroll.
	ServerURL string `yaml:"server_url"`

	// AgentToken is the bearer credential issued at enrollment.
	// Empty means "not yet enrolled."
	AgentToken string `yaml:"agent_token"`

	// HostID is what the server thinks this host is.
	HostID string `yaml:"host_id"`

	// CertPinSHA256 (optional) is the SHA-256 of the server's TLS
	// cert. When set, the agent refuses to connect to a server
	// whose cert hash doesn't match.
	CertPinSHA256 string `yaml:"cert_pin_sha256,omitempty"`

	// ResticPath overrides the auto-detected restic binary path.
	ResticPath string `yaml:"restic_path,omitempty"`

	// SecretsPath is the on-disk location of the AEAD-encrypted
	// repo-creds blob. Defaults to /var/lib/restic-manager/secrets.enc
	// when empty. Phase 1 trust boundary: this file is 0600 owned by
	// the agent service user; the SecretsKey below sits in this same
	// agent.yaml (also 0600), so anyone who can read one can read the
	// other. Phase 2 swaps SecretsKey for an OS-keyring lookup.
	SecretsPath string `yaml:"secrets_path,omitempty"`

	// SecretsKey is the base64-encoded 32-byte AEAD master key for
	// the secrets file. Minted on first Save if absent.
	SecretsKey string `yaml:"secrets_key,omitempty"`

	// LegacyRepoURL / LegacyRepoPassword: remnants of the pre-P1-33
	// world where repo creds lived in plaintext here. Load() pulls
	// them off (so a config written by an older agent still works on
	// upgrade), the agent migrates them into secrets.enc on next
	// start, and Save() writes them out as empty (yamls "omitempty")
	// — i.e. the next file on disk no longer contains them.
	LegacyRepoURL      string `yaml:"repo_url,omitempty"`
	LegacyRepoPassword string `yaml:"repo_password,omitempty"`

	// AnnounceKey is the base64-encoded Ed25519 private key used by
	// announce-and-approve enrolment (P2-18). Generated on first
	// announce, persisted so the agent can re-attach to the same
	// pending row across restarts. 64 bytes when decoded.
	// Empty for token-flow enrolments.
	AnnounceKey string `yaml:"announce_key,omitempty"`

	// path is the file we loaded from. Used by Save.
	path string `yaml:"-"`
}

// DefaultSecretsPath is where the encrypted secrets blob lives on a
// stock Linux install. Override via Config.SecretsPath.
const DefaultSecretsPath = "/var/lib/restic-manager/secrets.enc"

// ResolvedSecretsPath returns SecretsPath if set, otherwise the
// default for the current OS.
func (c *Config) ResolvedSecretsPath() string {
	if c.SecretsPath != "" {
		return c.SecretsPath
	}
	return DefaultSecretsPath
}

// EnsureSecretsKey mints a fresh 32-byte AEAD key into c.SecretsKey
// if one isn't already set. Caller must Save() afterwards. Idempotent.
func (c *Config) EnsureSecretsKey() error {
	if c.SecretsKey != "" {
		return nil
	}
	raw := make([]byte, crypto.KeyLen)
	if _, err := io.ReadFull(rand.Reader, raw); err != nil {
		return fmt.Errorf("agent config: mint secrets key: %w", err)
	}
	c.SecretsKey = base64.StdEncoding.EncodeToString(raw)
	return nil
}

// SecretsKeyBytes returns the decoded AEAD key, or an error if the
// key is unset/malformed.
func (c *Config) SecretsKeyBytes() ([]byte, error) {
	if c.SecretsKey == "" {
		return nil, fmt.Errorf("agent config: secrets_key is empty")
	}
	b, err := base64.StdEncoding.DecodeString(c.SecretsKey)
	if err != nil {
		return nil, fmt.Errorf("agent config: decode secrets_key: %w", err)
	}
	if len(b) != crypto.KeyLen {
		return nil, fmt.Errorf("agent config: secrets_key must decode to %d bytes, got %d",
			crypto.KeyLen, len(b))
	}
	return b, nil
}

// DefaultPath returns the canonical config path for the current OS.
// Phase 1 ships Linux only; Windows path lives in the spec for P2.
func DefaultPath() string {
	return "/etc/restic-manager/agent.yaml"
}

// Load reads and parses the config file at path. A missing file is
// returned as an empty Config (not an error) — first-run agents
// haven't been enrolled yet.
func Load(path string) (*Config, error) {
	c := &Config{path: path}
	body, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return c, nil
		}
		return nil, fmt.Errorf("agent config: read %q: %w", path, err)
	}
	if err := yaml.Unmarshal(body, c); err != nil {
		return nil, fmt.Errorf("agent config: parse %q: %w", path, err)
	}
	c.path = path
	return c, nil
}

// Save writes the config back atomically: write to <path>.tmp, fsync,
// rename. A crash mid-write either leaves the old file or the new one,
// never a half-written one.
func (c *Config) Save() error {
	if c.path == "" {
		return fmt.Errorf("agent config: no path set")
	}
	dir := filepath.Dir(c.path)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return fmt.Errorf("agent config: mkdir %q: %w", dir, err)
	}
	body, err := yaml.Marshal(c)
	if err != nil {
		return fmt.Errorf("agent config: marshal: %w", err)
	}

	tmp := c.path + ".tmp"
	f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
	if err != nil {
		return fmt.Errorf("agent config: create tmp: %w", err)
	}
	if _, err := f.Write(body); err != nil {
		_ = f.Close()
		_ = os.Remove(tmp)
		return fmt.Errorf("agent config: write tmp: %w", err)
	}
	if err := f.Sync(); err != nil {
		_ = f.Close()
		_ = os.Remove(tmp)
		return fmt.Errorf("agent config: fsync tmp: %w", err)
	}
	if err := f.Close(); err != nil {
		_ = os.Remove(tmp)
		return fmt.Errorf("agent config: close tmp: %w", err)
	}
	if err := os.Rename(tmp, c.path); err != nil {
		_ = os.Remove(tmp)
		return fmt.Errorf("agent config: rename: %w", err)
	}
	return nil
}

// Enrolled reports whether the agent has finished enrollment.
func (c *Config) Enrolled() bool {
	return c.AgentToken != "" && c.HostID != "" && c.ServerURL != ""
}
