// Package auth provides bearer-token issuance and validation for
// zddc-server. Tokens are persisted as YAML files under
// <ZDDC_ROOT>/.zddc.d/tokens/, keyed by the SHA256 hash of the token
// value (filename = hash, never the plaintext token). The directory is
// shielded from public serving by the standard dot-prefix guard in the
// dispatcher and the listing filter in fs.ListDirectory.
//
// Format of a token file:
//
//	email: user@example.com
//	created: 2026-05-08T10:23:00Z
//	expires: 2026-08-08T10:23:00Z   # zero/missing = no expiry
//	description: Field laptop       # free-form, optional
//
// Token validation: client sends `Authorization: Bearer <token>`,
// server hashes, looks up <dir>/<sha256-hex>. If the file exists, parses
// cleanly, and is not expired, the token is valid and the request runs
// as the file's email.
package auth

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"

	"gopkg.in/yaml.v3"
)

// DirName is the leaf-directory name under <ZDDC_ROOT>/.zddc.d/ where
// token files live. Kept here so callers don't repeat the literal.
const DirName = "tokens"

// HashLen is the hex-encoded length of a SHA256 hash (the filename
// length for token files).
const HashLen = 64

// IDLen is the length of the short identifier used in the management UI
// and revocation API. First IDLen hex chars of the SHA256 hash.
const IDLen = 8

// ErrInvalidToken is returned when the token does not exist, fails to
// parse, or is expired. Distinct from other errors so callers can map
// it to a 401 without leaking which case occurred.
var ErrInvalidToken = errors.New("invalid token")

// ErrNotFound is returned by List/Revoke when a requested token does
// not exist. Revoke also returns this when the token exists but does
// not belong to the requesting email — same error to avoid leaking
// existence of other users' tokens.
var ErrNotFound = errors.New("token not found")

// Token holds the metadata persisted alongside a token. The token
// value itself (the bearer string the client sends) is NOT carried in
// this struct — it's only known at generation time, returned once to
// the user, and never re-derivable from the stored data.
type Token struct {
	// Hash is the hex-encoded SHA256 of the plaintext token. Equal
	// to the filename of the on-disk YAML file.
	Hash string `yaml:"-"`

	Email       string    `yaml:"email"`
	Created     time.Time `yaml:"created"`
	Expires     time.Time `yaml:"expires,omitempty"`
	Description string    `yaml:"description,omitempty"`
}

// ID returns the short identifier (first IDLen hex chars of Hash) used
// in the management UI and the revocation endpoint. Distinct from the
// secret token so it can be displayed and logged safely.
func (t Token) ID() string {
	if len(t.Hash) < IDLen {
		return t.Hash
	}
	return t.Hash[:IDLen]
}

// Expired reports whether the token has an expiry in the past. Tokens
// with a zero Expires never expire.
func (t Token) Expired(now time.Time) bool {
	return !t.Expires.IsZero() && !now.Before(t.Expires)
}

// Store manages the on-disk token directory.
type Store struct {
	dir string
	mu  sync.Mutex // serializes file creation; reads are lock-free
}

// NewStore opens (and creates if missing) the token directory at
// <root>/.zddc.d/tokens. Directory mode 0700 — single-user-trust at
// the FS layer.
func NewStore(root string) (*Store, error) {
	dir := filepath.Join(root, ".zddc.d", DirName)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return nil, fmt.Errorf("create token dir: %w", err)
	}
	// Best-effort tighten if pre-existing dir was looser. Don't fail
	// startup if chmod isn't permitted (NFS/SMB shares often refuse).
	_ = os.Chmod(dir, 0o700)
	return &Store{dir: dir}, nil
}

// Dir returns the absolute path of the token directory. Exposed for
// tests and operator diagnostics; not used in the request path.
func (s *Store) Dir() string { return s.dir }

// Generate creates a new token for the given email and writes its file.
// Returns the plaintext bearer token (to be displayed once to the user)
// and the persisted Token metadata.
//
// expires==zero means no expiry. description is free-form; the caller
// is responsible for any sanitization (e.g. trimming, length-capping).
func (s *Store) Generate(email, description string, expires time.Time) (string, Token, error) {
	if email == "" {
		return "", Token{}, errors.New("email required")
	}

	// 32 bytes = 256 bits of entropy, URL-safe base64 → 43-char string.
	var raw [32]byte
	if _, err := rand.Read(raw[:]); err != nil {
		return "", Token{}, fmt.Errorf("generate token: %w", err)
	}
	plaintext := base64.RawURLEncoding.EncodeToString(raw[:])
	sum := sha256.Sum256([]byte(plaintext))
	hashHex := hex.EncodeToString(sum[:])

	tok := Token{
		Hash:        hashHex,
		Email:       email,
		Created:     time.Now().UTC().Truncate(time.Second),
		Expires:     expires,
		Description: description,
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	if err := s.writeAtomic(hashHex, tok); err != nil {
		return "", Token{}, err
	}
	return plaintext, tok, nil
}

// Validate looks up a plaintext bearer token. Returns the persisted
// Token on success, ErrInvalidToken if missing/malformed/expired.
//
// Constant-time comparison: not strictly required since the lookup is
// keyed by the hash (no string comparison happens), but the hex
// filename comparison via os.Stat is timing-stable enough for our
// purposes — there's no known oracle that distinguishes "file missing"
// from "file present but expired" without observing the response code.
func (s *Store) Validate(plaintext string) (*Token, error) {
	if plaintext == "" {
		return nil, ErrInvalidToken
	}
	sum := sha256.Sum256([]byte(plaintext))
	hashHex := hex.EncodeToString(sum[:])

	tok, err := s.readByHash(hashHex)
	if err != nil {
		return nil, ErrInvalidToken
	}
	if tok.Expired(time.Now()) {
		return nil, ErrInvalidToken
	}
	return tok, nil
}

// List returns all tokens belonging to the given email, sorted by
// Created descending (newest first). Returns an empty slice (not nil)
// when the user has no tokens.
func (s *Store) List(email string) ([]Token, error) {
	if email == "" {
		return nil, errors.New("email required")
	}
	entries, err := os.ReadDir(s.dir)
	if err != nil {
		if os.IsNotExist(err) {
			return []Token{}, nil
		}
		return nil, err
	}
	out := make([]Token, 0, len(entries))
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if len(name) != HashLen || !isHex(name) {
			continue
		}
		tok, err := s.readByHash(name)
		if err != nil {
			continue
		}
		if !strings.EqualFold(tok.Email, email) {
			continue
		}
		out = append(out, *tok)
	}
	sort.Slice(out, func(i, j int) bool {
		return out[i].Created.After(out[j].Created)
	})
	return out, nil
}

