using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;

namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;

/// <summary>
///     One-time copy of the pre-consolidation <c>alarm-historian.db</c> store-and-forward queue
///     into the consolidated <c>ZB.MOM.WW.LocalDb</c> database.
/// </summary>
/// <remarks>
///     <para>
///         <b>What is at stake.</b> Rows sit in that file precisely because the historian could not
///         be reached. Discarding them on upgrade would throw away exactly the alarm audit trail
///         the store-and-forward queue exists to protect — so the copy tolerates an older column
///         set rather than bailing out, and refuses to rename a file it did not actually read.
///     </para>
///     <para>
///         <b>Runs after <c>RegisterReplicated</c>, deliberately.</b> Capture is trigger-based, so
///         rows inserted before registration never enter the oplog and never reach the peer —
///         silently, and permanently, because nothing revisits history. Migrating after
///         registration means the recovered backlog replicates like any other write.
///     </para>
///     <para>
///         <b>Ids come from the payload, not from the legacy row id.</b> The legacy primary key was
///         <c>RowId INTEGER PRIMARY KEY AUTOINCREMENT</c>, which cannot survive into a replicated
///         table: each node allocated its own sequence, so node A's row 7 and node B's row 7 are
///         different alarms that would silently overwrite one another under last-writer-wins.
///         Hashing the payload (<see cref="AlarmSfSchema.DeriveId"/>) solves that and one more
///         problem besides — a warm pair's two legacy files <i>overlap</i>, because
///         <c>HistorianAdapterActor</c> default-writes while its redundancy role is unknown and
///         both nodes therefore accepted the same transitions during every boot window. A
///         node-prefixed id would carry that duplication into the merged buffer forever; an
///         equal-payload id collapses it. It is also what makes a re-run harmless under
///         <c>INSERT OR IGNORE</c>.
///     </para>
///     <para>
///         <b>All-or-nothing.</b> The copy runs in one transaction and the legacy file is renamed
///         to <c>&lt;name&gt;.migrated</c> only after the commit. A failure throws out of
///         <c>OnReady</c>, failing host startup with the legacy file untouched — no half-migrated
///         state to reason about, and the operator still holds the original. A later boot sees the
///         renamed file and no-ops.
///     </para>
/// </remarks>
public static class AlarmSfLegacyMigrator
{
    private const string MigratedSuffix = ".migrated";

    /// <summary>The legacy queue table.</summary>
    private const string LegacyTable = "Queue";

    /// <summary>
    ///     The configuration key that used to carry the standalone queue file's path. Read as a raw
    ///     key because the corresponding <c>AlarmHistorianOptions</c> property was removed with the
    ///     bespoke file management it configured.
    /// </summary>
    public const string LegacyPathKey = "AlarmHistorian:DatabasePath";

    /// <summary>The pre-removal default for <see cref="LegacyPathKey"/>.</summary>
    private const string DefaultLegacyPath = "alarm-historian.db";

    /// <summary>
    ///     The legacy column whose absence means this is not a queue file we can read. Without the
    ///     payload there is no event and no id to derive from one.
    /// </summary>
    private const string RequiredColumn = "PayloadJson";

    /// <summary>
    ///     Every legacy column, mapped to its consolidated counterpart. Columns absent from an
    ///     older file are dropped from the copy rather than failing it — see
    ///     <see cref="PresentColumns"/>.
    /// </summary>
    private static readonly (string Legacy, string Current)[] ColumnMap =
    [
        ("AlarmId", "alarm_id"),
        ("EnqueuedUtc", "enqueued_at_utc"),
        ("PayloadJson", "payload_json"),
        ("AttemptCount", "attempt_count"),
        ("LastAttemptUtc", "last_attempt_utc"),
        ("LastError", "last_error"),
        ("DeadLettered", "dead_lettered"),
    ];

    /// <summary>
    ///     Copies any legacy alarm queue into <paramref name="db"/>, then renames the legacy file.
    /// </summary>
    /// <param name="db">The consolidated database, with <c>alarm_sf_events</c> already registered.</param>
    /// <param name="config">Configuration supplying the legacy queue's path.</param>
    public static void Migrate(ILocalDb db, IConfiguration config)
    {
        ArgumentNullException.ThrowIfNull(db);
        ArgumentNullException.ThrowIfNull(config);

        var legacyPath = ResolveLegacyPath(config);
        if (!ShouldMigrate(legacyPath)) return;

        using (var legacy = OpenLegacyReadOnly(legacyPath))
        {
            var present = PresentColumns(legacy);

            // An absent table probes as zero columns, so this one guard covers both "no queue
            // table" and "a shape we do not recognise". Returning without renaming leaves the file
            // for an operator to inspect.
            if (!present.Contains(RequiredColumn)) return;

            using var connection = db.CreateConnection();
            using var transaction = connection.BeginTransaction();
            CopyRows(legacy, connection, transaction, present);
            transaction.Commit();
        }

        // Only after the commit. A crash between the two leaves the file in place and the next
        // boot copies it again, which the payload-derived ids make harmless.
        File.Move(legacyPath, legacyPath + MigratedSuffix, overwrite: true);
    }

