package db

import (
	"context"
	"database/sql"
	_ "embed"
	"fmt"
	"net/url"

	_ "modernc.org/sqlite"
)

//go:embed migrations/0001_init.sql
var initSchema string

// Open dials a sqlite db with WAL + busy timeout pragmas wired in.
// dbPath is the on-disk file path; passing ":memory:" works for tests.
func Open(dbPath string) (*sql.DB, error) {
	dsn := buildDSN(dbPath)
	d, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, fmt.Errorf("open sqlite: %w", err)
	}
	if err := d.PingContext(context.Background()); err != nil {
		_ = d.Close()
		return nil, fmt.Errorf("ping sqlite: %w", err)
	}
	return d, nil
}

func buildDSN(dbPath string) string {
	q := url.Values{}
	q.Add("_pragma", "journal_mode(WAL)")
	q.Add("_pragma", "busy_timeout(5000)")
	q.Add("_pragma", "foreign_keys(1)")
	return dbPath + "?" + q.Encode()
}

// Bootstrap runs the embedded init schema in a single transaction.
// Idempotent: every CREATE TABLE uses IF NOT EXISTS.
func Bootstrap(ctx context.Context, d *sql.DB) error {
	tx, err := d.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin bootstrap tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx, initSchema); err != nil {
		return fmt.Errorf("exec init schema: %w", err)
	}
	// Additive migration for DBs created before clipboard_state.version existed:
	// CREATE TABLE IF NOT EXISTS won't add the column to an existing table, and
	// SQLite has no ADD COLUMN IF NOT EXISTS, so guard it with a pragma check.
	if err := ensureColumn(ctx, tx, "clipboard_state", "version",
		"ALTER TABLE clipboard_state ADD COLUMN version INTEGER NOT NULL DEFAULT 0"); err != nil {
		return fmt.Errorf("migrate clipboard_state.version: %w", err)
	}
	if err := ensureColumn(ctx, tx, "clipboard_state", "origin_ts",
		"ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil {
		return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err)
	}
	// push_subscriptions gained a platform column to distinguish Web Push (VAPID)
	// from APNs (iOS device token) rows. Existing rows default to 'webpush'.
	if err := ensureColumn(ctx, tx, "push_subscriptions", "platform",
		"ALTER TABLE push_subscriptions ADD COLUMN platform TEXT NOT NULL DEFAULT 'webpush'"); err != nil {
		return fmt.Errorf("migrate push_subscriptions.platform: %w", err)
	}
	// devices moved to a stable opaque device_id primary key (Auth Broker path A);
	// the old table was keyed (user_id, name). Device rows are ephemeral registrations
	// re-created on the next request / scan, so a legacy table is dropped and recreated
	// rather than column-migrated (SQLite can't ALTER a primary key in place).
	if err := recreateDevicesIfLegacy(ctx, tx); err != nil {
		return fmt.Errorf("migrate devices to device_id: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit bootstrap: %w", err)
	}
	return nil
}

// recreateDevicesIfLegacy drops and recreates the devices table when it predates the
// device_id primary key (the legacy (user_id, name) shape). On a fresh DB the init
// schema already created the new shape, so device_id is present and this no-ops. The
// dropped rows are ephemeral device registrations, re-created on the next request or
// scan-login, so no durable data is lost.
func recreateDevicesIfLegacy(ctx context.Context, tx *sql.Tx) error {
	has, err := columnExists(ctx, tx, "devices", "device_id")
	if err != nil {
		return err
	}
	if has {
		return nil
	}
	stmts := []string{
		"DROP TABLE IF EXISTS devices",
		`CREATE TABLE devices (
            device_id  TEXT PRIMARY KEY,
            user_id    TEXT NOT NULL,
            name       TEXT NOT NULL,
            type       TEXT NOT NULL,
            tier       TEXT NOT NULL DEFAULT 'full',
            broker_sid TEXT NOT NULL DEFAULT '',
            created_at INTEGER NOT NULL,
            last_seen  INTEGER NOT NULL
        )`,
		"CREATE INDEX IF NOT EXISTS idx_devices_user ON devices (user_id)",
	}
	for _, s := range stmts {
		if _, err := tx.ExecContext(ctx, s); err != nil {
			return err
		}
	}
	return nil
}

// ensureColumn runs addSQL only when table lacks the named column — an idempotent
// additive migration for already-created tables.
func ensureColumn(ctx context.Context, tx *sql.Tx, table, column, addSQL string) error {
	has, err := columnExists(ctx, tx, table, column)
	if err != nil {
		return err
	}
	if has {
		return nil
	}
	_, err = tx.ExecContext(ctx, addSQL)
	return err
}

func columnExists(ctx context.Context, tx *sql.Tx, table, column string) (bool, error) {
	// table is a trusted compile-time constant, not user input.
	rows, err := tx.QueryContext(ctx, "PRAGMA table_info("+table+")")
	if err != nil {
		return false, err
	}
	defer rows.Close()
	for rows.Next() {
		var (
			cid, notnull, pk int
			name, ctype      string
			dflt             sql.NullString
		)
		if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
			return false, err
		}
		if name == column {
			return true, nil
		}
	}
	return false, rows.Err()
}
