using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace ClaudeDo.Data;

// Allocates TaskEntity.Number and inserts the task. Never MAX(number)+1 -- that would reissue a
// deleted task's number. Allocation is a single atomic UPDATE...RETURNING against the
// app_settings singleton row, which SQLite's single-writer model already serializes.
//
// The allocation and the insert are deliberately two separate statements, each committed on its
// own, rather than one shared transaction: a shared transaction would roll the counter bump back
// together with a failed insert, so a retry would hand out the exact same (still colliding)
// number every time and the bounded retry below would be pointless. Committing the bump
// unconditionally means a retry after a collision always advances to a fresh number, at the cost
// of burning the collided number as an unused gap -- which is fine, gaps are expected.
public static class TaskNumberAllocator
{
    private const int MaxAttempts = 5;

    public static async Task AddWithNumberAsync(
        ClaudeDoDbContext context, TaskEntity entity, CancellationToken ct = default)
    {
        for (var attempt = 1; ; attempt++)
        {
            var settings = await context.AppSettings.FromSqlRaw(
                """
                UPDATE app_settings SET next_task_number = next_task_number + 1
                WHERE id = {0}
                RETURNING *
                """, AppSettingsEntity.SingletonId).AsNoTracking().ToListAsync(ct);

            if (settings.Count == 0)
            {
                // The singleton row is missing (e.g. a corrupted/hand-edited DB) -- recreate it,
                // as its own separate insert/commit, and retry the UPDATE above rather than
                // indexing into an empty result. Doesn't count against MaxAttempts: it's not a
                // number collision.
                await EnsureSingletonRowAsync(context, ct);
                continue;
            }

            entity.Number = settings[0].NextTaskNumber - 1;
            context.Tasks.Add(entity);

            try
            {
                await context.SaveChangesAsync(ct);
                return;
            }
            catch (DbUpdateException ex)
            {
                context.Entry(entity).State = EntityState.Detached;
                if (attempt < MaxAttempts && IsNumberCollision(ex))
                    continue;
                throw;
            }
        }
    }

    private static async Task EnsureSingletonRowAsync(ClaudeDoDbContext context, CancellationToken ct)
    {
        var row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
        context.AppSettings.Add(row);
        try
        {
            await context.SaveChangesAsync(ct);
        }
        catch (DbUpdateException)
        {
            // A concurrent process already inserted the singleton -- fine, the next UPDATE
            // attempt above will find it.
        }
        finally
        {
            context.Entry(row).State = EntityState.Detached;
        }
    }

    // Distinguishes the expected unique-index collision on idx_tasks_number (worth retrying with
    // a fresh number) from any other DbUpdateException -- an FK violation, a NOT NULL violation,
    // etc. -- which must surface immediately instead of silently burning up to MaxAttempts numbers
    // on a failure a retry can never fix.
    private static bool IsNumberCollision(DbUpdateException ex) =>
        ex.InnerException is SqliteException { SqliteErrorCode: 19 } sqliteEx
        && sqliteEx.Message.Contains("tasks.number", StringComparison.Ordinal);
}
