using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;

/// <summary>
/// EF Core implementation of <see cref="IDeploymentManagerRepository"/> covering
/// the deployment pipeline's persistence surface: <c>DeploymentRecord</c> CRUD
/// (with optimistic concurrency via <c>DeploymentRecord.RowVersion</c>),
/// <c>SystemArtifactDeploymentRecord</c> CRUD, <c>DeployedConfigSnapshot</c> CRUD,
/// and a Restrict-FK-aware <see cref="DeleteInstanceAsync"/> that explicitly
/// clears dependent deployment-record rows before removing an instance.
/// </summary>
public class DeploymentManagerRepository : IDeploymentManagerRepository
{
    private readonly ScadaBridgeDbContext _dbContext;

    /// <summary>
    /// Initializes a new instance of the DeploymentManagerRepository class.
    /// </summary>
    /// <param name="dbContext">The database context for accessing deployment data.</param>
    public DeploymentManagerRepository(ScadaBridgeDbContext dbContext)
    {
        _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
    }

    // --- DeploymentRecord ---

    /// <inheritdoc />
    public async Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default)
    {
        return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken);
    }

    /// <summary>
    /// Shared filtered join behind <see cref="QueryDeploymentListPageAsync"/> and
    /// <see cref="GetDeploymentStatusCountsAsync"/>, so the two reads that back one
    /// screen can never disagree about which rows they describe.
    ///
    /// <para>
    /// The join to <c>Instance</c> is an INNER join, which is exact rather than
    /// merely convenient: <c>DeploymentRecord</c> has a <c>Restrict</c> FK to
    /// <c>Instance</c> and <see cref="DeleteInstanceAsync"/> removes the records
    /// before the instance, so an orphaned deployment record cannot exist. The join
    /// is what lets site scoping run in SQL — <c>DeploymentRecord</c> itself has no
    /// <c>SiteId</c>.
    /// </para>
    /// </summary>
    /// <param name="filter">Filter to apply.</param>
    /// <param name="applyStatus">
    /// When <see langword="false"/>, the status constraint is skipped — the
    /// status-count aggregation needs every status of the otherwise-identical set.
    /// </param>
    /// <returns>The composed, unordered, untracked query.</returns>
    private IQueryable<DeploymentInstanceJoin> BuildListQuery(DeploymentListFilter filter, bool applyStatus)
    {
        ArgumentNullException.ThrowIfNull(filter);

        var query =
            from d in _dbContext.DeploymentRecords.AsNoTracking()
            join i in _dbContext.Set<Instance>().AsNoTracking() on d.InstanceId equals i.Id
            select new DeploymentInstanceJoin
            {
                Id = d.Id,
                DeploymentId = d.DeploymentId,
                InstanceId = d.InstanceId,
                InstanceUniqueName = i.UniqueName,
                SiteId = i.SiteId,
                Status = d.Status,
                RevisionHash = d.RevisionHash,
                DeployedBy = d.DeployedBy,
                DeployedAt = d.DeployedAt,
                CompletedAt = d.CompletedAt,
                ErrorMessage = d.ErrorMessage
            };

        if (filter.InstanceId.HasValue)
            query = query.Where(r => r.InstanceId == filter.InstanceId.Value);

        if (applyStatus && filter.Status.HasValue)
            query = query.Where(r => r.Status == filter.Status.Value);

        if (filter.SiteIdScope != null)
        {
            // An EMPTY scope is a real filter — a user permitted no sites sees
            // nothing — so it must not be short-circuited into "no filter".
            var scope = filter.SiteIdScope as int[] ?? filter.SiteIdScope.ToArray();
            query = query.Where(r => scope.Contains(r.SiteId));
        }

        if (filter.NormalizedSearch is { } search)
        {
            // Substring match across the four identifiers a deployment is findable
            // by. Translated to LIKE '%term%' by both providers; case-insensitivity
            // is the database collation's (SQL Server default CI, SQLite ASCII LIKE).
            query = query.Where(r =>
                r.InstanceUniqueName.Contains(search)
                || r.DeploymentId.Contains(search)
                || r.DeployedBy.Contains(search)
                || (r.RevisionHash != null && r.RevisionHash.Contains(search)));
        }

        return query;
    }

    /// <inheritdoc />
    public async Task<DeploymentListPage> QueryDeploymentListPageAsync(
        DeploymentListFilter filter,
        int pageNumber,
        int pageSize,
        CancellationToken cancellationToken = default)
    {
        var page = Math.Max(1, pageNumber);
        var size = Math.Clamp(pageSize, 1, DeploymentRecordSummary.MaxPageSize);

        var query = BuildListQuery(filter, applyStatus: true);

        // Count first: the pager needs a page count, and the caller must be able to
        // tell "page 7 is now past the end" (a concurrent purge or a narrowed filter)
        // from "page 7 is empty".
        var totalCount = await query.CountAsync(cancellationToken);

        // DeployedAt ties on rapid redeploys; without the Id tie-break the sort key
        // is unstable and offset paging repeats or drops rows between pages.
        var rows = await query
            .OrderByDescending(r => r.DeployedAt)
            .ThenByDescending(r => r.Id)
            .Skip((page - 1) * size)
            .Take(size)
            .Select(r => new DeploymentListRow(
                r.Id,
                r.DeploymentId,
                r.InstanceId,
                r.InstanceUniqueName,
                r.SiteId,
                r.Status,
                r.RevisionHash,
                r.DeployedBy,
                r.DeployedAt,
                r.CompletedAt,
                r.ErrorMessage))
            .ToListAsync(cancellationToken);

        return new DeploymentListPage(rows, totalCount);
    }

    /// <inheritdoc />
    public async Task<DeploymentStatusCounts> GetDeploymentStatusCountsAsync(
        DeploymentListFilter filter,
        CancellationToken cancellationToken = default)
    {
        // applyStatus: false — the tiles are the status breakdown of the filtered
        // set, so the active status filter is the one dimension they must NOT honour.
        var grouped = await BuildListQuery(filter, applyStatus: false)
            .GroupBy(r => r.Status)
            .Select(g => new { Status = g.Key, Count = g.Count() })
            .ToListAsync(cancellationToken);

        return DeploymentStatusCounts.FromGrouped(grouped.Select(g => (g.Status, g.Count)));
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<DeploymentRecord>> GetDeploymentsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
    {
        return await _dbContext.DeploymentRecords
            .Where(d => d.InstanceId == instanceId)
            .OrderByDescending(d => d.DeployedAt)
            .ToListAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<DeploymentRecord?> GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default)
    {
        // Deployments are insert-only (one row per deploy
        // attempt), so two records for the same instance can tie on DeployedAt when
        // they are created within the same clock tick (a rapid redeploy, or a
        // redeploy immediately after a timed-out attempt). SQL Server's choice
        // between equal sort keys is undefined, so reconciliation could read the
        // wrong "current" record. ThenByDescending(d => d.Id) makes the read
        // deterministic — the highest Id (the most recently inserted row) wins.
        return await _dbContext.DeploymentRecords
            .Where(d => d.InstanceId == instanceId)
            .OrderByDescending(d => d.DeployedAt)
            .ThenByDescending(d => d.Id)
            .FirstOrDefaultAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<DeploymentRecord?> GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default)
    {
        return await _dbContext.DeploymentRecords
            .FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, cancellationToken);
    }

    /// <inheritdoc />
    public async Task AddDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
    {
        await _dbContext.DeploymentRecords.AddAsync(record, cancellationToken);
    }

    /// <inheritdoc />
    public Task UpdateDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
    {
        _dbContext.DeploymentRecords.Update(record);
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public Task DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(expectedRowVersion);

        // DeploymentRecord carries a SQL Server rowversion concurrency token.
        // The stub-attach delete path must seed EF's OriginalValues["RowVersion"] with
        // the caller's last-observed value so the generated SQL becomes
        // `DELETE ... WHERE Id = @id AND RowVersion = @prior`. Without this seeding a
        // concurrent edit is silently overwritten; with it, EF raises
        // DbUpdateConcurrencyException on SaveChangesAsync — the documented
        // optimistic-concurrency contract on deployment status records.
        var record = _dbContext.DeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
        if (record != null)
        {
            var entry = _dbContext.Entry(record);
            entry.OriginalValues["RowVersion"] = expectedRowVersion;
            _dbContext.DeploymentRecords.Remove(record);
        }
        else
        {
            var stub = new DeploymentRecord("stub", "stub") { Id = id };
            _dbContext.DeploymentRecords.Attach(stub);
            var entry = _dbContext.Entry(stub);
            entry.OriginalValues["RowVersion"] = expectedRowVersion;
            _dbContext.DeploymentRecords.Remove(stub);
        }
        return Task.CompletedTask;
    }

    // --- SystemArtifactDeploymentRecord ---

    /// <inheritdoc />
    public async Task<SystemArtifactDeploymentRecord?> GetSystemArtifactDeploymentByIdAsync(int id, CancellationToken cancellationToken = default)
    {
        return await _dbContext.SystemArtifactDeploymentRecords.FindAsync([id], cancellationToken);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<SystemArtifactDeploymentRecord>> GetAllSystemArtifactDeploymentsAsync(CancellationToken cancellationToken = default)
    {
        return await _dbContext.SystemArtifactDeploymentRecords
            .OrderByDescending(d => d.DeployedAt)
            .ToListAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task AddSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
    {
        await _dbContext.SystemArtifactDeploymentRecords.AddAsync(record, cancellationToken);
    }

    /// <inheritdoc />
    public Task UpdateSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
    {
        _dbContext.SystemArtifactDeploymentRecords.Update(record);
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public Task DeleteSystemArtifactDeploymentAsync(int id, CancellationToken cancellationToken = default)
    {
        var record = _dbContext.SystemArtifactDeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
        if (record != null)
        {
            _dbContext.SystemArtifactDeploymentRecords.Remove(record);
        }
        else
        {
            var stub = new SystemArtifactDeploymentRecord("stub", "stub") { Id = id };
            _dbContext.SystemArtifactDeploymentRecords.Attach(stub);
            _dbContext.SystemArtifactDeploymentRecords.Remove(stub);
        }
        return Task.CompletedTask;
    }

    // --- DeployedConfigSnapshot ---

    /// <inheritdoc />
    public async Task<DeployedConfigSnapshot?> GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
    {
        return await _dbContext.Set<DeployedConfigSnapshot>()
            .FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
    }

    /// <inheritdoc />
    public async Task AddDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
    {
        await _dbContext.Set<DeployedConfigSnapshot>().AddAsync(snapshot, cancellationToken);
    }

    /// <inheritdoc />
    public Task UpdateDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
    {
        _dbContext.Set<DeployedConfigSnapshot>().Update(snapshot);
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public async Task DeleteDeployedSnapshotAsync(int instanceId, CancellationToken cancellationToken = default)
    {
        var snapshot = await _dbContext.Set<DeployedConfigSnapshot>()
            .FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
        if (snapshot != null)
        {
            _dbContext.Set<DeployedConfigSnapshot>().Remove(snapshot);
        }
    }

    // --- Notify-and-fetch: PendingDeployment staging store ---

    /// <inheritdoc />
    public async Task AddPendingDeploymentAsync(PendingDeployment pending, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(pending);

        // Supersession: a newer deploy for the same instance replaces any prior pending
        // row. Safe because the per-instance operation lock serializes same-instance
        // deploys. SaveChanges is deferred to the caller, matching the other Add methods.
        var prior = await _dbContext.Set<PendingDeployment>()
            .Where(p => p.InstanceId == pending.InstanceId)
            .ToListAsync(cancellationToken);
        if (prior.Count > 0)
        {
            _dbContext.Set<PendingDeployment>().RemoveRange(prior);
        }
        await _dbContext.Set<PendingDeployment>().AddAsync(pending, cancellationToken);
    }

    /// <inheritdoc />
    public Task<PendingDeployment?> GetPendingDeploymentByIdAsync(string deploymentId, CancellationToken cancellationToken = default)
    {
        return _dbContext.Set<PendingDeployment>()
            .FirstOrDefaultAsync(p => p.DeploymentId == deploymentId, cancellationToken);
    }

    /// <inheritdoc />
    public Task<PendingDeployment?> GetPendingDeploymentByInstanceIdAsync(int instanceId, DateTimeOffset? nowUtc = null, CancellationToken cancellationToken = default)
    {
        // At most one pending row per instance by design (supersession + stage-if-absent),
        // but order deterministically and take the most recent so a hypothetical duplicate
        // never makes the read non-deterministic — mirrors GetCurrentDeploymentStatusAsync.
        var query = _dbContext.Set<PendingDeployment>()
            .Where(p => p.InstanceId == instanceId);

        // Expiry-aware: never hand back an EXPIRED row. Pending rows are only TTL-purged, so an
        // expired-but-unpurged row would otherwise return a token the config-fetch endpoint 404s
        // (it correctly rejects expired rows), leaving the node unhealed.
        if (nowUtc.HasValue)
        {
            var now = nowUtc.Value;
            query = query.Where(p => p.ExpiresAtUtc > now);
        }

        return query
            .OrderByDescending(p => p.CreatedAtUtc)
            .ThenByDescending(p => p.Id)
            .FirstOrDefaultAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task DeletePendingDeploymentByIdAsync(string deploymentId, CancellationToken cancellationToken = default)
    {
        var row = await _dbContext.Set<PendingDeployment>()
            .FirstOrDefaultAsync(p => p.DeploymentId == deploymentId, cancellationToken);
        if (row != null)
        {
            _dbContext.Set<PendingDeployment>().Remove(row);
        }
    }

    /// <inheritdoc />
    public async Task<int> PurgeExpiredPendingDeploymentsAsync(DateTimeOffset nowUtc, CancellationToken cancellationToken = default)
    {
        // Self-contained TTL maintenance op: commits its own delete and returns the count.
        var expired = await _dbContext.Set<PendingDeployment>()
            .Where(p => p.ExpiresAtUtc <= nowUtc)
            .ToListAsync(cancellationToken);
        if (expired.Count == 0)
        {
            return 0;
        }
        _dbContext.Set<PendingDeployment>().RemoveRange(expired);
        await _dbContext.SaveChangesAsync(cancellationToken);
        return expired.Count;
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
        int? instanceId,
        DeploymentStatus? status,
        IReadOnlyCollection<int>? instanceIdScope,
        int skip,
        int? take,
        CancellationToken cancellationToken = default)
    {
        var query = _dbContext.DeploymentRecords.AsNoTracking();

        if (instanceId.HasValue)
            query = query.Where(d => d.InstanceId == instanceId.Value);

        if (status.HasValue)
            query = query.Where(d => d.Status == status.Value);

        if (instanceIdScope != null)
        {
            // Site scoping applied DB-side. An empty scope is a real, meaningful
            // filter — a user permitted no in-scope instances sees nothing — so it
            // must not be short-circuited into "no filter".
            var scope = instanceIdScope as int[] ?? instanceIdScope.ToArray();
            query = query.Where(d => scope.Contains(d.InstanceId));
        }

        // ThenByDescending(Id) for the same reason GetCurrentDeploymentStatusAsync
        // does it: DeployedAt ties on rapid redeploys, and an unstable sort key
        // makes Skip/Take paging non-deterministic (rows repeat or vanish between
        // pages).
        var paged = query
            .OrderByDescending(d => d.DeployedAt)
            .ThenByDescending(d => d.Id)
            .Skip(Math.Max(0, skip));

        if (take is > 0)
            paged = paged.Take(Math.Min(take.Value, DeploymentRecordSummary.MaxPageSize));

        return await paged
            .Select(d => new DeploymentRecordSummary(
                d.Id,
                d.DeploymentId,
                d.InstanceId,
                d.Status,
                d.RevisionHash,
                d.DeployedBy,
                d.DeployedAt,
                d.CompletedAt,
                d.ErrorMessage))
            .ToListAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<int> PurgeTerminalDeploymentRecordsAsync(
        DateTimeOffset cutoffUtc,
        int batchSize,
        CancellationToken cancellationToken = default)
    {
        // Terminal statuses ONLY. An InProgress (or Pending) row is never purged
        // regardless of age: it is precisely the row TryReconcileWithSiteAsync
        // looks for when deciding whether a prior deploy actually landed, and
        // deleting it would silently disable the query-before-redeploy idempotency
        // guard for that instance.
        //
        // Bounded batch rather than one unbounded DELETE so the first purge on a
        // long-lived system cannot take a table-scale lock on DeploymentRecords.
        // The caller re-invokes until it returns fewer rows than the batch size.
        var stale = await _dbContext.DeploymentRecords
            .Where(d => d.CompletedAt != null
                        && d.CompletedAt < cutoffUtc
                        && (d.Status == DeploymentStatus.Success || d.Status == DeploymentStatus.Failed))
            .OrderBy(d => d.Id)
            .Take(batchSize)
            .ToListAsync(cancellationToken);

        if (stale.Count == 0)
            return 0;

        _dbContext.DeploymentRecords.RemoveRange(stale);
        await _dbContext.SaveChangesAsync(cancellationToken);
        return stale.Count;
    }

    // --- Startup reconciliation ---

    /// <inheritdoc />
    public async Task<IReadOnlyList<ExpectedDeployment>> GetExpectedDeploymentsForSiteAsync(int siteId, CancellationToken cancellationToken = default)
    {
        // Inner join: only instances that have a DeployedConfigSnapshot are returned.
        // ConfigurationJson is intentionally excluded — the caller fetches the full config
        // on demand via the HTTP endpoint for gap instances only.
        return await (
            from snap in _dbContext.Set<DeployedConfigSnapshot>()
            join inst in _dbContext.Set<Instance>() on snap.InstanceId equals inst.Id
            where inst.SiteId == siteId
            select new ExpectedDeployment(
                inst.Id,
                inst.UniqueName,
                snap.RevisionHash,
                snap.DeploymentId,
                inst.State == InstanceState.Enabled))
            .ToListAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<bool> StagePendingIfAbsentAsync(
        int instanceId, string deploymentId, string revisionHash,
        string configurationJson, string token,
        DateTimeOffset createdAtUtc, DateTimeOffset expiresAtUtc,
        CancellationToken cancellationToken = default)
    {
        // Treat createdAtUtc as "now". FIRST remove any EXPIRED pending rows for this instance
        // (ExpiresAtUtc <= now). Pending rows are only TTL-purged (the periodic purge is still a
        // deferred TODO), so an EXPIRED-but-unpurged row would otherwise (a) read as "a deploy is
        // in flight" and block staging — handing the node an expired token (HTTP 404) and leaving
        // it unhealed — and (b) collide on the DeploymentId UNIQUE index when a reconcile re-stages
        // the snapshot's own DeploymentId. Dropping expired rows first fixes both.
        var expired = await _dbContext.Set<PendingDeployment>()
            .Where(p => p.InstanceId == instanceId && p.ExpiresAtUtc <= createdAtUtc)
            .ToListAsync(cancellationToken);
        if (expired.Count > 0)
        {
            _dbContext.Set<PendingDeployment>().RemoveRange(expired);
        }

        // THEN insert-if-absent against still-LIVE rows only. A live pending row means a genuine
        // in-flight deploy (or a concurrent reconcile) already owns the slot — do NOT supersede it;
        // clobbering it could make the node fetch the reconcile token while the original deliver is
        // mid-flight. (Expired rows just removed are disjoint from this future-expiry predicate.)
        var liveExists = await _dbContext.Set<PendingDeployment>()
            .AnyAsync(p => p.InstanceId == instanceId && p.ExpiresAtUtc > createdAtUtc, cancellationToken);
        if (!liveExists)
        {
            var pending = new PendingDeployment(
                deploymentId, instanceId, revisionHash,
                configurationJson, token, createdAtUtc, expiresAtUtc);
            await _dbContext.Set<PendingDeployment>().AddAsync(pending, cancellationToken);
        }

        // Self-contained: one SaveChanges flushes the expired-row cleanup and, when staged, the new
        // row together (EF orders the delete before the same-DeploymentId insert to satisfy the
        // unique index). Returns true only when a fresh row was staged.
        await _dbContext.SaveChangesAsync(cancellationToken);
        return !liveExists;
    }

    // --- Instance lookups for deployment pipeline ---

    /// <inheritdoc />
    public async Task<Instance?> GetInstanceByIdAsync(int instanceId, CancellationToken cancellationToken = default)
    {
        return await _dbContext.Set<Instance>()
            .Include(i => i.AttributeOverrides)
            .Include(i => i.AlarmOverrides)
            .Include(i => i.ConnectionBindings)
            .Include(i => i.NativeAlarmSourceOverrides)
            .AsSplitQuery()
            .FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
    }

    /// <inheritdoc />
    public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
    {
        return await _dbContext.Set<Instance>()
            .Include(i => i.AttributeOverrides)
            .Include(i => i.AlarmOverrides)
            .Include(i => i.ConnectionBindings)
            .Include(i => i.NativeAlarmSourceOverrides)
            .AsSplitQuery()
            .FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
    }

    /// <inheritdoc />
    public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
    {
        _dbContext.Set<Instance>().Update(instance);
        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public async Task DeleteInstanceAsync(int instanceId, CancellationToken cancellationToken = default)
    {
        // DeploymentRecords have a Restrict FK to Instance — remove them
        // explicitly first. The snapshot, overrides, and connection bindings
        // are configured with cascade delete and go with the instance.
        var records = await _dbContext.DeploymentRecords
            .Where(d => d.InstanceId == instanceId)
            .ToListAsync(cancellationToken);
        if (records.Count > 0)
        {
            _dbContext.DeploymentRecords.RemoveRange(records);
        }

        var instance = await _dbContext.Set<Instance>()
            .FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
        if (instance != null)
        {
            _dbContext.Set<Instance>().Remove(instance);
        }
    }

    /// <inheritdoc />
    public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        return await _dbContext.SaveChangesAsync(cancellationToken);
    }

    /// <summary>
    /// Flat shape of the deployment-record ⋈ instance join used to compose the list
    /// query. A named type rather than an anonymous one so
    /// <see cref="BuildListQuery"/> can hand the composed-but-unexecuted query back
    /// to both the paging read and the count aggregation, keeping the two in
    /// lockstep. Not a public contract — <see cref="DeploymentListRow"/> is what
    /// leaves this class.
    /// </summary>
    private sealed class DeploymentInstanceJoin
    {
        public int Id { get; init; }
        public string DeploymentId { get; init; } = string.Empty;
        public int InstanceId { get; init; }
        public string InstanceUniqueName { get; init; } = string.Empty;
        public int SiteId { get; init; }
        public DeploymentStatus Status { get; init; }
        public string? RevisionHash { get; init; }
        public string DeployedBy { get; init; } = string.Empty;
        public DateTimeOffset DeployedAt { get; init; }
        public DateTimeOffset? CompletedAt { get; init; }
        public string? ErrorMessage { get; init; }
    }
}
