using System.Data.Common;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;

namespace JobTrackerApi.Services;

public static class StartupInitializationExtensions
{
    // SQLite-dialect schema helpers. Promoted from local functions to class-level statics so a
    // second reconciliation pass can run after Migrate() creates the base tables on a brand-new
    // database (see the CoreSchemaReady-adjacent block near the end of InitializeJobTrackerAsync):
    // the ad-hoc EnsureColumn calls below no-op on a table that doesn't exist yet, so a genuinely
    // fresh boot needs them re-run once Migrate() has created JobApplications/Correspondences.
    private static bool HasTable(DbConnection c, string table)
    {
        using var cmd = c.CreateCommand();
        cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
        var p = cmd.CreateParameter();
        p.ParameterName = "$name";
        p.Value = table;
        cmd.Parameters.Add(p);
        return cmd.ExecuteScalar() is not null;
    }

    private static bool HasColumn(DbConnection c, string table, string column)
    {
        using var cmd = c.CreateCommand();
        cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
        return cmd.ExecuteScalar() is not null;
    }

    private static bool HasMigration(DbConnection c, string migrationId)
    {
        if (!HasTable(c, "__EFMigrationsHistory")) return false;
        using var cmd = c.CreateCommand();
        cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
        var p = cmd.CreateParameter();
        p.ParameterName = "$id";
        p.Value = migrationId;
        cmd.Parameters.Add(p);
        return cmd.ExecuteScalar() is not null;
    }

    private static void Exec(DbConnection c, string sql)
    {
        using var cmd = c.CreateCommand();
        cmd.CommandText = sql;
        cmd.ExecuteNonQuery();
    }

    private static void EnsureColumn(DbConnection c, string table, string column, string ddl)
    {
        // Fresh databases won't have the table until EF migrations run.
        if (!HasTable(c, table)) return;
        if (!HasColumn(c, table, column)) Exec(c, ddl);
    }

    // Ad-hoc columns/backfills added over time without a matching EF migration (the reason the
    // ModelSnapshot drifted -- see the SyncModelSnapshot migration's doc comment). Safe to call
    // any number of times against any connection state: every check no-ops if the table or
    // column doesn't exist yet or already matches.
    private static void ReconcileCoreAppColumns(DbConnection conn)
    {
        EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
        EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
        EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
        EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
        EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
        EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
        EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
        EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
        EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
        EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
        EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
        EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
        EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
        EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
        EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
        EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
        EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
        EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
        EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
        EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
        EnsureColumn(conn, "Correspondences", "Provider", "ALTER TABLE Correspondences ADD COLUMN Provider TEXT NULL;");
        if (HasTable(conn, "Correspondences"))
        {
            Exec(conn, "UPDATE Correspondences SET Provider = 'gmail' WHERE Provider IS NULL AND ExternalThreadId IS NOT NULL;");
            Exec(conn, "UPDATE Correspondences SET Provider = 'manual' WHERE Provider IS NULL;");
        }
        EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
        EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
    }

    // MySQL/MariaDB-dialect equivalents of the helpers above.
    private static bool HasMySqlTable(DbConnection c, string table)
    {
        using var cmd = c.CreateCommand();
        cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
        var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
        var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
        return cmd.ExecuteScalar() is not null;
    }

    private static bool HasMySqlIndex(DbConnection c, string table, string indexName)
    {
        using var cmd = c.CreateCommand();
        cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;";
        var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
        var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
        var p3 = cmd.CreateParameter(); p3.ParameterName = "@index"; p3.Value = indexName; cmd.Parameters.Add(p3);
        return cmd.ExecuteScalar() is not null;
    }

    // The one place an index is created on MySQL. Guarded on TABLE existence, not just index
    // existence: the reconciler runs a pass BEFORE Migrate(), so on a brand-new database the
    // migration-owned tables do not exist yet. Repairing an absent table is not this pass's job --
    // it is skipped, Migrate() creates it, and the post-Migrate pass then finds it and reconciles.
    // Without this guard a clean MariaDB install died on "Table 'CareerProfiles' doesn't exist".
    // See docs/infrastructure/database-ownership.md.
    private static void EnsureMySqlIndex(DbConnection c, string table, string indexName, string columnsSql, bool unique = false)
    {
        if (!HasMySqlTable(c, table)) return;
        if (HasMySqlIndex(c, table, indexName)) return;
        using var cmd = c.CreateCommand();
        cmd.CommandText = $"CREATE {(unique ? "UNIQUE " : string.Empty)}INDEX `{indexName}` ON `{table}` ({columnsSql});";
        cmd.ExecuteNonQuery();
    }

