// Step 3 of M30: backup.home agent command. `restic backup /home/<u>` with
// the canonical account_backup tag set.
//
// A best-effort `du -sb` pre-pass records the account's logical content size
// (reported back as logical_bytes). It no longer caps the backup — large
// accounts back up too; restic's dedup keeps the repo far below the logical
// size (the old 50 GiB refusal was removed).
package commands

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"

	"git.jabali-panel.com/shukivaknin/jabali2/internal/backup"
)

// HomeExcludeFile is the install-managed exclude list. Generated by
// install_backup_foundation in install.sh (Step 1 follow-up — for now
// the file may not exist; missing exclude file is non-fatal).
const HomeExcludeFile = "/etc/jabali-panel/restic-excludes.list"

// BackupIgnoreFile is the per-account, tenant-editable exclude list at the home
// root (GH #454). Patterns use restic's --exclude-file syntax; the tenant edits
// it via the backup UI or drops it in like a .gitignore.
const BackupIgnoreFile = ".backupignore"

// homeBackupIgnoreFile returns <home>/.backupignore when it is a usable regular
// file. It rejects a symlink, directory, or absurdly large file: the file is
// user-writable and the backup agent reads it as root, so a symlink must never
// redirect that read, and an exclude list is tiny. Absent/unusable => ("", false)
// and the backup proceeds with only the global list.
func homeBackupIgnoreFile(homePath string) (string, bool) {
	p := filepath.Join(homePath, BackupIgnoreFile)
	fi, err := os.Lstat(p)
	if err != nil {
		return "", false
	}
	if !fi.Mode().IsRegular() {
		return "", false
	}
	if fi.Size() > 256*1024 {
		return "", false
	}
	return p, true
}

type backupHomeParams struct {
	JobID          string            `json:"job_id"`
	UserID         string            `json:"user_id"`
	Username       string            `json:"username"`
	ScheduleID     string            `json:"schedule_id,omitempty"`
	RepoURL        string            `json:"repo_url,omitempty"`
	PasswordFile   string            `json:"password_file,omitempty"`
	CredentialsRef string            `json:"credentials_ref,omitempty"`
	SFTP           *backupSFTPInputs `json:"sftp,omitempty"`
	Compression    string            `json:"compression,omitempty"`
	// Folders restricts the backup to these paths under the account home
	// (relative or absolute-under-home). Empty = whole home (GH #294).
	Folders []string `json:"folders,omitempty"`
}

type backupHomeResult struct {
	SnapshotID     string   `json:"snapshot_id"`
	ParentSnapshot string   `json:"parent_snapshot,omitempty"`
	BytesAdded     uint64   `json:"bytes_added"`
	BytesTotal     uint64   `json:"bytes_total"`
	LogicalBytes   uint64   `json:"logical_bytes"`
	Warnings       []string `json:"warnings,omitempty"`
}

func backupHomeHandler(ctx context.Context, raw json.RawMessage) (any, error) {
	var req backupHomeParams
	if err := json.Unmarshal(raw, &req); err != nil {
		return nil, bkInvalidArg("malformed JSON body")
	}
	if !ulidRE.MatchString(req.JobID) {
		return nil, bkInvalidArg("job_id must be a 26-char ULID")
	}
	if !ulidRE.MatchString(req.UserID) {
		return nil, bkInvalidArg("user_id must be a 26-char ULID")
	}
	if !backupUsernameRE.MatchString(req.Username) {
		return nil, bkInvalidArg("username must match ^[a-z][a-z0-9_-]{0,31}$")
	}

	homePath := filepath.Join("/home", req.Username)
	if _, err := os.Stat(homePath); err != nil {
		return nil, bkInvalidArg(fmt.Sprintf("user home not found: %s", homePath))
	}

	// Best-effort logical-size pre-pass, for reporting only (no ceiling). A
	// du failure (e.g. a transient permission hiccup) must not block the
	// backup — restic walks the tree itself.
	logical, derr := duBytes(ctx, homePath)
	if derr != nil {
		logical = 0
	}

	if _, err := bkResticBin(); err != nil {
		return nil, bkInternal("restic missing", err)
	}

	cfg, cerr := bkResticConfigWithPassword(req.RepoURL, req.CredentialsRef, req.PasswordFile, req.SFTP)
	if cerr != nil {
		return nil, bkInternal("restic config", cerr)
	}
	cfg.Compression = req.Compression
	c := backup.New(cfg)
	tags := backup.AccountBackupTags(req.JobID, req.UserID, req.ScheduleID, backup.StageHome)

	// Restrict to selected folders when requested; each must resolve inside
	// the account home (no escape). Missing folders are skipped (non-fatal).
	paths := []string{homePath}
	if len(req.Folders) > 0 {
		paths = paths[:0]
		for _, f := range req.Folders {
			clean := filepath.Clean(f)
			full := clean
			if !filepath.IsAbs(clean) {
				full = filepath.Join(homePath, clean)
			}
			if full != homePath && !strings.HasPrefix(full, homePath+string(os.PathSeparator)) {
				return nil, bkInvalidArg(fmt.Sprintf("folder %q escapes the account home", f))
			}
			if _, err := os.Stat(full); err != nil {
				continue
			}
			paths = append(paths, full)
		}
		if len(paths) == 0 {
			return nil, bkInvalidArg("no valid folders to back up")
		}
	}
	opts := backup.BackupOpts{
		Paths: paths,
		Tags:  tags,
	}
	if _, err := os.Stat(HomeExcludeFile); err == nil {
		opts.ExcludeFile = HomeExcludeFile
	}
	// GH #454: layer the tenant's own ~/.backupignore on top of the global
	// list so they can skip regenerable dirs (cache/, node_modules/, vendor/,
	// *.log, …). Only a real regular file is honoured (see homeBackupIgnoreFile).
	if userIgnore, ok := homeBackupIgnoreFile(homePath); ok {
		opts.ExtraExcludeFiles = append(opts.ExtraExcludeFiles, userIgnore)
	}
	summary, err := c.Backup(ctx, opts)
	if err != nil {
		return nil, bkInternal("restic backup", err)
	}
	return backupHomeResult{
		SnapshotID:   summary.SnapshotID,
		BytesAdded:   summary.DataAdded,
		BytesTotal:   summary.TotalBytesProcessed,
		LogicalBytes: logical,
		Warnings:     summary.Warnings,
	}, nil
}

// duBytes runs `du -sb <path>` and returns the byte count. Hard-fails
// on a non-zero exit (could be permission-denied; treat as scan failure
// rather than silently passing).
func duBytes(ctx context.Context, path string) (uint64, error) {
	out, err := exec.CommandContext(ctx, "du", "-sb", path).Output()
	if err != nil {
		return 0, fmt.Errorf("du -sb %s: %w", path, err)
	}
	fields := strings.Fields(strings.TrimSpace(string(out)))
	if len(fields) == 0 {
		return 0, fmt.Errorf("du -sb %s: empty output", path)
	}
	n, err := strconv.ParseUint(fields[0], 10, 64)
	if err != nil {
		return 0, fmt.Errorf("parse du output %q: %w", fields[0], err)
	}
	return n, nil
}

func init() {
	Default.Register("backup.home", backupHomeHandler)
}