    /// <summary>
    ///     Resolves the legacy queue path from the removed configuration key, falling back to the
    ///     code default it used to carry.
    /// </summary>
    /// <remarks>
    ///     Relative paths resolve against the process working directory — not the LocalDb
    ///     directory — because that is where the old code actually put them.
    /// </remarks>
    /// <param name="config">The application configuration.</param>
    /// <returns>An absolute path, or empty when there is nothing durable to migrate from.</returns>
    internal static string ResolveLegacyPath(IConfiguration config)
    {
        var path = config[LegacyPathKey] ?? DefaultLegacyPath;

        // In-memory and URI-form sources (test / dev configurations) have nothing durable behind
        // them, and Path.GetFullPath on them would produce nonsense.
        if (string.IsNullOrWhiteSpace(path) ||
            path.Equals(":memory:", StringComparison.OrdinalIgnoreCase) ||
            path.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
        {
            return string.Empty;
        }

        return Path.GetFullPath(path);
    }

    /// <summary>Whether there is a legacy file worth opening.</summary>
    private static bool ShouldMigrate(string legacyPath) =>
        !string.IsNullOrEmpty(legacyPath)
        && File.Exists(legacyPath)
        && !File.Exists(legacyPath + MigratedSuffix);

    /// <summary>Opens the legacy file read-only, so a failed migration cannot damage it.</summary>
    private static SqliteConnection OpenLegacyReadOnly(string path)
    {
        var connection = new SqliteConnection(new SqliteConnectionStringBuilder
        {
            DataSource = path,
            Mode = SqliteOpenMode.ReadOnly,
        }.ToString());
        connection.Open();
        return connection;
    }

    /// <summary>
    ///     Which of the columns we know about the legacy table actually has.
    /// </summary>
    /// <remarks>
    ///     A file written by an older build predates some columns, and naming a missing one in the
    ///     SELECT throws "no such column" — which discards every row in the table rather than the
    ///     one field. Intersecting first means an old file migrates its data and simply leaves the
    ///     newer columns at their schema defaults.
    /// </remarks>
    private static HashSet<string> PresentColumns(SqliteConnection legacy)
    {
        var present = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        using var cmd = legacy.CreateCommand();
        cmd.CommandText = $"SELECT name FROM pragma_table_info('{LegacyTable}')";
        using var reader = cmd.ExecuteReader();
        while (reader.Read())
            present.Add(reader.GetString(0));

        return present;
    }

    /// <summary>Copies every legacy row into the consolidated table, keyed by payload hash.</summary>
    private static void CopyRows(
        SqliteConnection legacy,
        SqliteConnection target,
        SqliteTransaction transaction,
        HashSet<string> present)
    {
        var columns = ColumnMap.Where(c => present.Contains(c.Legacy)).ToArray();
        var payloadOrdinal = Array.FindIndex(columns, c => c.Legacy == RequiredColumn);

        using var read = legacy.CreateCommand();
        read.CommandText =
            $"SELECT {string.Join(", ", columns.Select(c => c.Legacy))} FROM {LegacyTable}";

        using var reader = read.ExecuteReader();
        while (reader.Read())
        {
            using var insert = target.CreateCommand();
            insert.Transaction = transaction;
            insert.CommandText = $"""
                INSERT OR IGNORE INTO {AlarmSfSchema.EventsTable}
                    ({AlarmSfSchema.IdColumn}, {string.Join(", ", columns.Select(c => c.Current))})
                VALUES
                    ($id, {string.Join(", ", columns.Select((_, i) => $"$c{i}"))})
                """;

            insert.Parameters.AddWithValue("$id", AlarmSfSchema.DeriveId(reader.GetString(payloadOrdinal)));
            for (var i = 0; i < columns.Length; i++)
                insert.Parameters.AddWithValue($"$c{i}", reader.GetValue(i) ?? DBNull.Value);

            insert.ExecuteNonQuery();
        }
    }
}
