package store

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

// migrations are ordered, forward-only schema steps applied after schema.sql.
// Index i (1-based) is the user_version the step at migrations[i-1] brings the
// DB TO; there is no step 0 — schema.sql is the idempotent baseline (version 0).
// An empty slice is a clean no-op that leaves user_version at 0.
//
// To add migration #1 (e.g. the sqlcipher rekey), append its SQL:
//
//	var migrations = []string{
//		`ALTER TABLE ...;`, // #1
//	}
var migrations = []string{
	`ALTER TABLE tools ADD COLUMN scope TEXT NOT NULL DEFAULT 'homelab';`, // #1
	`ALTER TABLE reminders ADD COLUMN cron TEXT;
ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
	`CREATE TABLE memory_vectors (
		id         TEXT PRIMARY KEY,
		vec        BLOB NOT NULL,
		meta       TEXT NOT NULL DEFAULT '{}',
		created_ts INTEGER NOT NULL
	);`, // #3 — long-term vector memory (persistent backend for internal/memory)
	`CREATE TABLE IF NOT EXISTS events (
		id         INTEGER PRIMARY KEY AUTOINCREMENT,
		fact_id    INTEGER NOT NULL REFERENCES facts(id),
		action     TEXT NOT NULL,
		object     TEXT NOT NULL,
		ts         INTEGER NOT NULL
	);
	CREATE INDEX IF NOT EXISTS idx_events_action_object ON events (action, object, ts DESC);
	CREATE TABLE IF NOT EXISTS proposed_routines (
		id            INTEGER PRIMARY KEY AUTOINCREMENT,
		action        TEXT NOT NULL,
		object        TEXT NOT NULL,
		interval_days REAL NOT NULL,
		status        TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ('proposed','accepted','dismissed')),
		created_ts    INTEGER NOT NULL,
		reminder_id   INTEGER REFERENCES reminders(id),
		UNIQUE(action, object)
	);`, // #4 — event extraction + pattern inference

	`CREATE TABLE IF NOT EXISTS ack_sends (
		rule      TEXT NOT NULL,
		sent_at   INTEGER NOT NULL
	);
	CREATE INDEX IF NOT EXISTS idx_ack_sends_rule ON ack_sends (rule, sent_at DESC);`, // #5 — sev4 telegram repeat-til-ack tracking

	`CREATE TABLE IF NOT EXISTS delivery_attempts (
		id           INTEGER PRIMARY KEY AUTOINCREMENT,
		kind         TEXT NOT NULL CHECK (kind IN ('nudge','reminder')),
		rule         TEXT NOT NULL DEFAULT '',
		reminder_id  INTEGER NOT NULL DEFAULT 0,
		channel      TEXT NOT NULL,
		body_hash    TEXT NOT NULL,
		status       TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','unknown')),
		created_ts   INTEGER NOT NULL,
		completed_ts INTEGER
	);
	CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`, // #6 — durable delivery outbox

	`ALTER TABLE facts ADD COLUMN subject TEXT NOT NULL DEFAULT '';
	ALTER TABLE facts ADD COLUMN entity_id TEXT;
	ALTER TABLE facts ADD COLUMN resolution_state TEXT NOT NULL DEFAULT 'none'
		CHECK (resolution_state IN ('none','pending','resolved','ambiguous','not_found'));
	CREATE INDEX IF NOT EXISTS idx_facts_entity_id ON facts (entity_id) WHERE entity_id IS NOT NULL;
	CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async

	`CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364)
	`ALTER TABLE proposed_routines ADD COLUMN accepted_ts INTEGER;
	ALTER TABLE proposed_routines ADD COLUMN last_fired_ts INTEGER;`, // #9 — accepted routines keep firing (Vikunja #366): the tick loop needs to know when a routine was accepted and when it last nudged

	`CREATE TABLE IF NOT EXISTS dialogue_sessions (
		id         TEXT PRIMARY KEY,
		data       BLOB NOT NULL,
		ts         INTEGER NOT NULL,
		ttl_ms     INTEGER NOT NULL,
		expires_ts INTEGER NOT NULL
	);
	CREATE INDEX IF NOT EXISTS idx_dialogue_sessions_expires ON dialogue_sessions (expires_ts);`, // #10 — the follow-up session survives a restart (Vikunja #363); small, TTL-pruned table, not a history log

	`CREATE TABLE IF NOT EXISTS meta (
		key   TEXT PRIMARY KEY,
		value TEXT NOT NULL
	);`, // #11 — small key/value table for facts about the DB itself; first key is embedder_id (Vikunja #378)

	// #12 — a suppressed nudge gets a 'dropped' row (Vikunja #370). sqlite
	// can't widen a CHECK constraint in place, so the table is rebuilt; the
	// index goes with the old table and is recreated. The columns are listed
	// out rather than `SELECT *` — copying by position would silently shuffle
	// every row if the old table's column order ever differed from this one.
	`CREATE TABLE delivery_attempts_v12 (
		id           INTEGER PRIMARY KEY AUTOINCREMENT,
		kind         TEXT NOT NULL CHECK (kind IN ('nudge','reminder')),
		rule         TEXT NOT NULL DEFAULT '',
		reminder_id  INTEGER NOT NULL DEFAULT 0,
		channel      TEXT NOT NULL,
		body_hash    TEXT NOT NULL,
		status       TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','unknown','dropped')),
		created_ts   INTEGER NOT NULL,
		completed_ts INTEGER
	);
	INSERT INTO delivery_attempts_v12
		(id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts)
		SELECT id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts
		FROM delivery_attempts;
	DROP TABLE delivery_attempts;
	ALTER TABLE delivery_attempts_v12 RENAME TO delivery_attempts;
	CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`,

	// #13 — durable digest outbox (Vikunja #281). A care nudge the restraint
	// gate suppresses (quiet hours / away / calendar-busy) is not necessarily
	// lost: if it's worth resurfacing, it lands here instead, and gets spoken
	// as one bundle at the next moment speaking is appropriate. body_hash
	// dedupes repeat suppressions of the "same" nudge; expires_ts bounds how
	// stale an entry may get before it's worthless and must be dropped rather
	// than delivered late.
	`CREATE TABLE IF NOT EXISTS digest_entries (
		id         INTEGER PRIMARY KEY AUTOINCREMENT,
		rule       TEXT NOT NULL,
		severity   INTEGER NOT NULL,
		body       TEXT NOT NULL,
		body_hash  TEXT NOT NULL,
		status     TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','drained','expired')),
		created_ts INTEGER NOT NULL,
		expires_ts INTEGER NOT NULL
	);
	CREATE INDEX IF NOT EXISTS idx_digest_entries_status ON digest_entries (status);`,

	// #14 — the task capture store (Vikunja #130). Deliberately NOT facts:
	// a fact is a claim about the world that gets superseded, a task is a
	// piece of work with a lifecycle (captured → open → done), and the
	// prioritiser needs to read the live set cheaply.
	//
	// status: 'candidate' is a task Maven derived from something she read
	// (mail, later) and has NOT been confirmed by the owner; 'open' is a task
	// he actually stated (or confirmed). Nothing schedules or announces off
	// this table — capture is not a nag.
	//
	// norm is the normalised dedupe key. The unique index is PARTIAL, over
	// live rows only: re-capturing "купить молоко" after last week's one is
	// done must work, while the same mail arriving twice must not produce two
	// rows.
	`CREATE TABLE IF NOT EXISTS tasks (
		id          INTEGER PRIMARY KEY AUTOINCREMENT,
		created_ts  INTEGER NOT NULL,
		text        TEXT    NOT NULL,
		norm        TEXT    NOT NULL,
		source      TEXT    NOT NULL,
		evidence    TEXT    NOT NULL DEFAULT '',
		status      TEXT    NOT NULL DEFAULT 'open' CHECK (status IN ('candidate','open','done','dropped')),
		due_ts      INTEGER,
		weight      INTEGER NOT NULL DEFAULT 0,
		resolved_ts INTEGER
	);
	CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
	CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,

	// #15 — external identity and resolution attribution for tasks.
	//
	// ext_id is the identity of the thing a derived task was extracted FROM
	// (message id plus the extracted span), and its unique index covers EVERY
	// row, not just the live ones. The live-only norm index is right for
	// voice, where him saying the errand again is the recurrence signal. It is
	// wrong for a mailbox: mavmaild is a read-only reader, nothing marks a
	// message read, so a task he already finished would be re-extracted from
	// the same immutable text on the next poll and land back on his list as a
	// fresh candidate, forever.
	//
	// resolved_by records which caller moved the task. resolved_ts said when
	// and never by what, so a wrong resolution left no trace at all.
	`ALTER TABLE tasks ADD COLUMN ext_id TEXT;
	ALTER TABLE tasks ADD COLUMN resolved_by TEXT NOT NULL DEFAULT '';
	CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_ext_id ON tasks (ext_id) WHERE ext_id IS NOT NULL;`,
	// #16 — ecosystem call traces (Vikunja #273). Deliberately NOT facts.
	// Traces are written at machine rate, one act turn produces three or four,
	// while facts are written at human rate. Sharing the facts table made every
	// bounded reader of facts (the habit profile's 2000-row window, memeval's
	// prompt snapshot, /dash's 50 and /history's 200) read mostly traces after
	// a day of ecosystem use, pushing the rows that matter out of range.
	// Retention is enforced on write (PruneEcosystemTraces) because nothing
	// here is an audit trail: a trace answers "did this hop work" for as long
	// as anyone is still asking.
	`CREATE TABLE IF NOT EXISTS ecosystem_traces (
		id             INTEGER PRIMARY KEY AUTOINCREMENT,
		ts             INTEGER NOT NULL,
		service        TEXT    NOT NULL,
		operation      TEXT    NOT NULL,
		status         TEXT    NOT NULL,
		duration_ms    INTEGER NOT NULL DEFAULT 0,
		correlation_id TEXT    NOT NULL DEFAULT '',
		causation_id   TEXT    NOT NULL DEFAULT '',
		http_status    INTEGER NOT NULL DEFAULT 0,
		fields         TEXT    NOT NULL DEFAULT '{}'
	);
	CREATE INDEX IF NOT EXISTS idx_eco_traces_ts ON ecosystem_traces (ts DESC);
	CREATE INDEX IF NOT EXISTS idx_eco_traces_correlation ON ecosystem_traces (correlation_id);`,
	`ALTER TABLE tools ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';`,
	// #17 — what a discovered tool WAS when it was approved (Vikunja #251).
	// An MCP row's cmd is ["mcp", server, tool], which is a late-bound
	// reference: it names a tool on a server the remote end owns and it pins
	// no behaviour at all. A server upgraded, or taken over, can redefine
	// list_tasks into something that writes without the row changing by one
	// byte. The fingerprint is the declared shape at approval time, so a
	// redefinition is a re-approval instead of a silent upgrade.
	`DELETE FROM facts
	  WHERE key LIKE 'calendar_event_%'
	    AND replace(substr(key, 25), '-', '') = '';`,
	// #18 — drop the calendar keys written while safeKey dropped Cyrillic
	// (Vikunja #443). Everything after the date prefix was punctuation, so
	// every Russian event on one day shared one key and only the last one
	// survived. Deleting rather than rewriting: a calendar fact is derived
	// data, the next poll writes the day again under keys that identify the
	// event, and the old rows would otherwise be recited as extra meetings.
	// The filter is exact — it keeps any key whose summary part still has a
	// letter or a digit in it.
	`CREATE TABLE IF NOT EXISTS list_items (
		id          INTEGER PRIMARY KEY AUTOINCREMENT,
		created_ts  INTEGER NOT NULL,
		list        TEXT    NOT NULL,
		item        TEXT    NOT NULL,
		norm        TEXT    NOT NULL,
		source      TEXT    NOT NULL,
		status      TEXT    NOT NULL DEFAULT 'open' CHECK (status IN ('open','done','dropped')),
		resolved_ts INTEGER
	);
	CREATE UNIQUE INDEX IF NOT EXISTS idx_list_items_live ON list_items (list, norm) WHERE status = 'open';
	CREATE INDEX IF NOT EXISTS idx_list_items_list ON list_items (list, status, created_ts);`,
	// #19 — standing lists (Vikunja #453). The fourth append-only shape, after
	// facts, notes and tasks, and the reason it is its own table rather than a
	// tag on tasks: milk on the shopping list is not work. Nothing prioritises
	// it, nothing nudges about it, and the prioritiser must not start counting
	// groceries as outstanding errands.
	//
	// The live-only unique index is the tasks one, per list: saying "молоко"
	// twice before the shop keeps one row, saying it again next week after the
	// last one was crossed off writes a new one.

	// #20 — unstick the routines accepted before the fire-forever fix
	// (Vikunja #377, follow-up to #366). Accepting used to leave accepted_ts
	// NULL and a live one-shot reminder behind, and the tick loop skips a row
	// with no accepted_ts, so every non-weekly routine accepted before that fix
	// has been silent ever since.
	//
	// Three statements, in this order, per stuck row: adopt created_ts as the
	// acceptance time, cancel the reminder that is still holding the schedule,
	// then let go of it. Cancelling before clearing matters — clearing first
	// loses the only pointer to the reminder and leaves it to fire on its own.
	//
	// created_ts rather than a fresh timestamp because a migration has no
	// clock, and because the first interval should be measured from when he
	// said yes. A routine whose interval has already elapsed nudges on the next
	// tick, which is what being unstuck looks like.
	//
	// Weekly rows are included deliberately. Theirs was the case that kept
	// working, because the cron reminder reschedules itself — so leaving them
	// alone would give them both a cron reminder and a tick-loop schedule for
	// one habit, and he would hear it twice.
	`UPDATE reminders
	     SET status = 'cancelled'
	   WHERE status = 'pending'
	     AND id IN (SELECT reminder_id FROM proposed_routines
	                 WHERE status = 'accepted' AND accepted_ts IS NULL AND reminder_id IS NOT NULL);
	UPDATE proposed_routines
	     SET accepted_ts = created_ts, reminder_id = NULL
	   WHERE status = 'accepted' AND accepted_ts IS NULL;`,

	// #21 — allow 'resolved' as a nudge outcome (Vikunja #535). The sev4 repeat
	// path needs an ending that means "the condition cleared, so I stopped
	// talking", which is neither 'acted' (he answered) nor 'ignored' (nobody
	// ever did). A CHECK cannot be altered in place, so the table is rebuilt.
	// Rows carry over unchanged; only the constraint widens.
	`CREATE TABLE nudges_new (
	   id          INTEGER PRIMARY KEY AUTOINCREMENT,
	   ts          INTEGER NOT NULL,
	   rule        TEXT    NOT NULL,
	   channel     TEXT    NOT NULL,
	   message     TEXT    NOT NULL,
	   outcome     TEXT    NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored','resolved')),
	   outcome_ts  INTEGER
	 );
	 INSERT INTO nudges_new (id, ts, rule, channel, message, outcome, outcome_ts)
	   SELECT id, ts, rule, channel, message, outcome, outcome_ts FROM nudges;
	 DROP TABLE nudges;
	 ALTER TABLE nudges_new RENAME TO nudges;
	 CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
	 CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome);`,

	// #22 — the two columns that make tasks a work board rather than a to-do
	// list (Vikunja #510). done_when is the acceptance criterion, and blocked_on
	// is a canonical Nexus entity id: it names a person, identity lives in
	// Nexus, and a local free-text name would be a second answer to a question
	// Nexus already owns. Both default to empty rather than NULL, because "he
	// has not written one" and "there is nothing to write" are the same state
	// here and no caller has to tell them apart.
	`ALTER TABLE tasks ADD COLUMN done_when TEXT NOT NULL DEFAULT '';
	 ALTER TABLE tasks ADD COLUMN blocked_on TEXT NOT NULL DEFAULT '';`,
	// #23 — the routing trace (V-629). internal/decision kept a 25-turn ring and
	// persisted nothing, on the argument that a turn record is read minutes later
	// or never. The owner reversed that on 06-08-2026: mode discovery and distance
	// calibration need real utterances, and there is no other source of them.
	// docs/plans/21-persisting-the-routing-trace.md carries the
	// reversal.
	//
	// utterance holds his words in clear. A 384-dimension vector of a short
	// sentence is substantially recoverable, so storing vectors instead would be a
	// privacy claim we cannot support. What makes it safe is the same thing that
	// makes the fact store safe: it never leaves the box, retention is bounded at
	// store.RoutingTraceRetention, and Wipe drops it with everything else.
	//
	// correction is empty until the owner corrects a turn on /chat (V-630). A
	// corrected pair is promoted out of here into a seed-shaped row and kept, so
	// this column is a queue, not the durable label.
	`CREATE TABLE IF NOT EXISTS routing_traces (
		id                  INTEGER PRIMARY KEY AUTOINCREMENT,
		ts                  INTEGER NOT NULL,
		utterance           TEXT    NOT NULL,
		source              TEXT    NOT NULL DEFAULT '',
		winner              TEXT    NOT NULL DEFAULT '',
		intent              TEXT    NOT NULL DEFAULT '',
		claimed_before_head INTEGER NOT NULL DEFAULT 0,
		encoder_id          TEXT    NOT NULL DEFAULT '',
		outcome             TEXT    NOT NULL DEFAULT '',
		correction          TEXT    NOT NULL DEFAULT '',
		claims              TEXT    NOT NULL DEFAULT '[]'
	);
	CREATE INDEX IF NOT EXISTS idx_routing_traces_ts ON routing_traces (ts DESC);`,
	// #24 — the corrected pairs (V-630). Separate from routing_traces on
	// purpose, and this is the whole retention argument: a trace is a transcript
	// and expires in 14 days, while a correction is a label the owner wrote by
	// hand and is the only supervised signal the box will ever get. Promoting it
	// out at the moment he writes it means the label survives the transcript
	// that carried it.
	//
	// should_be may be empty. "That was wrong" with no target is a usable
	// negative and must not cost more to give than the full answer would.
	//
	// UNIQUE(utterance) so correcting the same sentence twice replaces the
	// label rather than stacking two. His second answer is the one he meant.
	`CREATE TABLE IF NOT EXISTS routing_labels (
		id         INTEGER PRIMARY KEY AUTOINCREMENT,
		ts         INTEGER NOT NULL,
		utterance  TEXT    NOT NULL UNIQUE,
		was        TEXT    NOT NULL DEFAULT '',
		should_be  TEXT    NOT NULL DEFAULT '',
		source     TEXT    NOT NULL DEFAULT '',
		encoder_id TEXT    NOT NULL DEFAULT ''
	);`,
	// #25 — durable reminder delivery state (V-651). A reminder can remain
	// pending for a long time when every reachable transport is unhealthy. The
	// phrased text belongs to that delivery occurrence, so retaining it here
	// avoids spending the resident model again on every retry and across daemon
	// restarts. next_attempt_ts makes transport failures wait on a bounded
	// exponential backoff instead of retrying at the tick rate. delivery_group
	// keeps the originals of a collapsed catch-up bundle together; a reminder
	// that becomes due later must not make the old bundle get re-phrased.
	`ALTER TABLE reminders ADD COLUMN delivery_group TEXT NOT NULL DEFAULT '';
	 ALTER TABLE reminders ADD COLUMN phrase_body TEXT NOT NULL DEFAULT '';
	 ALTER TABLE reminders ADD COLUMN phrase_summary TEXT NOT NULL DEFAULT '';
	 ALTER TABLE reminders ADD COLUMN phrase_mood TEXT NOT NULL DEFAULT '';
	 ALTER TABLE reminders ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0 CHECK (delivery_attempts >= 0);
	 ALTER TABLE reminders ADD COLUMN next_attempt_ts INTEGER;
	 ALTER TABLE delivery_attempts ADD COLUMN delivery_group TEXT NOT NULL DEFAULT '';
	 ALTER TABLE reminders ADD COLUMN delivery_blocked_ts INTEGER;
	 ALTER TABLE reminders ADD COLUMN delivery_blocked_error TEXT NOT NULL DEFAULT '';
	 CREATE INDEX IF NOT EXISTS idx_reminders_due
	   ON reminders (next_fire_ts, next_attempt_ts)
	   WHERE status = 'pending';
	 CREATE INDEX IF NOT EXISTS idx_delivery_attempts_reminder_group
	   ON delivery_attempts (delivery_group, status)
	   WHERE kind = 'reminder' AND delivery_group <> '';`,

	// #26 — pre-phrase identity for the suppressed-nudge digest (V-687).
	// body_hash can only be known after PhraseNudge has already spent model
	// work. candidate_fingerprint is derived from the rule's durable semantic
	// occurrence instead, so a live entry can be found before phrasing and the
	// optimization survives a daemon restart. Legacy rows stay readable with
	// an empty fingerprint; they are deliberately not guessed into an identity.
	`ALTER TABLE digest_entries ADD COLUMN candidate_fingerprint TEXT NOT NULL DEFAULT '';
	 CREATE UNIQUE INDEX IF NOT EXISTS idx_digest_entries_live_candidate
	   ON digest_entries (rule, candidate_fingerprint)
	   WHERE status = 'pending' AND candidate_fingerprint <> '';`,
}

// migrate applies every migration with a number greater than the DB's current
// user_version, each in its own transaction that also bumps user_version. Fails
// closed: the first erroring step aborts and leaves prior steps committed.
func migrate(ctx context.Context, db *sql.DB) error {
	var current int
	if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&current); err != nil {
		return fmt.Errorf("read user_version: %w", err)
	}
	for i := current; i < len(migrations); i++ {
		version := i + 1 // 1-based: migrations[i] brings DB to `version`
		tx, err := db.BeginTx(ctx, nil)
		if err != nil {
			return fmt.Errorf("migration %d begin: %w", version, err)
		}
		if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
			_ = tx.Rollback()
			return fmt.Errorf("migration %d: %w", version, err)
		}
		// PRAGMA user_version can't be parameterized; version is our own int.
		if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil {
			_ = tx.Rollback()
			return fmt.Errorf("migration %d bump: %w", version, err)
		}
		if err := tx.Commit(); err != nil {
			return fmt.Errorf("migration %d commit: %w", version, err)
		}
	}
	return nil
}