    // A migration scaffolded against SQLite bakes SQLite type names into its DDL ("TEXT" for
    // DateTimeOffset, "INTEGER" for bool/int, no AUTO_INCREMENT). Run against MariaDB it produces a
    // structurally wrong table -- and a composite index over a TEXT column then blows MySQL's
    // 3072-byte key limit, which is what crashed backend startup on prod. Detect that shape by
    // probing one column's data type and rebuild the table from the correct MySQL DDL.
    //
    // Guarded on row count: an empty malformed table is dropped and recreated; a table with ANY rows
    // is left untouched (never destroy user data -- surface it instead of silently deleting).
    private static bool DropMalformedMySqlTable(DbConnection c, string table, string probeColumn, string expectedDataType)
    {
        if (!HasMySqlTable(c, table)) return false;

        string? actual;
        using (var probe = c.CreateCommand())
        {
            probe.CommandText = "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
            var s = probe.CreateParameter(); s.ParameterName = "@schema"; s.Value = c.Database; probe.Parameters.Add(s);
            var t = probe.CreateParameter(); t.ParameterName = "@table"; t.Value = table; probe.Parameters.Add(t);
            var col = probe.CreateParameter(); col.ParameterName = "@column"; col.Value = probeColumn; probe.Parameters.Add(col);
            actual = probe.ExecuteScalar() as string;
        }

        // Unknown column or already the right type: nothing to repair.
        if (string.IsNullOrEmpty(actual) || string.Equals(actual, expectedDataType, StringComparison.OrdinalIgnoreCase)) return false;

        long rows;
        using (var count = c.CreateCommand())
        {
            count.CommandText = $"SELECT COUNT(*) FROM `{table}`;";
            rows = Convert.ToInt64(count.ExecuteScalar() ?? 0L);
        }
        if (rows > 0) return false;

        using var drop = c.CreateCommand();
        drop.CommandText = $"DROP TABLE `{table}`;";
        drop.ExecuteNonQuery();
        return true;
    }

    private static bool MySqlColumnExists(DbConnection c, string table, string column)
    {
        using var cmd = c.CreateCommand();
        cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
        var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
        var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
        var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
        return cmd.ExecuteScalar() is not null;
    }

    private static void EnsureMySqlColumn(DbConnection c, string table, string column, string ddl)
    {
        if (!HasMySqlTable(c, table)) return;
        if (MySqlColumnExists(c, table, column)) return;
        using var ddlCmd = c.CreateCommand();
        ddlCmd.CommandText = ddl;
        ddlCmd.ExecuteNonQuery();
    }

    // MySQL mirror of ReconcileCoreAppColumns -- same rationale (re-run after Migrate() on a
    // brand-new database, where these tables didn't exist yet during the pre-Migrate pass).
    private static void ReconcileCoreAppColumnsMySql(DbConnection conn)
    {
        EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
        EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;");
        EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;");
        EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;");
        EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;");
        EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;");
        EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;");
        EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;");
        EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;");
        EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;");
        EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
        EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;");
        EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;");
        EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;");
        EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
        EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
        EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
        EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
        EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
        EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;");
        EnsureMySqlColumn(conn, "Correspondences", "Provider", "ALTER TABLE `Correspondences` ADD COLUMN `Provider` varchar(50) NULL;");
        if (HasMySqlTable(conn, "Correspondences"))
        {
            using (var backfillGmail = conn.CreateCommand())
            {
                backfillGmail.CommandText = "UPDATE `Correspondences` SET `Provider` = 'gmail' WHERE `Provider` IS NULL AND `ExternalThreadId` IS NOT NULL;";
                backfillGmail.ExecuteNonQuery();
            }
            using (var backfillManual = conn.CreateCommand())
            {
                backfillManual.CommandText = "UPDATE `Correspondences` SET `Provider` = 'manual' WHERE `Provider` IS NULL;";
                backfillManual.ExecuteNonQuery();
            }
        }
        EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
        EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
    }

