package db

// Which tables belong in which database.
//
// OpsLog keeps settings and contacts apart — a settings database (settings.db /
// opslog.db) and a logbook (logbook.db, a per-profile file, or a shared MySQL).
// The SEPARATION of the data was real from the start; the SCHEMA was not: every
// target got the whole migration set, so a shared MySQL logbook grew a settings
// table and a station_profiles table that nothing ever wrote to. An operator
// inspecting the server with phpMyAdmin had no way to tell which copy was
// authoritative — a fair question, and the reason for this file.
//
// So a migration statement is now filtered by the ROLE of the database it is
// being applied to. Only two rules, and both fail safe:
//
//   - RoleAll (the settings database) applies everything, exactly as before.
//     It has to: a legacy single-file installation holds its QSOs there, and it
//     still serves as the logbook when no separate one could be created.
//   - RoleLogbook applies everything EXCEPT statements aimed at a table on the
//     settings side. A table this file does not know about is kept, in both —
//     an unrecognised future table behaves as it does today rather than
//     silently going missing from one database.

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

// Role is what a database is for.
type Role int

const (
	// RoleAll applies every migration statement. The settings database.
	RoleAll Role = iota
	// RoleLogbook applies only what the contacts need.
	RoleLogbook
)

// settingsTables are owned by the settings database. Every one of them is
// reached through the settings connection in app.go — grep NewRepo/NewStore
// there: only qso.NewRepo is given the logbook connection.
//
// Ordered children-before-parents, because pruneForeignTables drops them in
// this order and operating_antennas has a foreign key into operating_stations.
var settingsTables = []string{
	"operating_antenna_bands",
	"operating_antennas",
	"operating_antennas_new",
	"operating_stations",
	"operating_stations_new",
	"award_references",
	"qsl_templates",
	"cluster_servers",
	"integrations_udp",
	"callsign_cache",
	"station_profiles",
	"settings",
}

// keepForRole reports whether one migration statement applies to this role.
func keepForRole(stmt string, role Role) bool {
	if role == RoleAll {
		return true
	}
	t := stmtTable(stmt)
	if t == "" {
		return true // not a table statement (PRAGMA, or a shape we don't parse)
	}
	for _, s := range settingsTables {
		if t == s {
			return false
		}
	}
	return true
}

// stmtTable returns the lower-cased table a statement acts on, or "".
//
// Deliberately literal: it recognises the handful of statement shapes the
// migrations actually use, and returns "" for anything else — which keeps the
// statement. Guessing would be the only way to drop something by accident.
func stmtTable(stmt string) string {
	f := strings.Fields(strings.ToLower(stmt))
	at := func(i int) string {
		if i < len(f) {
			return f[i]
		}
		return ""
	}
	// Skip the leading keywords that carry no table name.
	switch {
	case at(0) == "create" || at(0) == "drop":
		// CREATE [UNIQUE] INDEX <name> ON <table> …
		// CREATE TABLE [IF NOT EXISTS] <table> …  /  DROP TABLE [IF EXISTS] <t>
		//
		// The "ON" search is confined to an INDEX statement on purpose: a CREATE
		// TABLE body is full of "ON DELETE CASCADE", and scanning the whole
		// statement for "on" once made every foreign-keyed table report itself as
		// a table named "delete" — unrecognised, therefore kept, therefore created
		// in a logbook that had no use for it.
		if at(1) == "index" || (at(1) == "unique" && at(2) == "index") {
			for i, w := range f {
				if w == "on" && i+1 < len(f) {
					return cleanIdent(f[i+1])
				}
			}
		}
		i := 1
		for at(i) == "unique" || at(i) == "table" || at(i) == "index" ||
			at(i) == "if" || at(i) == "not" || at(i) == "exists" || at(i) == "view" {
			i++
		}
		return cleanIdent(at(i))
	case at(0) == "alter":
		return cleanIdent(at(2)) // ALTER TABLE <table> …
	case at(0) == "insert" || at(0) == "replace":
		i := 1
		for at(i) == "or" || at(i) == "ignore" || at(i) == "into" || at(i) == "replace" {
			i++
		}
		return cleanIdent(at(i))
	case at(0) == "update":
		return cleanIdent(at(1))
	case at(0) == "delete":
		return cleanIdent(at(2)) // DELETE FROM <table>
	}
	return ""
}

// cleanIdent strips quoting and anything glued to the name — "qso(id)" from an
// index, `settings` from the MySQL translation, "qso;" from a split statement.
func cleanIdent(s string) string {
	s = strings.TrimSpace(s)
	if i := strings.IndexAny(s, "(;"); i >= 0 {
		s = s[:i]
	}
	return strings.Trim(s, "`\"'[]")
}

// pruneForeignTables removes settings tables from a logbook database that an
// earlier version created there.
//
// ONLY WHEN EMPTY, without exception. A table with rows in it is data, whatever
// this file thinks it is for: a profile pointing at a legacy combined database
// as its logbook is a real configuration, and dropping its profiles because the
// schema now says they belong elsewhere would be destroying a log-keeping
// operator's work on the strength of a tidiness rule.
//
// Best effort throughout: a logbook the user cannot drop tables in (a restricted
// MySQL grant) keeps its empty tables and works exactly as it does today.
func pruneForeignTables(conn *sql.DB, role Role, label string) {
	if role != RoleLogbook || conn == nil {
		return
	}
	// Once per database, recorded like a migration.
	//
	// The check itself is a COUNT(*) per settings table — twelve round trips,
	// which is nothing locally and is paid on EVERY open of a remote MySQL
	// logbook, including every profile switch. There is nothing to find after the
	// first pass: the role filter means no settings table is ever created in a
	// logbook again.
	if _, err := conn.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, prunedMarker); err != nil {
		return // already recorded (primary key), or the table is not writable
	}
	dropped := 0
	for _, t := range settingsTables {
		var n int
		// A missing table errors here, which is the "nothing to do" answer.
		if err := conn.QueryRow(`SELECT COUNT(*) FROM ` + quoteIdent(t)).Scan(&n); err != nil {
			continue
		}
		if n != 0 {
			logf("db[%s]: keeping %s — it has %d row(s)", label, t, n)
			continue
		}
		if _, err := conn.Exec(`DROP TABLE ` + quoteIdent(t)); err != nil {
			logf("db[%s]: could not drop unused %s: %v", label, t, err)
			continue
		}
		dropped++
	}
	if dropped > 0 {
		logf("db[%s]: dropped %d unused settings table(s) — this database holds contacts only", label, dropped)
	}
}

