// Package localapi implements the agent's per-guest local API (doc 03 §6, slice 8A): the
// HTTPS server the in-guest controller calls over the bridge, the durable per-guest token
// store, and the self-signed leaf the agent serves. The agent is the per-guest authorization
// gate — every call is authorized against the token's guest only (self-scoped), never a
// caller-supplied id.
package localapi

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sync"
)

// TokenStore is the durable, crash-safe per-guest token→guest map. It mirrors the
// authz.FileNonceStore mechanism (fsync'd append-only JSONL + in-memory index) with two
// differences: it stores a SHA-256 HASH of each token (never the plaintext — the plaintext
// exists only transiently between Mint and write-to-mount, then is discarded), and it is a
// last-write-wins map keyed by VMID (re-provisioning a guest rotates its token).
//
// Security: a leaked token store reveals only hashes, not usable tokens. Lookup is
// constant-time per candidate (subtle.ConstantTimeCompare) to avoid leaking the hash via
// timing. Single-process; one mutex guards the file handle and both indexes (03 §10).
type TokenStore struct {
	mu     sync.Mutex
	path   string
	f      *os.File
	byHash map[string]int // tokenHash(hex) -> vmid
	byVMID map[int]string // vmid -> current tokenHash(hex)
}

// tokenRecord is one durable line: VMID v gets token-hash h (last write per v wins).
type tokenRecord struct {
	VMID int    `json:"v"`
	Hash string `json:"h"`
}

// tokenBytes is the per-guest token length (256-bit; well above the ≥128-bit requirement).
const tokenBytes = 32

// OpenTokenStore opens (or creates) the durable store at path, replaying the log into the
// in-memory indexes (last record per VMID wins; a superseded hash is dropped from byHash).
func OpenTokenStore(path string) (*TokenStore, error) {
	s := &TokenStore{
		path:   path,
		byHash: make(map[string]int),
		byVMID: make(map[int]string),
	}
	if err := s.load(); err != nil {
		return nil, err
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return nil, fmt.Errorf("localapi: token store dir: %w", err)
	}
	f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
	if err != nil {
		return nil, err
	}
	s.f = f
	syncDir(filepath.Dir(path))
	return s, nil
}

func (s *TokenStore) load() error {
	b, err := os.ReadFile(s.path)
	if errors.Is(err, fs.ErrNotExist) {
		return nil
	}
	if err != nil {
		return err
	}
	for _, line := range bytes.Split(b, []byte("\n")) {
		line = bytes.TrimSpace(line)
		if len(line) == 0 {
			continue
		}
		var r tokenRecord
		if json.Unmarshal(line, &r) != nil {
			continue // skip a torn trailing line from a crash mid-append
		}
		s.apply(r)
	}
	return nil
}

// apply folds one record into the indexes (last-write-wins per VMID). Caller holds the mutex
// (or is in single-threaded load).
func (s *TokenStore) apply(r tokenRecord) {
	if old, ok := s.byVMID[r.VMID]; ok && old != r.Hash {
		delete(s.byHash, old) // the previous token for this guest is no longer valid
	}
	s.byVMID[r.VMID] = r.Hash
	s.byHash[r.Hash] = r.VMID
}

// Mint generates a fresh high-entropy token for vmid, durably records its HASH (last-write
// wins — any previous token for this guest is revoked), and returns the PLAINTEXT exactly
// once. The caller must write the plaintext into the guest's bootstrap mount and then discard
// it; only the hash is persisted. On any I/O failure the token is not recorded and an error is
// returned (the caller must not hand out an unrecorded token).
func (s *TokenStore) Mint(vmid int) (string, error) {
	if vmid <= 0 {
		return "", fmt.Errorf("localapi: Mint needs a positive vmid")
	}
	raw := make([]byte, tokenBytes)
	if _, err := rand.Read(raw); err != nil {
		return "", fmt.Errorf("localapi: token entropy: %w", err)
	}
	token := base64.RawURLEncoding.EncodeToString(raw)
	hash := hashToken(token)

	s.mu.Lock()
	defer s.mu.Unlock()
	rec, _ := json.Marshal(tokenRecord{VMID: vmid, Hash: hash})
	rec = append(rec, '\n')
	if _, err := s.f.Write(rec); err != nil {
		return "", fmt.Errorf("localapi: token store write: %w", err)
	}
	if err := s.f.Sync(); err != nil {
		return "", fmt.Errorf("localapi: token store sync: %w", err)
	}
	s.apply(tokenRecord{VMID: vmid, Hash: hash})
	return token, nil
}

// Lookup resolves a presented bearer token to its guest VMID. It hashes the candidate and
// looks it up; the per-candidate comparison is constant-time to avoid a timing oracle on the
// stored hash. ok is false for an unknown/empty token.
func (s *TokenStore) Lookup(token string) (int, bool) {
	if token == "" {
		return 0, false
	}
	want := hashToken(token)
	s.mu.Lock()
	defer s.mu.Unlock()
	// Direct map hit is the common path; the constant-time compare guards against a timing
	// side-channel by re-checking the matched key (map lookup itself is not the secret-bearing
	// comparison — the hash of a random 256-bit token is not feasibly guessable regardless).
	if vmid, ok := s.byHash[want]; ok {
		if subtle.ConstantTimeCompare([]byte(want), []byte(s.byVMID[vmid])) == 1 {
			return vmid, true
		}
	}
	return 0, false
}

// Close releases the file handle.
func (s *TokenStore) Close() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.f != nil {
		return s.f.Close()
	}
	return nil
}

// hashToken is the persisted representation of a token: lowercase hex SHA-256.
func hashToken(tok string) string {
	sum := sha256.Sum256([]byte(tok))
	return hex.EncodeToString(sum[:])
}

// syncDir best-effort fsyncs a directory so a create/rename is durable.
func syncDir(dir string) {
	if d, err := os.Open(dir); err == nil {
		_ = d.Sync()
		_ = d.Close()
	}
}
