package db

import (
	"regexp"
	"strings"
	"testing"
)

func TestMySQLDDL_Translations(t *testing.T) {
	cases := []struct {
		name string
		in   string
		want string
	}{
		{"autoinc pk", "id INTEGER PRIMARY KEY AUTOINCREMENT,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
		{"plain pk", "id INTEGER PRIMARY KEY,", "id BIGINT AUTO_INCREMENT PRIMARY KEY,"},
		{"bare integer", "freq_hz INTEGER,", "freq_hz BIGINT,"},
		{"strftime default", "created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),",
			"created_at VARCHAR(255) NOT NULL DEFAULT '',"},
		{"strftime tight", "updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),",
			"updated_at VARCHAR(255) NOT NULL DEFAULT '',"},
		// The column-name/type whitespace collapses to a single space — harmless.
		{"text default N", "qsl_sent        TEXT DEFAULT 'N',", "qsl_sent VARCHAR(255) DEFAULT 'N',"},
		{"indexed col → varchar", "callsign        TEXT    NOT NULL,", "callsign VARCHAR(255)    NOT NULL,"},
		{"plain non-indexed col → text", "name            TEXT,", "name TEXT,"},
		{"plain text stays text", "comment         TEXT,", "comment TEXT,"},
		{"json longtext", "    json        TEXT NOT NULL,", "    json LONGTEXT NOT NULL,"},
		{"create index", "CREATE INDEX IF NOT EXISTS idx_qso_dxcc ON qso(dxcc);",
			"CREATE INDEX idx_qso_dxcc ON qso(dxcc);"},
	}
	for _, c := range cases {
		if got := mysqlDDL(c.in); got != c.want {
			t.Errorf("%s:\n  in   %q\n  got  %q\n  want %q", c.name, c.in, got, c.want)
		}
	}
}

func TestMySQLDDL_KeyColumnBackticked(t *testing.T) {
	in := "CREATE TABLE IF NOT EXISTS settings (\n    key        TEXT PRIMARY KEY,\n    value      TEXT NOT NULL\n);"
	got := mysqlDDL(in)
	if !strings.Contains(got, "`key` VARCHAR(255) PRIMARY KEY") {
		t.Errorf("key column not backticked/translated:\n%s", got)
	}
}

func TestSplitStatements(t *testing.T) {
	in := "-- a comment\n" +
		"ALTER TABLE qso ADD COLUMN a TEXT;\n" +
		"ALTER TABLE qso ADD COLUMN b TEXT;  -- inline note\n" +
		"\n" +
		"CREATE INDEX idx ON qso(a);\n"
	got := splitStatements(in)
	if len(got) != 3 {
		t.Fatalf("want 3 statements, got %d: %#v", len(got), got)
	}
	if !strings.Contains(got[0], "ADD COLUMN a") ||
		!strings.Contains(got[1], "ADD COLUMN b") ||
		!strings.Contains(got[2], "CREATE INDEX") {
		t.Errorf("unexpected split: %#v", got)
	}
	// No fragment should be a comment-only or blank statement.
	for _, s := range got {
		if strings.TrimSpace(s) == "" {
			t.Errorf("empty statement in result: %#v", got)
		}
	}
}

// Every embedded migration must split into at least one runnable statement
// and never produce an empty fragment.
func TestSplitStatements_AllMigrations(t *testing.T) {
	entries, _ := migrationsFS.ReadDir("migrations")
	for _, e := range entries {
		if !strings.HasSuffix(e.Name(), ".sql") {
			continue
		}
		raw, _ := migrationsFS.ReadFile("migrations/" + e.Name())
		stmts := splitStatements(mysqlDDL(string(raw)))
		if len(stmts) == 0 {
			t.Errorf("%s: produced no statements", e.Name())
		}
		for _, s := range stmts {
			if strings.TrimSpace(s) == "" {
				t.Errorf("%s: empty statement fragment", e.Name())
			}
		}
	}
}

// TestMySQLDDL_QSORowSizeUnderLimit guards against the InnoDB 65535-byte row
// limit: every VARCHAR(255) in utf8mb4 costs 1020 bytes in-row, and the qso
// table (built from 0001 + the qso-only ALTERs in 0003 and 0019) must stay well
// clear of it. TEXT columns are off-page and don't count here.
func TestMySQLDDL_QSORowSizeUnderLimit(t *testing.T) {
	qsoMigrations := []string{"0001_init.sql", "0003_adif_extra.sql", "0019_adif_317_fields.sql"}
	varchars := 0
	for _, name := range qsoMigrations {
		raw, err := migrationsFS.ReadFile("migrations/" + name)
		if err != nil {
			t.Fatal(err)
		}
		varchars += strings.Count(mysqlDDL(string(raw)), "VARCHAR(255)")
	}
	const bytesPerVarchar = 255 * 4 // utf8mb4
	rowBytes := varchars * bytesPerVarchar
	t.Logf("qso VARCHAR(255) columns: %d (~%d bytes in-row)", varchars, rowBytes)
	if rowBytes > 60000 { // leave headroom under the 65535 hard limit
		t.Errorf("qso row too large: %d VARCHAR(255) cols = ~%d bytes (limit 65535)", varchars, rowBytes)
	}
}

// TestMySQLDDL_NoLeftoverSQLiteisms translates every embedded migration and
// fails if any SQLite-only construct survives — a fast guard against a new
// migration sneaking in a dialect-ism the translator doesn't cover.
func TestMySQLDDL_NoLeftoverSQLiteisms(t *testing.T) {
	entries, err := migrationsFS.ReadDir("migrations")
	if err != nil {
		t.Fatal(err)
	}
	reInteger := regexp.MustCompile(`\bINTEGER\b`)
	for _, e := range entries {
		if !strings.HasSuffix(e.Name(), ".sql") {
			continue
		}
		raw, err := migrationsFS.ReadFile("migrations/" + e.Name())
		if err != nil {
			t.Fatal(err)
		}
		out := mysqlDDL(string(raw))
		if strings.Contains(out, "AUTOINCREMENT") {
			t.Errorf("%s: AUTOINCREMENT survived", e.Name())
		}
		if strings.Contains(out, "strftime") {
			t.Errorf("%s: strftime survived", e.Name())
		}
		if strings.Contains(out, "IF NOT EXISTS idx") {
			t.Errorf("%s: CREATE INDEX IF NOT EXISTS survived", e.Name())
		}
		// Strip comment lines before checking for bare INTEGER (comments are
		// prose and may legitimately mention the word).
		var code strings.Builder
		for _, ln := range strings.Split(out, "\n") {
			if strings.HasPrefix(strings.TrimSpace(ln), "--") {
				continue
			}
			code.WriteString(ln)
			code.WriteByte('\n')
		}
		if reInteger.MatchString(code.String()) {
			t.Errorf("%s: bare INTEGER survived in code", e.Name())
		}
	}
}

// A QUOTED column declaration must be translated like a bare one.
//
// The SQLite baseline dump quotes its identifiers, and mysqlDDL turns those
// double quotes into backticks before the TEXT rules run. The pattern only
// matched a bare name, so every column in the baseline stayed TEXT — and MySQL
// then refused the schema in two different ways, neither naming the real cause:
//
//	CREATE INDEX … ON qso(state)  → 1170 BLOB/TEXT column used in key
//	                                specification without a key length
//	op_name TEXT … DEFAULT ''     → 1101 TEXT column can't have a default value
//
// The operator saw only "MySQL is enabled but the connection failed at startup",
// and fell back to the local SQLite database.
func TestMySQLDDL_QuotedTextColumns(t *testing.T) {
	cases := []struct {
		name, in, wantHas string
	}{
		{
			// An indexed column must become VARCHAR, quoted or not.
			name:    "backticked indexed column",
			in:      "CREATE TABLE qso (`state` TEXT);",
			wantHas: "`state` VARCHAR(255)",
		},
		{
			name:    "bare indexed column still works",
			in:      "ALTER TABLE qso ADD COLUMN state              TEXT;",
			wantHas: "state VARCHAR(255)",
		},
		{
			// A default-bearing column must become VARCHAR, or MySQL rejects it.
			name:    "double-quoted column with a default",
			in:      `CREATE TABLE station_profiles ("op_name" TEXT NOT NULL DEFAULT '');`,
			wantHas: "`op_name` VARCHAR(255)",
		},
		{
			// And a plain unindexed column stays TEXT: VARCHAR everywhere would
			// blow past MySQL's row-size limit, which is why the rule exists.
			name:    "unindexed column stays TEXT",
			in:      "CREATE TABLE qso (`comment` TEXT);",
			wantHas: "`comment` TEXT",
		},
	}
	for _, c := range cases {
		got := mysqlDDL(c.in)
		if !strings.Contains(got, c.wantHas) {
			t.Errorf("%s:\n  in   %s\n  got  %s\n  want it to contain %q", c.name, c.in, got, c.wantHas)
		}
		// The quoting must survive: a lost backtick is a syntax error further on.
		if strings.Count(got, "`")%2 != 0 {
			t.Errorf("%s: unbalanced backticks in %s", c.name, got)
		}
	}
}

// A CREATE TABLE with several columns on ONE line — the shape sqlite_master
// stores once columns have been added by ALTER TABLE.
//
// The old translation converted only the first column per line, so on a real
// logbook the baseline kept most of its columns as TEXT and MySQL refused the
// schema: "column 'op_name' can't have a default value". Our own migration files
// hide this — they put one column per line — which is why it only ever appeared
// against a database that had been through upgrades.
func TestMySQLDDL_ManyColumnsOnOneLine(t *testing.T) {
	in := "CREATE TABLE station_profiles (id INTEGER PRIMARY KEY, `name` TEXT, " +
		"`op_name` TEXT NOT NULL DEFAULT '', `comment` TEXT, `state` TEXT);"
	got := mysqlDDL(in)

	// Default-bearing and indexed columns must be VARCHAR…
	for _, want := range []string{"`op_name` VARCHAR(255)", "`state` VARCHAR(255)"} {
		if !strings.Contains(got, want) {
			t.Errorf("missing %q in:\n  %s", want, got)
		}
	}
	// …while the plain ones stay TEXT, or the row-size limit is breached.
	for _, want := range []string{"`name` TEXT", "`comment` TEXT"} {
		if !strings.Contains(got, want) {
			t.Errorf("missing %q in:\n  %s", want, got)
		}
	}
	// No column may be left as a bare TEXT that carries a default.
	if strings.Contains(got, "TEXT NOT NULL DEFAULT") {
		t.Errorf("a TEXT column still carries a DEFAULT — MySQL rejects that:\n  %s", got)
	}
}

// A neighbour's DEFAULT must not decide this column's type: deciding per LINE
// made every column on a shared line VARCHAR as soon as one of them had a
// default, which is how the row-size limit gets breached quietly.
func TestMySQLDDL_DefaultDoesNotLeakAcrossColumns(t *testing.T) {
	in := "CREATE TABLE t (`a` TEXT NOT NULL DEFAULT '', `b` TEXT);"
	got := mysqlDDL(in)
	if !strings.Contains(got, "`a` VARCHAR(255)") {
		t.Errorf("the column WITH the default should be VARCHAR:\n  %s", got)
	}
	if !strings.Contains(got, "`b` TEXT") {
		t.Errorf("the column without one should stay TEXT:\n  %s", got)
	}
}