    public static Task InitializeJobTrackerAsync(this WebApplication app)
    {
        // Apply EF migrations on startup (SQLite dev DB lives in the repo).
        using (var scope = app.Services.CreateScope())
        {
            var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
            var paths = scope.ServiceProvider.GetRequiredService<AppPaths>();
            var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
            var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
            var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
            var useSqliteBootstrap = provider is not "mysql" and not "mariadb";

            // The schema reconciler. Runs TWICE: once before Migrate() and once after.
            //
            // Before: legacy databases need hand-added columns repaired and the legacy
            // migration-history stamp written, or Migrate() can collide with them.
            // After: on a brand-new database the migration-owned tables did not exist during the first
            // pass, so index, column, and AUTO_INCREMENT repairs were skipped. The second pass finds
            // the migration-created tables and finishes compatibility repair.
            //
            // Every statement in here is existence-guarded, so running it twice is a no-op scan on an
            // already-correct database. docs/infrastructure/database-ownership.md.
            void ReconcileSchema()
            {
            if (useSqliteBootstrap)
            {
                // Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied).
                // If the schema already contains the columns added by migration 20260310195000, record that migration
                // so EF doesn't try to apply it again and fail on duplicate columns.
                const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete";
                const string legacyProductVersion = "7.0.17";

                // Not a `using`: the connection belongs to the DbContext, and ReconcileSchema runs more
                    // than once — disposing it here made the second pass throw ObjectDisposedException.
                    DbConnection conn = db.Database.GetDbConnection();
                // ReconcileSchema runs twice; the second pass may inherit an already-open connection.
                if (conn.State != System.Data.ConnectionState.Open) conn.Open();

                EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvStructureJson TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvUploadArtifactId INTEGER NULL;");
                EnsureColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvExtractionRunId INTEGER NULL;");
                EnsureColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvProfileVersion INTEGER NULL;");
                EnsureColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE AspNetUsers ADD COLUMN AvatarImageDataUrl TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftLinkedAt TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpSecretEncrypted TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpPendingSecretEncrypted TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE AspNetUsers ADD COLUMN TotpEnabledAtUtc TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "StripeCustomerId", "ALTER TABLE AspNetUsers ADD COLUMN StripeCustomerId TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionId TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionStatus TEXT NULL;");
                EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
                // UiLanguage is migration-owned (AddUiLanguagePreference). Adding it here before
                // the per-migration loop makes a fresh database fail when that migration runs.

                static void ReconcileGmailConnectionColumns(DbConnection c)
                {
                    EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT NULL;");
                    EnsureColumn(c, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSucceededAt TEXT NULL;");
                    EnsureColumn(c, "GmailConnections", "LastSyncMode", "ALTER TABLE GmailConnections ADD COLUMN LastSyncMode TEXT NULL;");
                    EnsureColumn(c, "GmailConnections", "LastSyncSource", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSource TEXT NULL;");
                    EnsureColumn(c, "GmailConnections", "LastSyncStatus", "ALTER TABLE GmailConnections ADD COLUMN LastSyncStatus TEXT NULL;");
                    EnsureColumn(c, "GmailConnections", "LastSyncError", "ALTER TABLE GmailConnections ADD COLUMN LastSyncError TEXT NULL;");
                }

                // Preserve the one historical additive column repair while migrations own table creation.
                static void ReconcileCareerProfileColumns(DbConnection c)
                {
                    EnsureColumn(c, "CareerProfiles", "LongTailJson",
                        """ALTER TABLE "CareerProfiles" ADD COLUMN "LongTailJson" TEXT NOT NULL DEFAULT '';""");
                }

                static void ReconcileAiInteractionUsageColumns(DbConnection c)
                {
                    EnsureColumn(c, "AiInteractions", "InputCharacterCount", "ALTER TABLE AiInteractions ADD COLUMN InputCharacterCount INTEGER NOT NULL DEFAULT 0;");
                    EnsureColumn(c, "AiInteractions", "OutputCharacterCount", "ALTER TABLE AiInteractions ADD COLUMN OutputCharacterCount INTEGER NOT NULL DEFAULT 0;");
                    EnsureColumn(c, "AiInteractions", "EstimatedTokenCount", "ALTER TABLE AiInteractions ADD COLUMN EstimatedTokenCount INTEGER NOT NULL DEFAULT 0;");
                }

                ReconcileGmailConnectionColumns(conn);
                ReconcileCareerProfileColumns(conn);
                ReconcileAiInteractionUsageColumns(conn);

                // Once the base app tables exist, stamp the historical migration before later
                // migrations rebuild JobApplications.
                var isLegacy =
                    HasMigration(conn, "20260310174114_AddCorrespondence") &&
                    !HasMigration(conn, legacyMigrationId);

                if (isLegacy)
                {
                    EnsureColumn(conn, "Companies", "Source", "ALTER TABLE Companies ADD COLUMN Source TEXT NULL;");
                    EnsureColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE JobApplications ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;");
                    EnsureColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE JobApplications ADD COLUMN DeletedAt TEXT NULL;");
                    EnsureColumn(conn, "JobApplications", "Location", "ALTER TABLE JobApplications ADD COLUMN Location TEXT NULL;");
                    EnsureColumn(conn, "JobApplications", "Salary", "ALTER TABLE JobApplications ADD COLUMN Salary TEXT NULL;");
                    EnsureColumn(conn, "JobApplications", "NextAction", "ALTER TABLE JobApplications ADD COLUMN NextAction TEXT NULL;");
                    EnsureColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE JobApplications ADD COLUMN FollowUpAt TEXT NULL;");

                    // Ensure the persisted short summary column exists for older dev DBs.
                    EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");

                    // Multi-user support: scope data to the authenticated user.
                    EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
                    EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");

                    // Legacy DBs may be missing later correspondence columns (Subject/Channel).
                    if (HasTable(conn, "Correspondences"))
                    {
                        EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
                        EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
                        EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
                        EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
                        EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
                        EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
                        EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
                        EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
                        EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
                    }

                    // Record the migration as applied.
                    Exec(
                        conn,
                        "INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) " +
                            $"VALUES ('{legacyMigrationId}', '{legacyProductVersion}');"
                    );
                }

                // Some dev DBs may not match the "legacy" fingerprint above but still lack
                // later ad-hoc columns. Ensure them unconditionally if missing (also re-run once
                // more after Migrate() below, in case this is a brand-new DB where these tables
                // didn't exist yet at this point).
                ReconcileCoreAppColumns(conn);

                // Hot-path composite indexes for tenant-scoped list/board/stats/analytics
                // (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). Guarded
                // on table existence: on a brand-new DB the table is created by Migrate()
                // below, so the index is picked up on the next start.
                if (HasTable(conn, "JobApplications"))
                {
                    Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted" ON "JobApplications" ("OwnerUserId", "IsDeleted");""");
                    Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_FollowUpAt" ON "JobApplications" ("OwnerUserId", "FollowUpAt");""");
                    Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted_Status" ON "JobApplications" ("OwnerUserId", "IsDeleted", "Status");""");
                }

                if (HasTable(conn, "Correspondences"))
                {
                    Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_Correspondences_JobApplicationId" ON "Correspondences" ("JobApplicationId");""");
                }

                if (HasTable(conn, "JobEvents"))
                {
                    Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobEvents_JobApplicationId" ON "JobEvents" ("JobApplicationId");""");
                }