// Revoke deletes the token identified by the given short ID (first
// IDLen hex chars of the hash) or the full hash. Only revokes tokens
// belonging to email; returns ErrNotFound otherwise (whether the token
// is missing or owned by a different user — to avoid leaking ownership).
func (s *Store) Revoke(email, idOrHash string) error {
	if email == "" {
		return errors.New("email required")
	}
	idOrHash = strings.ToLower(strings.TrimSpace(idOrHash))

	s.mu.Lock()
	defer s.mu.Unlock()

	// Full-hash path: O(1) lookup.
	if len(idOrHash) == HashLen && isHex(idOrHash) {
		tok, err := s.readByHash(idOrHash)
		if err != nil {
			return ErrNotFound
		}
		if !strings.EqualFold(tok.Email, email) {
			return ErrNotFound
		}
		return os.Remove(filepath.Join(s.dir, idOrHash))
	}

	// Short-ID path: must scan. Token IDs are 8 hex chars (~32 bits),
	// collision-resistant in practice for any reasonable per-user
	// token count. We pick the (single) match owned by email.
	if len(idOrHash) < 4 || !isHex(idOrHash) {
		return ErrNotFound
	}
	entries, err := os.ReadDir(s.dir)
	if err != nil {
		return ErrNotFound
	}
	var matches []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if len(name) != HashLen || !isHex(name) {
			continue
		}
		if !strings.HasPrefix(name, idOrHash) {
			continue
		}
		tok, err := s.readByHash(name)
		if err != nil {
			continue
		}
		if !strings.EqualFold(tok.Email, email) {
			continue
		}
		matches = append(matches, name)
	}
	if len(matches) == 0 {
		return ErrNotFound
	}
	if len(matches) > 1 {
		return fmt.Errorf("ambiguous id %q matches %d tokens", idOrHash, len(matches))
	}
	return os.Remove(filepath.Join(s.dir, matches[0]))
}

// readByHash reads and parses the token file named by hashHex. The
// returned Token has its Hash field set.
func (s *Store) readByHash(hashHex string) (*Token, error) {
	if len(hashHex) != HashLen || !isHex(hashHex) {
		return nil, ErrInvalidToken
	}
	path := filepath.Join(s.dir, hashHex)
	bytes, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var tok Token
	if err := yaml.Unmarshal(bytes, &tok); err != nil {
		return nil, err
	}
	tok.Hash = hashHex
	if tok.Email == "" {
		return nil, ErrInvalidToken
	}
	return &tok, nil
}

// writeAtomic serializes a token to YAML and writes it via tmp+rename.
// File mode 0600.
func (s *Store) writeAtomic(hashHex string, tok Token) error {
	bytes, err := yaml.Marshal(tok)
	if err != nil {
		return fmt.Errorf("marshal token: %w", err)
	}
	final := filepath.Join(s.dir, hashHex)
	tmp, err := os.CreateTemp(s.dir, ".tmp-*")
	if err != nil {
		return fmt.Errorf("create temp: %w", err)
	}
	tmpName := tmp.Name()
	if _, err := tmp.Write(bytes); err != nil {
		tmp.Close()
		os.Remove(tmpName)
		return fmt.Errorf("write temp: %w", err)
	}
	if err := tmp.Chmod(0o600); err != nil {
		tmp.Close()
		os.Remove(tmpName)
		return fmt.Errorf("chmod temp: %w", err)
	}
	if err := tmp.Close(); err != nil {
		os.Remove(tmpName)
		return fmt.Errorf("close temp: %w", err)
	}
	if err := os.Rename(tmpName, final); err != nil {
		os.Remove(tmpName)
		return fmt.Errorf("rename temp: %w", err)
	}
	return nil
}

// isHex reports whether s is a non-empty string of [0-9a-f] characters.
func isHex(s string) bool {
	if s == "" {
		return false
	}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
			return false
		}
	}
	return true
}
