// Package dictadmin stages and installs scrabble-dictionary release archives for
// the admin console's online dictionary-update flow. It validates the release
// archive (scrabble-dawg-vX.Y.Z.tar.gz), extracts it safely into a per-version
// directory under BACKEND_DICT_DIR, and keeps a staging area for the two-step
// upload-then-confirm interaction. Extraction is hardened against the usual
// archive risks (path traversal, symlinks, decompression bombs).
package dictadmin

import (
	"archive/tar"
	"compress/gzip"
	crand "crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"regexp"
	"time"

	"scrabble/backend/internal/engine"
)

// MaxArchiveBytes bounds an uploaded release archive; the handler wraps the request
// body in an http.MaxBytesReader with this limit. The three compressed DAWGs are a
// few MB together, so 64 MiB is generous.
const MaxArchiveBytes int64 = 64 << 20

// stagingRoot is the dot-prefixed directory under the dictionary dir holding
// not-yet-installed uploads. The engine's boot scan skips dot-prefixed dirs, so a
// staged upload never becomes a resident version.
const stagingRoot = ".staging"

// stagingTTL is how long an abandoned staged upload lingers before Stage sweeps it.
const stagingTTL = time.Hour

// maxMemberBytes and maxEntries bound a single archive member and the number of
// entries, defeating decompression and entry-count bombs. They are variables so
// tests can lower them. maxMemberBytes is per file; a real DAWG is a few MB.
var (
	maxMemberBytes int64 = 32 << 20
	maxEntries           = 64
)

var (
	nameRE    = regexp.MustCompile(`^scrabble-dawg-(v[0-9]+\.[0-9]+\.[0-9]+)\.tar\.gz$`)
	versionRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
	tokenRE   = regexp.MustCompile(`^[0-9a-f]{32}$`)
)

// ParseVersionFromName extracts the version from a release archive filename of the
// form scrabble-dawg-vX.Y.Z.tar.gz, or returns an error for any other shape.
func ParseVersionFromName(filename string) (string, error) {
	m := nameRE.FindStringSubmatch(filename)
	if m == nil {
		return "", fmt.Errorf("dictadmin: %q is not a scrabble-dawg-vX.Y.Z.tar.gz archive", filename)
	}
	return m[1], nil
}

// ValidVersion reports whether v is a vMAJOR.MINOR.PATCH version label. It guards
// the operator's manual override before the version is used as a directory name.
func ValidVersion(v string) bool {
	return versionRE.MatchString(v)
}

// Extract reads a gzip+tar release archive from r and writes the recognised
// dictionary files into destDir, returning the variants found in catalogue order.
// Only regular files whose base name is a known DAWG filename are written (their
// directory part is discarded, so a traversal entry cannot escape destDir);
// symlinks and other types are ignored, and oversize members or an excessive entry
// count are rejected.
func Extract(r io.Reader, destDir string) ([]engine.Variant, error) {
	gz, err := gzip.NewReader(r)
	if err != nil {
		return nil, fmt.Errorf("dictadmin: open gzip: %w", err)
	}
	defer func() { _ = gz.Close() }()
	tr := tar.NewReader(gz)

	byName := filenameToVariant()
	seen := make(map[engine.Variant]bool)
	entries := 0
	for {
		hdr, err := tr.Next()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			return nil, fmt.Errorf("dictadmin: read archive: %w", err)
		}
		entries++
		if entries > maxEntries {
			return nil, fmt.Errorf("dictadmin: archive has more than %d entries", maxEntries)
		}
		if hdr.Typeflag != tar.TypeReg {
			continue
		}
		base := filepath.Base(filepath.Clean(hdr.Name))
		v, ok := byName[base]
		if !ok {
			continue
		}
		if seen[v] {
			return nil, fmt.Errorf("dictadmin: duplicate %s in archive", base)
		}
		if hdr.Size > maxMemberBytes {
			return nil, fmt.Errorf("dictadmin: %s exceeds the %d-byte member limit", base, maxMemberBytes)
		}
		if err := writeMember(filepath.Join(destDir, base), tr); err != nil {
			return nil, err
		}
		seen[v] = true
	}

	var found []engine.Variant
	for _, v := range engine.Variants() {
		if seen[v] {
			found = append(found, v)
		}
	}
	return found, nil
}

