using ZB.MOM.WW.Secrets.Abstractions;

namespace ZB.MOM.WW.Secrets.Cli.Interactive;

/// <summary>
/// Tally of an <see cref="BundleService.ImportAsync"/> run.
/// </summary>
/// <param name="Imported">Rows written (new inserts plus conflict winners).</param>
/// <param name="SkippedOlder">Rows a last-writer-wins (or override) decision rejected as not newer.</param>
/// <param name="SkippedForeignKek">
/// Rows wrapped under a KEK other than the target's, with no matching source KEK supplied to re-wrap them.
/// </param>
/// <param name="Conflicts">Rows whose name already existed in the target store (won or lost).</param>
public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts);

/// <summary>
/// Exports and imports <see cref="SecretBundle"/> documents — ciphertext-only movement of secret rows
/// between deployment stores. Import reconciles per row with the shared last-writer-wins ordering (an
/// optional per-row override can force a decision) and, when a row is wrapped under a foreign KEK,
/// re-wraps it under the target's KEK given the source KEK — otherwise it is skipped and reported.
/// Both operations require a <b>full</b> (non-degraded) session: import needs the target cipher to
/// re-wrap, and export is a data-movement operation that must not run half-configured.
/// </summary>
public sealed class BundleService
{
    private readonly TimeProvider _timeProvider;

    /// <summary>Creates the service.</summary>
    /// <param name="timeProvider">Clock used to stamp <see cref="SecretBundle.ExportedUtc"/>; defaults to <see cref="TimeProvider.System"/>.</param>
    public BundleService(TimeProvider? timeProvider = null) =>
        _timeProvider = timeProvider ?? TimeProvider.System;

    /// <summary>
    /// Exports every secret in <paramref name="session"/>'s store to a ciphertext-only bundle at
    /// <paramref name="path"/>, written atomically (temp file then move). Tombstones are excluded unless
    /// <paramref name="includeDeleted"/> is set.
    /// </summary>
    /// <param name="session">The full session to export from.</param>
    /// <param name="path">Destination bundle path.</param>
    /// <param name="includeDeleted">When <see langword="true"/>, tombstoned rows are exported too.</param>
    /// <param name="ct">A token to cancel the operation.</param>
    /// <returns>The number of rows written to the bundle.</returns>
    /// <exception cref="InvalidOperationException">The session is degraded (no KEK).</exception>
    public async Task<int> ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(session);
        ArgumentException.ThrowIfNullOrWhiteSpace(path);

        IMasterKeyProvider masterKey = session.MasterKey
            ?? throw new InvalidOperationException(
                "Cannot export a bundle from a degraded session (no KEK available).");

        IReadOnlyList<SecretMetadata> metadata =
            await session.Store.ListAsync(includeDeleted, ct).ConfigureAwait(false);

        var entries = new List<BundleEntry>(metadata.Count);
        foreach (SecretMetadata meta in metadata)
        {
            StoredSecret? row = await session.Store.GetAsync(meta.Name, ct).ConfigureAwait(false);
            if (row is null)
            {
                continue;
            }

            entries.Add(SecretBundleCodec.FromRow(row));
        }

        var bundle = new SecretBundle
        {
            ExportedUtc = _timeProvider.GetUtcNow(),
            SourceKekId = masterKey.KekId,
            Entries = entries,
        };

