// Package backup implements orca's signed tarball backup/restore
// subsystem (P04, v0.11 milestone).
//
// The model is tar.gz + HMAC-SHA256 signature:
//
//   - Backup walks the ORCA_HOME recursively, excludes ephemeral
//     paths (/run/orca/*), unix sockets (*.sock), and SQLite WAL/SHM
//     sidecars (*.db-wal, *.db-shm), packs the rest into a tar.gz, and
//     computes an HMAC-SHA256 of the tarball using the cluster master
//     key. The tarball is written to OutputPath; the hex-encoded
//     signature to OutputPath + ".sig".
//   - VerifySignature recomputes the HMAC and compares it (constant
//     time) against the recorded signature.
//   - Restore verifies the signature first (refuses on mismatch), then
//     extracts the tarball to TargetDir. With Force=false it refuses to
//     clobber a non-empty TargetDir; with Force=true it overwrites.
//
// The package never logs key material. slog calls carry only metadata.
package backup

import (
	"archive/tar"
	"compress/gzip"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
)

// ErrSignatureMismatch is returned when the recorded HMAC-SHA256
// signature does not match the recomputed one (tampering or wrong key).
var ErrSignatureMismatch = errors.New("backup: signature mismatch")

// ErrTargetNotEmpty is returned when Restore is called with Force=false
// against a non-empty TargetDir.
var ErrTargetNotEmpty = errors.New("backup: target directory not empty (use Force to overwrite)")

// BackupOptions configures Backup.
type BackupOptions struct {
	SourceDir  string // ORCA_HOME — the tree to back up
	OutputPath string // destination tarball path (.tar.gz)
	MasterKey  []byte // HMAC-SHA256 key (cluster master.key)
}

// RestoreOptions configures Restore.
type RestoreOptions struct {
	InputPath string // source tarball path (.tar.gz)
	TargetDir string // destination ORCA_HOME
	MasterKey []byte // HMAC-SHA256 key (for verification)
	Force     bool   // overwrite non-empty target
}

// excludeGlobSuffixes are the suffixes excluded from the backup. We
// exclude SQLite WAL/SHM sidecars (the main db is backed up) and unix
// sockets.
var excludeGlobSuffixes = []string{".sock", ".db-wal", ".db-shm"}

// shouldExclude reports whether a path should be excluded from the
// backup. It excludes /run/orca/* (ephemeral runtime), *.sock, *.db-wal,
// and *.db-shm. The /run/orca match is done on the absolute path; the
// suffix matches are done on the base name.
func shouldExclude(absPath string) bool {
	clean := filepath.Clean(absPath)
	if strings.HasPrefix(clean, "/run/orca/") || clean == "/run/orca" {
		return true
	}
	if idx := strings.LastIndex(clean, string(filepath.Separator)+"run"+string(filepath.Separator)+"orca"+string(filepath.Separator)); idx >= 0 {
		return true
	}
	if strings.HasSuffix(clean, string(filepath.Separator)+"run"+string(filepath.Separator)+"orca") {
		return true
	}
	base := filepath.Base(clean)
	for _, suf := range excludeGlobSuffixes {
		if strings.HasSuffix(base, suf) {
			return true
		}
	}
	return false
}