// prunedMarker records, in schema_migrations, that a logbook has had its unused
// settings tables removed. Named like a migration and stored beside them because
// that is exactly what it is: a one-off schema step, and the applied set is
// already read in a single query at every open.
const prunedMarker = "_opslog_pruned_settings_tables"

// quoteIdent quotes one of OUR OWN table names for either dialect. Backticks
// work in MySQL and in SQLite alike, which is why the migrations use them.
func quoteIdent(s string) string { return "`" + s + "`" }

// DropRedundantSettingsTables removes the settings tables from a logbook
// database EVEN IF THEY HAVE ROWS.
//
// Reserved for the one case where those rows are provably a stale duplicate:
// the logbook file was seeded by VACUUM INTO from the old combined database
// (see the split at startup), so every profile and every setting in it is a
// copy of what the settings database still holds and is authoritative for.
// Nothing reads them — the settings connection is a different file entirely —
// and leaving them behind is what made the two databases look interchangeable.
//
// The caller must have established that authority. pruneForeignTables is the
// safe default for every other situation.
func DropRedundantSettingsTables(conn *sql.DB, label string) {
	if conn == nil {
		return
	}
	dropped := 0
	for _, t := range settingsTables {
		if err := conn.QueryRow(`SELECT COUNT(*) FROM ` + quoteIdent(t)).Scan(new(int)); err != nil {
			continue // not there: nothing to do
		}
		if _, err := conn.Exec(`DROP TABLE ` + quoteIdent(t)); err != nil {
			logf("db[%s]: could not drop redundant %s: %v", label, t, err)
			continue
		}
		dropped++
	}
	if dropped > 0 {
		logf("db[%s]: dropped %d settings table(s) copied by the logbook split — the settings database keeps the originals", label, dropped)
	}
}

// EnsureQSOTable recreates the contacts table if it is missing.
//
// It exists so dropping an EMPTY qso table from the settings database is a
// reversible act. The settings database can still be pressed into service as the
// logbook — that is the fallback when no separate logbook file can be created —
// and the migrations that would have built the table are recorded as applied, so
// nothing would ever build it again. This does, from the same migrations, on
// demand.
//
// The schema is derived rather than duplicated: the migrations are replayed on a
// throwaway in-memory SQLite whose sqlite_master then holds the FINAL shape of
// the table with every ALTER-added column folded in. Same trick as the MySQL
// baseline, and for the same reason — there is no second schema to drift.
//
// SQLite only: the shared MySQL logbook is never the settings database.
func EnsureQSOTable(conn *sql.DB) error {
	if conn == nil {
		return nil
	}
	if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(new(int)); err == nil {
		return nil // already there
	}
	stmts, err := qsoSchemaDDL()
	if err != nil {
		return err
	}
	for _, st := range stmts {
		if _, err := conn.Exec(st); err != nil {
			return fmt.Errorf("recreate qso table: %w", err)
		}
	}
	logf("db: recreated the qso table — this database is being used as the logbook")
	return nil
}

// qsoSchemaDDL returns the CREATE statements for the qso table and its indexes,
// in that order.
func qsoSchemaDDL() ([]string, error) {
	mem, err := sql.Open("sqlite", "file:opslog_qsoschema?mode=memory&cache=shared")
	if err != nil {
		return nil, fmt.Errorf("open schema sqlite: %w", err)
	}
	defer mem.Close()
	if err := migrate(mem, nil, "", "qso-schema:memory", RoleLogbook); err != nil {
		return nil, fmt.Errorf("build qso schema: %w", err)
	}
	rows, err := mem.Query(`SELECT type, sql FROM sqlite_master
	                        WHERE sql IS NOT NULL AND tbl_name = 'qso'`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var tables, indexes []string
	for rows.Next() {
		var typ, s string
		if err := rows.Scan(&typ, &s); err != nil {
			return nil, err
		}
		if typ == "table" {
			tables = append(tables, s)
		} else {
			indexes = append(indexes, s)
		}
	}
	if len(tables) == 0 {
		return nil, fmt.Errorf("qso table not found in the derived schema")
	}
	return append(tables, indexes...), rows.Err()
}

// DropEmptyQSOTable removes the contacts table from a database that is not the
// logbook — the settings database, once its QSOs live in their own file.
//
// Only when empty, and reversible: EnsureQSOTable builds it again the moment
// this database is asked to serve as the logbook.
func DropEmptyQSOTable(conn *sql.DB, label string) {
	if conn == nil {
		return
	}
	var n int
	if err := conn.QueryRow(`SELECT COUNT(*) FROM qso`).Scan(&n); err != nil {
		return // not there: nothing to do
	}
	if n != 0 {
		return // contacts: this table is data, whatever the schema says
	}
	if _, err := conn.Exec(`DROP TABLE qso`); err != nil {
		logf("db[%s]: could not drop the unused qso table: %v", label, err)
		return
	}
	logf("db[%s]: dropped the unused qso table — the contacts live in their own database", label)
}