        await WriteAtomicAsync(path, SecretBundleCodec.Serialize(bundle), ct).ConfigureAwait(false);
        return entries.Count;
    }

    /// <summary>
    /// Imports the bundle at <paramref name="path"/> into <paramref name="session"/>'s store.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Per row: a row wrapped under a foreign KEK is re-wrapped under the target KEK when
    /// <paramref name="sourceKek"/> matches its <see cref="StoredSecret.KekId"/>, otherwise it is
    /// skipped (<see cref="BundleImportReport.SkippedForeignKek"/>). A row whose name does not yet
    /// exist is imported. A row whose name already exists is a conflict resolved by
    /// <paramref name="conflictOverride"/> when supplied, else by <see cref="SecretLastWriterWins"/>;
    /// the winner is written and the loser skipped (<see cref="BundleImportReport.SkippedOlder"/>).
    /// No plaintext is ever handled — the bundle is ciphertext only.
    /// </para>
    /// <para>
    /// Writes go through the store's <see cref="ISecretStore.ApplyReplicatedAsync"/> so the row lands
    /// <b>verbatim</b> — its own revision, timestamps, and tombstone flag preserved — under a
    /// serializable transaction with the shared last-writer-wins gate built in. This keeps LWW metadata
    /// intact across further hops (a second import, or cluster anti-entropy) and means an imported
    /// tombstone stays a tombstone. The one exception is a <paramref name="conflictOverride"/> that
    /// forces the incoming row against the LWW ordering (operator adopts an <em>older</em> row): a
    /// verbatim replicate would be silently rejected by the LWW gate, so that single case is written
    /// through <see cref="ISecretStore.UpsertAsync"/> instead — a local-write that restamps the row as
    /// current, which is exactly what makes the operator's forced choice durable rather than flipped
    /// back on the next reconciliation. Note the narrowed remnant of this: a
    /// <paramref name="conflictOverride"/> that forces an <em>older, tombstoned</em> bundle row over a
    /// newer live local row resurrects it, because that forced <see cref="ISecretStore.UpsertAsync"/>
    /// path clears the tombstone.
    /// </para>
    /// </remarks>
    /// <param name="session">The full session to import into.</param>
    /// <param name="path">Source bundle path.</param>
    /// <param name="sourceKek">The KEK the bundle was exported under, to re-wrap foreign-KEK rows; may be <see langword="null"/>.</param>
    /// <param name="conflictOverride">
    /// Optional per-row decision for an existing row: given (existing, incoming), return
    /// <see langword="true"/> to take the incoming row. When <see langword="null"/>, last-writer-wins decides.
    /// </param>
    /// <param name="ct">A token to cancel the operation.</param>
    /// <returns>A tally of the import.</returns>
    /// <exception cref="InvalidOperationException">The session is degraded (no KEK/cipher).</exception>
    public async Task<BundleImportReport> ImportAsync(
        SecretsSession session,
        string path,
        IMasterKeyProvider? sourceKek,
        Func<StoredSecret, StoredSecret, bool>? conflictOverride,
        CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(session);
        ArgumentException.ThrowIfNullOrWhiteSpace(path);

        IMasterKeyProvider targetKek = session.MasterKey
            ?? throw new InvalidOperationException(
                "Cannot import a bundle into a degraded session (no KEK available).");
        ISecretCipher cipher = session.Cipher
            ?? throw new InvalidOperationException(
                "Cannot import a bundle into a degraded session (no cipher available).");

        string json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false);
        SecretBundle bundle = SecretBundleCodec.Deserialize(json);

        if (bundle.FormatVersion != SecretBundle.CurrentFormatVersion)
        {
            throw new InvalidOperationException(
                $"Bundle '{path}' has unsupported format version {bundle.FormatVersion}; " +
                $"this build supports version {SecretBundle.CurrentFormatVersion}.");
        }

        int imported = 0, skippedOlder = 0, skippedForeignKek = 0, conflicts = 0;

        for (int i = 0; i < bundle.Entries.Count; i++)
        {
            BundleEntry entry = bundle.Entries[i];

            // Rehydrate the row; a malformed entry (bad base64, unknown content type, illegal name)
            // surfaces as an entry-aware failure rather than a bare FormatException/ArgumentException.
            StoredSecret row;
            try
            {
                row = SecretBundleCodec.ToRow(entry);
            }
            catch (Exception ex) when (ex is FormatException or ArgumentException)
            {
                throw new InvalidOperationException(
                    $"Bundle '{path}' entry #{i} ('{entry.Name}') is malformed: {ex.Message}", ex);
            }

            // Foreign KEK: re-wrap under the target KEK if the source KEK is available, else skip.
            if (!string.Equals(row.KekId, targetKek.KekId, StringComparison.Ordinal))
            {
                if (sourceKek is not null && string.Equals(sourceKek.KekId, row.KekId, StringComparison.Ordinal))
                {
                    row = cipher.Rewrap(row, sourceKek, targetKek);
                }
                else
                {
                    skippedForeignKek++;
                    continue;
                }
            }

            StoredSecret? existing = await session.Store.GetAsync(row.Name, ct).ConfigureAwait(false);
            if (existing is null)
            {
                // No local row: ApplyReplicatedAsync inserts it verbatim (its LWW read finds nothing).
                await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
                imported++;
                continue;
            }

            conflicts++;
            bool lwwWin = SecretLastWriterWins.IsNewer(
                row.UpdatedUtc, row.Revision, existing.UpdatedUtc, existing.Revision);
            bool takeIncoming = conflictOverride is not null ? conflictOverride(existing, row) : lwwWin;

            if (!takeIncoming)
            {
                skippedOlder++;
                continue;
            }

            if (lwwWin)
            {
                // Natural (or override-agreed) winner: apply verbatim, keeping LWW metadata intact.
                await session.Store.ApplyReplicatedAsync(row, ct).ConfigureAwait(false);
            }
            else
            {
                // Override forced an older row against LWW. A verbatim replicate would be rejected by
                // the LWW gate, so adopt it as a local write — restamped current so the choice sticks.
                await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
            }

            imported++;
        }

        return new BundleImportReport(imported, skippedOlder, skippedForeignKek, conflicts);
    }

    // Writes content to a sibling temp file then moves it over the target, so a reader never observes
    // a half-written bundle and a crash mid-write cannot corrupt an existing bundle.
    private static async Task WriteAtomicAsync(string path, string content, CancellationToken ct)
    {
        string directory = Path.GetDirectoryName(Path.GetFullPath(path)) ?? ".";
        Directory.CreateDirectory(directory);
        string temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");

        try
        {
            await File.WriteAllTextAsync(temp, content, ct).ConfigureAwait(false);
            File.Move(temp, path, overwrite: true);
        }
        finally
        {
            if (File.Exists(temp))
            {
                try { File.Delete(temp); } catch (IOException) { /* best-effort cleanup of the temp file */ }
            }
        }
    }
}
