// Package backup creates rotating copies of the SQLite logbook so a single
// disk failure or accidental delete doesn't wipe the user's QSO history.
// The strategy is intentionally simple: a daily snapshot of the .db file
// (optionally zipped), keeping the last N. SQLite's atomic-commit format
// lets us copy the raw file safely as long as we copy after a checkpoint.
package backup

import (
	"archive/zip"
	"context"
	"database/sql"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

// Settings holds the user-tweakable backup configuration. Stored as flat
// key/value pairs in the settings table (so we don't need a schema change
// every time we add a field).
type Settings struct {
	Enabled      bool   `json:"enabled"`
	Folder       string `json:"folder"`         // empty → DefaultFolder
	Rotation     int    `json:"rotation"`       // how many backups to keep; 0/neg = 5
	Zip          bool   `json:"zip"`            // compress with deflate
	LastBackupAt string `json:"last_backup_at"` // RFC3339; empty if never
}

// DefaultFolder returns the folder used when Settings.Folder is empty.
// It sits next to the database file (in <appdata>/hamlog/backups) which
// keeps it portable when the user moves the data directory.
func DefaultFolder(dataDir string) string {
	return filepath.Join(dataDir, "backups")
}

// Run executes one backup pass: VACUUM INTO a fresh snapshot → optionally
// zip → rotate. Returns the path of the file that was written so the caller
// can surface it to the UI.
//
// VACUUM INTO produces a transactionally-consistent copy in a single SQL
// statement (no torn-copy window while the app keeps writing), and compacts
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
// which could capture a half-written page during a concurrent write.
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool, prefix string, unique bool) (string, error) {
	if dbConn == nil {
		return "", fmt.Errorf("nil db connection")
	}
	if rotation <= 0 {
		rotation = 5
	}
	if folder == "" {
		return "", fmt.Errorf("backup folder not set")
	}
	if prefix == "" {
		prefix = "opslog"
	}
	if err := os.MkdirAll(folder, 0o755); err != nil {
		return "", fmt.Errorf("create backup folder: %w", err)
	}

	// One file per DAY by default (same-day runs overwrite). With `unique`, add the
	// time so every run is a distinct file — rotation still keeps the newest N.
	stamp := time.Now().Format("2006-01-02")
	if unique {
		stamp = time.Now().Format("2006-01-02-150405")
	}
	base := fmt.Sprintf("%s-%s", prefix, stamp)

	// VACUUM INTO requires a non-existent target → use a temp file, then
	// move/zip it into place.
	tmp := filepath.Join(folder, base+".vacuum.tmp")
	_ = os.Remove(tmp)
	if _, err := dbConn.ExecContext(ctx, `VACUUM INTO ?;`, tmp); err != nil {
		_ = os.Remove(tmp)
		return "", fmt.Errorf("vacuum into: %w", err)
	}

	var dstPath string
	if doZip {
		dstPath = filepath.Join(folder, base+".db.zip")
		// Inner name = the live DB's base so unzip restores e.g. "opslog.db".
		if err := copyZipped(tmp, dstPath, filepath.Base(dbPath)); err != nil {
			_ = os.Remove(tmp)
			return "", err
		}
		_ = os.Remove(tmp)
	} else {
		dstPath = filepath.Join(folder, base+".db")
		_ = os.Remove(dstPath)
		if err := os.Rename(tmp, dstPath); err != nil {
			// Rename can fail across filesystems — fall back to a copy.
			if cerr := copyFile(tmp, dstPath); cerr != nil {
				_ = os.Remove(tmp)
				return "", cerr
			}
			_ = os.Remove(tmp)
		}
	}

	if err := rotateMatch(folder, rotation, prefix+"-", ".db", ".db.zip"); err != nil {
		// Rotation errors are non-fatal — the backup itself succeeded.
		return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
	}
	return dstPath, nil
}