// Backup creates a signed tar.gz of SourceDir. The tarball is written
// to OutputPath and the hex-encoded HMAC-SHA256 signature to
// OutputPath + ".sig". The write is atomic: the tarball is streamed to
// a temp file in the same directory and renamed on success; the
// signature is written after the rename so a crash never leaves a
// tarball with a stale or missing signature.
func Backup(opts BackupOptions) error {
	if opts.SourceDir == "" {
		return fmt.Errorf("backup: SourceDir is empty")
	}
	if opts.OutputPath == "" {
		return fmt.Errorf("backup: OutputPath is empty")
	}
	if len(opts.MasterKey) == 0 {
		return fmt.Errorf("backup: MasterKey is empty")
	}

	src, err := filepath.Abs(opts.SourceDir)
	if err != nil {
		return fmt.Errorf("backup: resolve SourceDir: %w", err)
	}
	info, err := os.Stat(src)
	if err != nil {
		return fmt.Errorf("backup: stat SourceDir: %w", err)
	}
	if !info.IsDir() {
		return fmt.Errorf("backup: SourceDir %q is not a directory", src)
	}

	outDir := filepath.Dir(opts.OutputPath)
	if err := os.MkdirAll(outDir, 0o755); err != nil {
		return fmt.Errorf("backup: mkdir output dir: %w", err)
	}

	tmp, err := os.CreateTemp(outDir, ".orca-backup-*.tar.gz.tmp")
	if err != nil {
		return fmt.Errorf("backup: create temp tarball: %w", err)
	}
	tmpPath := tmp.Name()
	defer func() {
		tmp.Close()
		_ = os.Remove(tmpPath)
	}()

	gw := gzip.NewWriter(tmp)
	tw := tar.NewWriter(gw)

	var walked int
	walkErr := filepath.Walk(src, func(path string, fi os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if shouldExclude(path) {
			if fi.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		rel, rerr := filepath.Rel(src, path)
		if rerr != nil {
			return fmt.Errorf("rel path %s: %w", path, rerr)
		}
		if rel == "." {
			return nil
		}
		hdr, herr := tar.FileInfoHeader(fi, "")
		if herr != nil {
			return fmt.Errorf("tar header for %s: %w", path, herr)
		}
		hdr.Name = filepath.ToSlash(rel)
		if err := tw.WriteHeader(hdr); err != nil {
			return fmt.Errorf("write header %s: %w", rel, err)
		}
		if !fi.Mode().IsRegular() {
			return nil
		}
		f, oerr := os.Open(path)
		if oerr != nil {
			return fmt.Errorf("open %s: %w", path, oerr)
		}
		defer f.Close()
		if _, err := io.Copy(tw, f); err != nil {
			return fmt.Errorf("copy %s: %w", rel, err)
		}
		walked++
		return nil
	})
	if walkErr != nil {
		tw.Close()
		gw.Close()
		return fmt.Errorf("backup: walk: %w", walkErr)
	}
	if err := tw.Close(); err != nil {
		gw.Close()
		return fmt.Errorf("backup: close tar writer: %w", err)
	}
	if err := gw.Close(); err != nil {
		return fmt.Errorf("backup: close gzip writer: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("backup: close temp tarball: %w", err)
	}

	if err := os.Rename(tmpPath, opts.OutputPath); err != nil {
		return fmt.Errorf("backup: rename tarball: %w", err)
	}

	sig, err := computeSignature(opts.OutputPath, opts.MasterKey)
	if err != nil {
		return fmt.Errorf("backup: compute signature: %w", err)
	}
	if err := os.WriteFile(opts.OutputPath+".sig", []byte(hex.EncodeToString(sig)), 0o644); err != nil {
		return fmt.Errorf("backup: write signature: %w", err)
	}

	slog.Info("backup complete", "path", opts.OutputPath, "files", walked, "sig", opts.OutputPath+".sig")
	return nil
}

// computeSignature reads the file at path and returns its HMAC-SHA256
// MAC under key.
func computeSignature(path string, key []byte) ([]byte, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("open %s: %w", path, err)
	}
	defer f.Close()
	mac := hmac.New(sha256.New, key)
	if _, err := io.Copy(mac, f); err != nil {
		return nil, fmt.Errorf("hash %s: %w", path, err)
	}
	return mac.Sum(nil), nil
}

// VerifySignature recomputes the HMAC-SHA256 of the tarball at
// tarballPath and compares it (constant time) against the hex-encoded
// signature at sigPath. Returns ErrSignatureMismatch on a mismatch.
func VerifySignature(tarballPath, sigPath string, masterKey []byte) error {
	if len(masterKey) == 0 {
		return fmt.Errorf("backup: MasterKey is empty")
	}
	got, err := computeSignature(tarballPath, masterKey)
	if err != nil {
		return fmt.Errorf("compute signature: %w", err)
	}
	wantHex, err := os.ReadFile(sigPath)
	if err != nil {
		return fmt.Errorf("read signature: %w", err)
	}
	want, err := hex.DecodeString(strings.TrimSpace(string(wantHex)))
	if err != nil {
		return fmt.Errorf("decode signature: %w", err)
	}
	if !hmac.Equal(got, want) {
		return ErrSignatureMismatch
	}
	return nil
}

// Restore verifies the signature on (InputPath, InputPath+".sig") using
// MasterKey, then extracts the tarball to TargetDir. With Force=false
// a non-empty TargetDir is refused (ErrTargetNotEmpty); with Force=true
// existing files are overwritten.
func Restore(opts RestoreOptions) error {
	if opts.InputPath == "" {
		return fmt.Errorf("restore: InputPath is empty")
	}
	if opts.TargetDir == "" {
		return fmt.Errorf("restore: TargetDir is empty")
	}
	if len(opts.MasterKey) == 0 {
		return fmt.Errorf("restore: MasterKey is empty")
	}

	sigPath := opts.InputPath + ".sig"
	if err := VerifySignature(opts.InputPath, sigPath, opts.MasterKey); err != nil {
		return fmt.Errorf("restore: verify signature: %w", err)
	}

	target := filepath.Clean(opts.TargetDir)
	if err := os.MkdirAll(target, 0o755); err != nil {
		return fmt.Errorf("restore: mkdir target: %w", err)
	}
	if !opts.Force {
		empty, err := dirIsEmpty(target)
		if err != nil {
			return fmt.Errorf("restore: check target: %w", err)
		}
		if !empty {
			return ErrTargetNotEmpty
		}
	}

	f, err := os.Open(opts.InputPath)
	if err != nil {
		return fmt.Errorf("restore: open tarball: %w", err)
	}
	defer f.Close()
	gz, err := gzip.NewReader(f)
	if err != nil {
		return fmt.Errorf("restore: gzip reader: %w", err)
	}
	defer gz.Close()
	tr := tar.NewReader(gz)
	var extracted int
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			return fmt.Errorf("restore: read tar entry: %w", err)
		}
		name := filepath.FromSlash(hdr.Name)
		// F3: tar-slip containment check. The prior prefix check
		// (HasPrefix "/" || "..") missed patterns like "a/../../etc".
		// Resolve the destination and verify it stays within target
		// via filepath.Rel; reject if the relative path escapes (starts
		// with ".." or is absolute).
		dest := filepath.Join(target, name)
		rel, err := filepath.Rel(target, dest)
		if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
			return fmt.Errorf("restore: unsafe path %q escapes target (F3: tar-slip)", hdr.Name)
		}
		switch hdr.Typeflag {
		case tar.TypeDir:
			if err := os.MkdirAll(dest, os.FileMode(hdr.Mode)); err != nil {
				return fmt.Errorf("restore: mkdir %s: %w", name, err)
			}
			continue
		case tar.TypeSymlink:
			// REQ-127 / F7: validate Linkname to prevent symlink attacks.
			// Reject absolute links, .. traversal, and links outside
			// the target dir (which could point to /etc/shadow etc.).
			link := hdr.Linkname
			if link == "" {
				return fmt.Errorf("restore: empty symlink linkname for %q", name)
			}
			if strings.HasPrefix(link, "/") {
				return fmt.Errorf("restore: symlink %q has absolute linkname %q (REQ-127: path traversal)", name, link)
			}
			if strings.Contains(link, "..") {
				// Resolve the link relative to the dest dir; if it
				// escapes the target, reject.
				linkDest := filepath.Join(filepath.Dir(dest), link)
				linkClean := filepath.Clean(linkDest)
				targetClean := filepath.Clean(target)
				if !strings.HasPrefix(linkClean, targetClean+string(filepath.Separator)) && linkClean != targetClean {
					return fmt.Errorf("restore: symlink %q linkname %q escapes target (REQ-127)", name, link)
				}
			}
			if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
				return fmt.Errorf("restore: clear symlink %s: %w", name, err)
			}
			if err := os.Symlink(hdr.Linkname, dest); err != nil {
				return fmt.Errorf("restore: symlink %s: %w", name, err)
			}
			continue
		case tar.TypeReg:
			if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
				return fmt.Errorf("restore: mkdir parent %s: %w", name, err)
			}
			out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
			if err != nil {
				return fmt.Errorf("restore: create %s: %w", name, err)
			}
			if _, err := io.Copy(out, tr); err != nil {
				out.Close()
				return fmt.Errorf("restore: write %s: %w", name, err)
			}
			out.Close()
			extracted++
		default:
			slog.Warn("restore: skipping non-regular entry", "name", name, "type", hdr.Typeflag)
		}
	}
	slog.Info("restore complete", "path", target, "files", extracted)
	return nil
}

// dirIsEmpty reports whether dir contains no entries.
func dirIsEmpty(dir string) (bool, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return false, err
	}
	return len(entries) == 0, nil
}