                // Ensure data folder exists before creating/opening SQLite files.
                Directory.CreateDirectory(paths.DataRoot);
            }
            else
            {
                var cs = app.Configuration.GetConnectionString("JobTracker");
                if (!string.IsNullOrWhiteSpace(cs))
                {
                    // Not a `using`: the connection belongs to the DbContext, and ReconcileSchema runs more
                    // than once — disposing it here made the second pass throw ObjectDisposedException.
                    DbConnection conn = db.Database.GetDbConnection();
                    // ReconcileSchema runs twice; the second pass may inherit an already-open connection.
                    if (conn.State != System.Data.ConnectionState.Open) conn.Open();

                    static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column)
                    {
                        using var cmd = c.CreateCommand();
                        cmd.CommandText = @"SELECT EXTRA
        FROM INFORMATION_SCHEMA.COLUMNS
        WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column
        LIMIT 1;";
                        var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
                        var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
                        var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
                        var extra = cmd.ExecuteScalar()?.ToString() ?? string.Empty;
                        return extra.Contains("auto_increment", StringComparison.OrdinalIgnoreCase);
                    }

                    static void EnsureMySqlAutoIncrementPrimaryKey(DbConnection c, string table, string column)
                    {
                        if (!HasMySqlTable(c, table) || !MySqlColumnExists(c, table, column) || MySqlIntPrimaryKeyIsAutoIncrement(c, table, column))
                        {
                            return;
                        }

                        using var cmd = c.CreateCommand();
                        cmd.CommandText = $"ALTER TABLE `{table}` MODIFY COLUMN `{column}` int NOT NULL AUTO_INCREMENT;";
                        cmd.ExecuteNonQuery();
                    }

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "Companies", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "Jobs", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "JobApplications", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "Correspondences", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "Attachments", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "ImapConnections", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id");

                    EnsureMySqlIndex(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId", "`OwnerUserId`", unique: true);

                    EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`");

                    // Guarded historical shape/index repairs only. Creation belongs to the current
                    // provider-aware Career Profile adoption migration.
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerExperiences", "Id");
                    EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_CareerProfileId", "`CareerProfileId`");
                    EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerEducations", "Id");
                    EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_CareerProfileId", "`CareerProfileId`");
                    EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerSkills", "Id");
                    EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_CareerProfileId", "`CareerProfileId`");
                    EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProjects", "Id");
                    EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_CareerProfileId", "`CareerProfileId`");
                    EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerCertifications", "Id");
                    EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_CareerProfileId", "`CareerProfileId`");
                    EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerLanguages", "Id");
                    EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_CareerProfileId", "`CareerProfileId`");
                    EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id");

                    EnsureMySqlIndex(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId", "`OwnerUserId`, `JobApplicationId`", unique: true);

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id");

                    EnsureMySqlIndex(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType", "`OwnerUserId`, `JobApplicationId`, `NoteType`", unique: true);

                    // Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
                    // Correspondences/Attachments) -- re-run once more after Migrate() below via
                    // ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
                    ReconcileCoreAppColumnsMySql(conn);
                    EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvExtractionRunId` int NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvProfileVersion` int NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE `AspNetUsers` ADD COLUMN `AvatarImageDataUrl` longtext NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` longtext NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftLinkedAt` datetime NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpSecretEncrypted` longtext NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpPendingSecretEncrypted` longtext NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpEnabledAtUtc` datetime NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "StripeCustomerId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeCustomerId` varchar(255) NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionId` varchar(255) NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionStatus` varchar(64) NULL;");
                    EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
                    // UiLanguage is migration-owned; the final reconciliation pass observes it
                    // after AddUiLanguagePreference instead of racing the migration.

                    // RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
                    // used to create it too, which made a clean install fail with "Table 'RuleSettings'
                    // already exists" when Migrate() then tried. Only the default row is seeded here,
                    // and only once the table exists (pass 3 on a fresh database).
                    if (HasMySqlTable(conn, "RuleSettings"))
                    {
                        using var seedRuleSettings = conn.CreateCommand();
                        seedRuleSettings.CommandText = @"INSERT INTO `RuleSettings` (`Id`, `AppliedFollowUpDays`, `AppliedGhostDays`, `OfferFollowUpDays`, `OfferGhostDays`, `FeedbackFollowUpDays`, `FeedbackGhostDays`)
        SELECT 1, 14, 30, 7, 14, 7, 14
        WHERE NOT EXISTS (SELECT 1 FROM `RuleSettings` WHERE `Id` = 1);";
                        seedRuleSettings.ExecuteNonQuery();
                    }

                    EnsureMySqlColumn(conn, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncAttemptedAt` datetime(6) NULL;");
                    EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSucceededAt` datetime(6) NULL;");
                    EnsureMySqlColumn(conn, "GmailConnections", "LastSyncMode", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncMode` varchar(255) NULL;");
                    EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSource", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSource` varchar(255) NULL;");
                    EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;");
                    EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;");

                    // Additive repair for a CareerProfiles table created before LongTailJson was added
                    // to the model. Without it, /api/cv/outline fails with "Unknown column LongTailJson".
                    // DEFAULT '' backfills existing rows and matches the non-nullable model property.
                    EnsureMySqlColumn(conn, "CareerProfiles", "LongTailJson", "ALTER TABLE `CareerProfiles` ADD COLUMN `LongTailJson` longtext NOT NULL DEFAULT '';");


                    // Historical Phase 4/5 migrations were scaffolded against SQLite, so on MariaDB
                    // they could emit TEXT datetimes and a PK with no AUTO_INCREMENT, then fail while
                    // indexing TEXT columns. Drop only malformed empty tables before the current
                    // provider-aware adoption migrations run; populated tables are never replaced.
                    // Children first: CvVariantVersions FKs into CvVariants.
                    DropMalformedMySqlTable(conn, "CvVariantVersions", "CreatedAtUtc", "datetime");
                    DropMalformedMySqlTable(conn, "CvVariants", "UpdatedAtUtc", "datetime");
                    DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime");
                    DropMalformedMySqlTable(conn, "ApplicationChecklistItems", "CreatedAtUtc", "datetime");
                    DropMalformedMySqlTable(conn, "CoverLetterVersions", "CreatedAtUtc", "datetime");
                    DropMalformedMySqlTable(conn, "InterviewPrepItems", "CreatedAtUtc", "datetime");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepItems", "Id");
                    EnsureMySqlIndex(conn, "InterviewPrepItems", "IX_InterviewPrepItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CoverLetterVersions", "Id");
                    EnsureMySqlIndex(conn, "CoverLetterVersions", "IX_CoverLetterVersions_Owner_Job_Version", "`OwnerUserId`, `JobApplicationId`, `Version`");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "ApplicationChecklistItems", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariantVersions", "Id");
                    EnsureMySqlAutoIncrementPrimaryKey(conn, "AiInteractions", "Id");
                    EnsureMySqlColumn(conn, "AiInteractions", "InputCharacterCount", "ALTER TABLE `AiInteractions` ADD COLUMN `InputCharacterCount` int NOT NULL DEFAULT 0;");
                    EnsureMySqlColumn(conn, "AiInteractions", "OutputCharacterCount", "ALTER TABLE `AiInteractions` ADD COLUMN `OutputCharacterCount` int NOT NULL DEFAULT 0;");
                    EnsureMySqlColumn(conn, "AiInteractions", "EstimatedTokenCount", "ALTER TABLE `AiInteractions` ADD COLUMN `EstimatedTokenCount` int NOT NULL DEFAULT 0;");

                    foreach (var (ixTable, ixName, ixColumns, ixUnique) in new[]
                    {
                        ("CvVariants", "IX_CvVariants_JobApplicationId", "`JobApplicationId`", false),
                        ("CvVariants", "IX_CvVariants_OwnerUserId_UpdatedAtUtc", "`OwnerUserId`, `UpdatedAtUtc`", false),
                        ("CvVariants", "IX_CvVariants_PublicSlug", "`PublicSlug`", true),
                        ("CvVariantVersions", "IX_CvVariantVersions_CvVariantId_Version", "`CvVariantId`, `Version`", false),
                        ("AiInteractions", "IX_AiInteractions_JobApplicationId", "`JobApplicationId`", false),
                        ("AiInteractions", "IX_AiInteractions_Owner_Job_Module_Created", "`OwnerUserId`, `JobApplicationId`, `Module`, `CreatedAtUtc`", false),
                        ("ApplicationChecklistItems", "IX_ApplicationChecklistItems_JobApplicationId_SystemKey", "`JobApplicationId`, `SystemKey`", true),
                        ("ApplicationChecklistItems", "IX_ApplicationChecklistItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`", false),
                    })
                    {
                        EnsureMySqlIndex(conn, ixTable, ixName, ixColumns, ixUnique);
                    }

                    EnsureMySqlIndex(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId", "`OwnerUserId`", unique: true);

                    EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id");

                    EnsureMySqlIndex(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc", "`UserId`, `UsedAtUtc`");

                    EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id");

                    EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_UserId", "`UserId`");

                    EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash", "`TokenHash`");

                    EnsureMySqlIndex(conn, "UserSessions", "IX_UserSessions_UserId", "`UserId`");

                    // Schema reconciliation must never crash app startup: an index that fails
                    // (e.g. combined key exceeds MySQL's 3072-byte limit because an older
                    // migration made OwnerUserId wider than the varchar(255) this reconciler
                    // assumes) is logged and skipped rather than taking prod down. OwnerUserId
                    // is prefix-indexed at 191 chars (safe under utf8mb4's 767-byte legacy
                    // per-column key limit, and far longer than the GUID-like Identity ids
                    // actually stored there) so composite indexes stay well under the cap
                    // regardless of the column's declared width.
                    // Same guarded creation, but non-fatal: these are optimisation indexes on
                    // migration-owned tables, so a failure must degrade performance, never boot.
                    void TryCreateIndex(string table, string indexName, string columnsSql, bool unique = false)
                    {
                        try
                        {
                            EnsureMySqlIndex(conn, table, indexName, columnsSql, unique);
                        }
                        catch (Exception ex)
                        {
                            app.Logger.LogWarning(ex, "Skipping index {Index} on {Table} during startup reconciliation.", indexName, table);
                        }
                    }

                    void TryCreateUniqueIndex(string table, string indexName, string columnsSql) =>
                        TryCreateIndex(table, indexName, columnsSql, unique: true);

                    TryCreateIndex("Companies", "IX_Companies_OwnerUserId", "`OwnerUserId`(191)");
                    TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId", "`OwnerUserId`(191)");

                    // Hot-path composite indexes for tenant-scoped list/board/stats/analytics
                    // (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt).
                    TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted", "`OwnerUserId`(191), `IsDeleted`");
                    // FollowUpAt is `text` on MariaDB (the migration was scaffolded against SQLite, which
                    // stores DateTimeOffset as TEXT). A text column cannot be indexed without a prefix
                    // length, so without one this index ALWAYS failed the 3072-byte key check, was caught
                    // and skipped on every boot, and left the follow-up reminder query unindexed — while
                    // logging a "Specified key was too long" line the deploy runbook flags as a rollback
                    // signal. Prefix it like Status(50) below. ISO-8601 date strings sort lexicographically,
                    // so a 20-char prefix ("YYYY-MM-DD HH:MM:SS") keeps the index useful for the reminder scan.
                    TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`(20)");
                    // Status is longtext in MySQL (see JobTrackerContext.OnModelCreating), so it
                    // needs an explicit prefix length to be indexable under MariaDB's key-length rules.
                    TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted_Status", "`OwnerUserId`(191), `IsDeleted`, `Status`(50)");
                    TryCreateIndex("Correspondences", "IX_Correspondences_JobApplicationId", "`JobApplicationId`");
                    TryCreateIndex("JobEvents", "IX_JobEvents_JobApplicationId", "`JobApplicationId`");
                    TryCreateIndex("CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc", "`OwnerUserId`(191), `UploadedAtUtc`");
                    TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc", "`OwnerUserId`(191), `StartedAtUtc`");
                    TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId", "`ArtifactId`");
                    TryCreateIndex("GmailConnections", "IX_GmailConnections_OwnerUserId", "`OwnerUserId`(191)");
                    TryCreateUniqueIndex("GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress", "`OwnerUserId`(191), `GmailAddress`(191)");
                    TryCreateIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId", "`OwnerUserId`(191)");
                    TryCreateUniqueIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress", "`OwnerUserId`(191), `MailAddress`(191)");
                    TryCreateUniqueIndex("ImapConnections", "IX_ImapConnections_OwnerUserId", "`OwnerUserId`(191)");
                    TryCreateUniqueIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId", "`OwnerUserId`(191), `JobApplicationId`");
                    TryCreateIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId", "`JobApplicationId`");
                }
            }
            }

            // 1. Reconcile what already exists. This runs before Migrate() so legacy schemas are
            //    repaired before migrations inspect them.
            ReconcileSchema();

            // 2. Apply one migration at a time, reconciling after each. Some historical migrations
            //    rebuild tables using columns owned by the reconciler, so a fresh database needs
            //    those columns added after the base table appears and before a later migration reads it.
            try
            {
                using var migrationScope = app.Services.CreateScope();
                var migrationDb = migrationScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
                if (useSqliteBootstrap)
                {
                    var migrator = migrationDb.Database.GetService<IMigrator>();
                    while (migrationDb.Database.GetPendingMigrations().FirstOrDefault() is { } migration)
                    {
                        migrator.Migrate(migration);
                        ReconcileSchema();
                    }
                }
                else
                {
                    // MariaDB ALTER operations do not rebuild tables from a snapshot, so they do
                    // not need SQLite's per-migration reconciliation. Reconciling between its
                    // historical migrations can add a later column (for example Companies.Source)
                    // immediately before the migration that owns it, producing a duplicate-column
                    // failure on a clean database. Apply the chain first, then use the common final
                    // reconciliation pass for provider-safe legacy repairs.
                    migrationDb.Database.Migrate();
                }
            }
            catch (Exception ex)
            {
                app.Logger.LogError(ex, "Database migration failed during startup initialization.");
                throw;
            }

            // 3. Reconcile again, now that migration-owned tables exist, applying the index and
            //    AUTO_INCREMENT repairs pass 1 had to skip. Existing databases receive a no-op scan.
            ReconcileSchema();

            // Optional: seed an initial admin user for local username/password login.
            // Set Auth:AdminEmail and Auth:AdminPassword to enable.
            var adminEmail = (app.Configuration["Auth:AdminEmail"] ?? "").Trim();
            var adminPassword = (app.Configuration["Auth:AdminPassword"] ?? "").Trim();
            if (!string.IsNullOrWhiteSpace(adminEmail) && !string.IsNullOrWhiteSpace(adminPassword))
            {
                using var adminScope = app.Services.CreateScope();
                var adminDb = adminScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
                var adminUsers = adminScope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
                var adminRoles = adminScope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
                const string adminRole = "Admin";

                if (!adminRoles.RoleExistsAsync(adminRole).GetAwaiter().GetResult())
                {
                    adminRoles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult();
                }

                var existing = adminUsers.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
                if (existing is null)
                {
                    var u = new ApplicationUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true };
                    var created = adminUsers.CreateAsync(u, adminPassword).GetAwaiter().GetResult();
                    if (created.Succeeded)
                    {
                        adminUsers.AddToRoleAsync(u, adminRole).GetAwaiter().GetResult();
                        app.Logger.LogInformation("Seeded admin user: {Email}", adminEmail);
                    }
                    else
                    {
                        app.Logger.LogWarning("Failed to seed admin user: {Errors}", string.Join("; ", created.Errors.Select(e => e.Description)));
                    }
                }
                else
                {
                    var inRole = adminUsers.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult();
                    if (!inRole) adminUsers.AddToRoleAsync(existing, adminRole).GetAwaiter().GetResult();
                }

                // One-time claim of legacy data for the admin user so enabling auth doesn't "hide" existing records.
                var admin = adminUsers.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
                if (admin is not null)
                {
                    try
                    {
                        using var conn = adminDb.Database.GetDbConnection();
                        conn.Open();

                        static bool ColumnExists(DbConnection c, string providerName, string table, string column)
                        {
                            using var cmd = c.CreateCommand();
                            if (providerName is "mysql" or "mariadb")
                            {
                                var databaseName = c.Database;
                                cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";

                                var schemaParam = cmd.CreateParameter();
                                schemaParam.ParameterName = "@schema";
                                schemaParam.Value = databaseName;
                                cmd.Parameters.Add(schemaParam);

                                var tableParam = cmd.CreateParameter();
                                tableParam.ParameterName = "@table";
                                tableParam.Value = table;
                                cmd.Parameters.Add(tableParam);

                                var columnParam = cmd.CreateParameter();
                                columnParam.ParameterName = "@column";
                                columnParam.Value = column;
                                cmd.Parameters.Add(columnParam);
                            }
                            else
                            {
                                cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
                            }

                            return cmd.ExecuteScalar() is not null;
                        }

                        var companyOwnershipExists = ColumnExists(conn, provider, "Companies", "OwnerUserId");
                        var jobOwnershipExists = ColumnExists(conn, provider, "JobApplications", "OwnerUserId");
                        var opportunityOwnershipExists = ColumnExists(conn, provider, "Jobs", "OwnerUserId");

                        if (companyOwnershipExists || jobOwnershipExists || opportunityOwnershipExists)
                        {
                            if (companyOwnershipExists)
                            {
                                adminDb.Database.ExecuteSqlRaw("UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
                            }

                            if (jobOwnershipExists)
                            {
                                adminDb.Database.ExecuteSqlRaw("UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
                            }

                            if (opportunityOwnershipExists)
                            {
                                adminDb.Database.ExecuteSqlRaw("UPDATE Jobs SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
                            }
                        }
                    }
                    catch (Exception ex)
                    {
                        app.Logger.LogWarning(ex, "Skipping legacy ownership claim because the current schema does not support it yet.");
                    }
                }
            }
        }

        bool CoreSchemaReady(DbConnection connection, string providerName)
        {
            using var cmd = connection.CreateCommand();
            if (providerName is "mysql" or "mariadb")
            {
                cmd.CommandText = "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('JobApplications', 'RuleSettings');";
                return Convert.ToInt32(cmd.ExecuteScalar() ?? 0) == 2;
            }

            cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('JobApplications', 'RuleSettings');";
            return Convert.ToInt32(cmd.ExecuteScalar() ?? 0) == 2;
        }

        using (var scope = app.Services.CreateScope())
        {
            var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
            var runtimeProvider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
            using var conn = db.Database.GetDbConnection();
            conn.Open();
            if (!CoreSchemaReady(conn, runtimeProvider))
            {
                app.Logger.LogWarning("Core schema is incomplete after startup initialization. Background services will remain paused until required tables exist.");
                return Task.CompletedTask;
            }

            // On a brand-new database, the ad-hoc-column reconciliation above ran before
            // Migrate() created JobApplications/Correspondences, so every EnsureColumn call
            // no-opped. Now that CoreSchemaReady confirms the tables exist (created either just
            // now by Migrate(), or already, on a prior boot), re-run it -- idempotent, so this is
            // free on every boot except the very first one, where it's required.
            if (runtimeProvider is "mysql" or "mariadb")
            {
                ReconcileCoreAppColumnsMySql(conn);
            }
            else
            {
                ReconcileCoreAppColumns(conn);
            }
        }

        using (var scope = app.Services.CreateScope())
        {
            var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
            var backfilled = JobOpportunitySync.BackfillLegacyAsync(db).GetAwaiter().GetResult();
            if (backfilled > 0)
                app.Logger.LogInformation("Backfilled {Count} legacy job opportunities.", backfilled);
        }

        var readiness = app.Services.GetRequiredService<IStartupReadiness>();
        readiness.MarkReady();

        return Task.CompletedTask;
    }
}