// RunADIF writes a rotating ADIF export of the QSO logbook. It's used when the
// log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an
// ADIF export is a portable, re-importable backup of the actual contacts.
// writeADIF must write the full ADIF to the path it's handed.
func RunADIF(folder string, rotation int, doZip bool, unique bool, writeADIF func(path string) error) (string, error) {
	if rotation <= 0 {
		rotation = 5
	}
	if folder == "" {
		return "", fmt.Errorf("backup folder not set")
	}
	if err := os.MkdirAll(folder, 0o755); err != nil {
		return "", fmt.Errorf("create backup folder: %w", err)
	}
	stamp := time.Now().Format("2006-01-02")
	if unique {
		stamp = time.Now().Format("2006-01-02-150405")
	}
	base := "opslog-log-" + stamp
	tmp := filepath.Join(folder, base+".adi.tmp")
	_ = os.Remove(tmp)
	if err := writeADIF(tmp); err != nil {
		_ = os.Remove(tmp)
		return "", fmt.Errorf("export adif: %w", err)
	}

	var dstPath string
	if doZip {
		dstPath = filepath.Join(folder, base+".adi.zip")
		if err := copyZipped(tmp, dstPath, base+".adi"); err != nil {
			_ = os.Remove(tmp)
			return "", err
		}
		_ = os.Remove(tmp)
	} else {
		dstPath = filepath.Join(folder, base+".adi")
		_ = os.Remove(dstPath)
		if err := os.Rename(tmp, dstPath); err != nil {
			if cerr := copyFile(tmp, dstPath); cerr != nil {
				_ = os.Remove(tmp)
				return "", cerr
			}
			_ = os.Remove(tmp)
		}
	}

	if err := rotateMatch(folder, rotation, "opslog-log-", ".adi", ".adi.zip"); err != nil {
		return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
	}
	return dstPath, nil
}

// copyFile performs a plain file copy. We don't use os.Rename because
// the source is the live database; we want a fresh standalone file.
func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return fmt.Errorf("open source: %w", err)
	}
	defer in.Close()
	out, err := os.Create(dst)
	if err != nil {
		return fmt.Errorf("create dest: %w", err)
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		os.Remove(dst)
		return fmt.Errorf("copy: %w", err)
	}
	return out.Close()
}

// copyZipped writes a single-entry deflate zip containing the database.
// innerName is the entry name inside the zip (the live DB's base name) so
// unzip restores e.g. "opslog.db" wherever the user extracts.
func copyZipped(src, dst, innerName string) error {
	in, err := os.Open(src)
	if err != nil {
		return fmt.Errorf("open source: %w", err)
	}
	defer in.Close()

	out, err := os.Create(dst)
	if err != nil {
		return fmt.Errorf("create dest: %w", err)
	}
	zw := zip.NewWriter(out)
	w, err := zw.Create(innerName)
	if err != nil {
		zw.Close()
		out.Close()
		os.Remove(dst)
		return fmt.Errorf("zip entry: %w", err)
	}
	if _, err := io.Copy(w, in); err != nil {
		zw.Close()
		out.Close()
		os.Remove(dst)
		return fmt.Errorf("zip write: %w", err)
	}
	if err := zw.Close(); err != nil {
		out.Close()
		os.Remove(dst)
		return fmt.Errorf("zip close: %w", err)
	}
	return out.Close()
}

// rotateMatch keeps the most recent `keep` files in folder whose name has the
// given prefix and one of the given suffixes, deleting older ones. Only matching
// files are touched — never unrelated user files in the same folder. The suffix
// filter keeps the .db family and the .adi family from rotating each other even
// though both share the "opslog-" prefix.
func rotateMatch(folder string, keep int, prefix string, suffixes ...string) error {
	entries, err := os.ReadDir(folder)
	if err != nil {
		return err
	}
	type item struct {
		path string
		mod  time.Time
	}
	matches := make([]item, 0, len(entries))
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if !strings.HasPrefix(name, prefix) {
			continue
		}
		ok := false
		for _, sfx := range suffixes {
			if strings.HasSuffix(name, sfx) {
				ok = true
				break
			}
		}
		if !ok {
			continue
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		matches = append(matches, item{path: filepath.Join(folder, name), mod: info.ModTime()})
	}
	if len(matches) <= keep {
		return nil
	}
	sort.Slice(matches, func(i, j int) bool { return matches[i].mod.After(matches[j].mod) })
	var firstErr error
	for _, m := range matches[keep:] {
		if err := os.Remove(m.path); err != nil && firstErr == nil {
			firstErr = err
		}
	}
	return firstErr
}

// HasBackupToday returns true if a SQLite backup for today's date already
// exists in folder. Used by the auto-backup to skip when the user has already
// restarted the app once today.
func HasBackupToday(folder string) bool {
	return hasBackupToday(folder, "opslog-", ".db", ".db.zip")
}

// HasADIFBackupToday is HasBackupToday for the ADIF log backup (MySQL mode).
func HasADIFBackupToday(folder string) bool {
	return hasBackupToday(folder, "opslog-log-", ".adi", ".adi.zip")
}

func hasBackupToday(folder, prefix string, exts ...string) bool {
	if folder == "" {
		return false
	}
	stamp := time.Now().Format("2006-01-02")
	for _, ext := range exts {
		if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil {
			return true
		}
	}
	return false
}