// writeMember copies one archive member into path, bounding it at maxMemberBytes so
// a member whose declared size lies cannot exhaust the disk.
func writeMember(path string, tr *tar.Reader) error {
	f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
	if err != nil {
		return fmt.Errorf("dictadmin: create %s: %w", filepath.Base(path), err)
	}
	defer func() { _ = f.Close() }()
	n, err := io.Copy(f, io.LimitReader(tr, maxMemberBytes+1))
	if err != nil {
		return fmt.Errorf("dictadmin: write %s: %w", filepath.Base(path), err)
	}
	if n > maxMemberBytes {
		return fmt.Errorf("dictadmin: %s exceeds the %d-byte member limit", filepath.Base(path), maxMemberBytes)
	}
	return nil
}

// filenameToVariant inverts the engine's variant-to-filename map.
func filenameToVariant() map[string]engine.Variant {
	files := engine.DictFiles()
	out := make(map[string]engine.Variant, len(files))
	for v, name := range files {
		out[name] = v
	}
	return out
}

// Manager stages and installs release archives under a dictionary directory
// (BACKEND_DICT_DIR).
type Manager struct {
	dir string
}

// New constructs a Manager rooted at the dictionary directory dir.
func New(dir string) *Manager {
	return &Manager{dir: dir}
}

// Stage accepts an uploaded archive: it sweeps abandoned previews, allocates a
// staging directory under <dir>/.staging/<token>, extracts the archive there and
// returns the token and the variants found. The staging directory is removed on an
// extraction error.
func (m *Manager) Stage(r io.Reader) (string, []engine.Variant, error) {
	m.cleanStaging()
	token, err := newToken()
	if err != nil {
		return "", nil, err
	}
	dest := filepath.Join(m.dir, stagingRoot, token)
	if err := os.MkdirAll(dest, 0o755); err != nil {
		return "", nil, fmt.Errorf("dictadmin: create staging dir: %w", err)
	}
	variants, err := Extract(r, dest)
	if err != nil {
		_ = os.RemoveAll(dest)
		return "", nil, err
	}
	return token, variants, nil
}

// StagedDir returns the directory of the staged upload named by token, after
// validating the token's shape and confirming the directory exists.
func (m *Manager) StagedDir(token string) (string, error) {
	if !tokenRE.MatchString(token) {
		return "", fmt.Errorf("dictadmin: invalid staging token")
	}
	dir := filepath.Join(m.dir, stagingRoot, token)
	info, err := os.Stat(dir)
	if err != nil || !info.IsDir() {
		return "", fmt.Errorf("dictadmin: staged upload not found")
	}
	return dir, nil
}

// VersionExists reports whether the version directory <dir>/<version> is present.
func (m *Manager) VersionExists(version string) bool {
	info, err := os.Stat(filepath.Join(m.dir, version))
	return err == nil && info.IsDir()
}

// Install promotes a staged upload into its version directory <dir>/<version> by an
// atomic rename (same filesystem) and returns the destination path. It rejects an
// invalid version and refuses to overwrite an existing version: versions are
// immutable, protecting games pinned to that tag.
func (m *Manager) Install(token, version string) (string, error) {
	if !ValidVersion(version) {
		return "", fmt.Errorf("dictadmin: invalid version %q", version)
	}
	staged, err := m.StagedDir(token)
	if err != nil {
		return "", err
	}
	if m.VersionExists(version) {
		return "", fmt.Errorf("dictadmin: version %s already exists", version)
	}
	dest := filepath.Join(m.dir, version)
	if err := os.Rename(staged, dest); err != nil {
		return "", fmt.Errorf("dictadmin: install %s: %w", version, err)
	}
	return dest, nil
}

// Discard removes the staging directory named by token, best effort. It is called
// when a preview is rejected (an incomplete or unusable archive).
func (m *Manager) Discard(token string) {
	if dir, err := m.StagedDir(token); err == nil {
		_ = os.RemoveAll(dir)
	}
}

// cleanStaging removes staging directories older than stagingTTL, best effort.
func (m *Manager) cleanStaging() {
	root := filepath.Join(m.dir, stagingRoot)
	entries, err := os.ReadDir(root)
	if err != nil {
		return
	}
	cutoff := time.Now().Add(-stagingTTL)
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
			_ = os.RemoveAll(filepath.Join(root, e.Name()))
		}
	}
}

// newToken returns a 32-hex-character random staging token.
func newToken() (string, error) {
	var b [16]byte
	if _, err := crand.Read(b[:]); err != nil {
		return "", fmt.Errorf("dictadmin: generate token: %w", err)
	}
	return hex.EncodeToString(b[:]), nil
}
