package main

// Compacting a database.
//
// SQLite never shrinks a file on its own: deleting rows — or dropping a table,
// which is what the schema-role cleanup does — frees pages INSIDE the file and
// leaves the file the size it always was. A logbook that held 200 000 imported
// QSOs for a day is still a 200 MB file the day after they are gone, and the
// only thing that reclaims the space is a VACUUM, which rewrites the database
// from scratch.
//
// Offered as a button rather than done automatically: a VACUUM rewrites the
// whole file, needs room for a second copy of it while it runs, and takes real
// time on a large logbook. That is a decision for the operator, at a moment they
// choose — not something to spring on them during startup.

import (
	"database/sql"
	"fmt"
	"os"
	"strings"

	"hamlog/internal/applog"
)

// CompactResult is what one compaction did.
type CompactResult struct {
	// Path of the file compacted, empty for MySQL.
	Path string `json:"path"`
	// Backend is "sqlite" or "mysql" — a shared server is optimised, not vacuumed.
	Backend string `json:"backend"`
	// Before and After are file sizes in bytes; both 0 when there is no file.
	Before int64 `json:"before"`
	After  int64 `json:"after"`
}

// CompactDatabase reclaims the unused space in one of the two databases.
//
// target is "settings" or "logbook". They are separate on purpose: they are
// different files, of very different sizes, and an operator compacting a 400 MB
// logbook has no reason to wait on a 2 MB settings file as well.
func (a *App) CompactDatabase(target string) (CompactResult, error) {
	switch strings.ToLower(strings.TrimSpace(target)) {
	case "settings":
		if a.db == nil {
			return CompactResult{}, fmt.Errorf("the settings database is not open")
		}
		return a.vacuumSQLite(a.db, a.dbPath)
	case "logbook":
		if a.logDb == nil {
			return CompactResult{}, fmt.Errorf("the logbook is not open")
		}
		if a.dbBackend == "mysql" {
			// A shared server's storage is the admin's business, and OPTIMIZE TABLE
			// locks the table for the length of a rebuild — every other operator
			// waits. Still offered, because a logbook that has had a large import
			// deleted benefits from it just as much; it simply reports no sizes,
			// which the server alone knows.
			if _, err := a.logDb.ExecContext(a.ctx, "OPTIMIZE TABLE qso"); err != nil {
				return CompactResult{}, fmt.Errorf("optimize qso: %w", err)
			}
			applog.Printf("compact: OPTIMIZE TABLE qso on the shared MySQL logbook")
			return CompactResult{Backend: "mysql"}, nil
		}
		// No separate file: the settings database is serving as the logbook, and
		// compacting it is the same operation.
		if a.logDbPath == "" {
			return a.vacuumSQLite(a.db, a.dbPath)
		}
		return a.vacuumSQLite(a.logDb, a.logDbPath)
	}
	return CompactResult{}, fmt.Errorf("unknown database %q", target)
}

// vacuumSQLite checkpoints the write-ahead log, then rewrites the file.
func (a *App) vacuumSQLite(conn *sql.DB, path string) (CompactResult, error) {
	res := CompactResult{Path: path, Backend: "sqlite", Before: fileSizeOf(path)}
	// Fold the WAL back into the main file first. Without it the pages freed by a
	// recent DELETE can still be sitting in the -wal, and the vacuum reports a
	// saving the file on disk does not show.
	if _, err := conn.ExecContext(a.ctx, "PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
		applog.Printf("compact: wal checkpoint on %s: %v", path, err)
	}
	if _, err := conn.ExecContext(a.ctx, "VACUUM"); err != nil {
		return res, fmt.Errorf("vacuum: %w", err)
	}
	res.After = fileSizeOf(path)
	applog.Printf("compact: %s %d → %d bytes", path, res.Before, res.After)
	return res, nil
}

// fileSizeOf returns a file's size, or 0 if it cannot be read.
func fileSizeOf(path string) int64 {
	if strings.TrimSpace(path) == "" {
		return 0
	}
	fi, err := os.Stat(path)
	if err != nil {
		return 0
	}
	return fi.Size()
}
