#nullable enable

using System.Buffers.Binary;
using System.Security;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;

namespace MBN_STOCK_WEBVIEW.Infrastructure;

public enum LegacyManualOperatorImportFailure
{
    SourceUnavailable,
    SourceInvalid,
    AlreadyImported,
    DestinationNotEmpty,
    InterruptedImport,
    ImportFailed,
    RollbackFailed
}

public sealed class LegacyManualOperatorImportException : Exception
{
    public LegacyManualOperatorImportException(
        LegacyManualOperatorImportFailure failure,
        bool outcomeUnknown = false)
        : base("The one-time legacy manual-data import could not be completed.")
    {
        Failure = failure;
        OutcomeUnknown = outcomeUnknown;
    }

    public LegacyManualOperatorImportFailure Failure { get; }

    public bool OutcomeUnknown { get; }
}

public sealed record LegacyManualOperatorImportResult(
    int MarkerVersion,
    string SourceSha256,
    DateTimeOffset ImportedAt,
    int NetSellRowCount,
    int ViItemCount);

public enum LegacyManualOperatorDataRuntimeState
{
    ReadyLegacyData,
    ReadyImportedData,
    InterruptedImport,
    InvalidImportedState
}

public sealed record LegacyManualOperatorDataRuntimeInspection(
    LegacyManualOperatorDataRuntimeState State)
{
    public bool IsReady => State is
        LegacyManualOperatorDataRuntimeState.ReadyLegacyData or
        LegacyManualOperatorDataRuntimeState.ReadyImportedData;
}

internal sealed class LegacyManualOperatorImportCrashSimulationException : Exception
{
}

/// <summary>
/// Read-only boundary for the original application's fixed Data directory. The
/// default root is compiled into the native application; no WebView request or
/// environment setting can select another source.
/// </summary>
public sealed class FixedLegacyManualOperatorDataSource : ILegacyManualOperatorDataSource
{
    public const string FixedRelativeDirectory =
        @"MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Data";

    private const int MaximumNetSellFileBytes = 32 * 1024;
    private static readonly TimeSpan DefaultQuiescenceDelay = TimeSpan.FromMilliseconds(100);
    private static readonly string[] OrderedFileNames =
    [
        "개인.dat",
        "외국인.dat",
        "기관.dat",
        "VI발동.dat"
    ];

    private readonly string _sourceDirectory;
    private readonly TimeSpan _quiescenceDelay;
    private readonly StringComparison _pathComparison = OperatingSystem.IsWindows()
        ? StringComparison.OrdinalIgnoreCase
        : StringComparison.Ordinal;

    public FixedLegacyManualOperatorDataSource()
        : this(ResolveDefaultSourceDirectory(), DefaultQuiescenceDelay)
    {
    }

    /// <summary>Test/composition-root constructor. Runtime uses the fixed default constructor.</summary>
    public FixedLegacyManualOperatorDataSource(
        string sourceDirectory,
        TimeSpan? quiescenceDelay = null)
    {
        if (string.IsNullOrWhiteSpace(sourceDirectory) ||
            sourceDirectory.IndexOf('\0') >= 0 ||
            !Path.IsPathFullyQualified(sourceDirectory))
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceInvalid);
        }

        _sourceDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceDirectory));
        _quiescenceDelay = quiescenceDelay ?? DefaultQuiescenceDelay;
        if (_quiescenceDelay < TimeSpan.Zero || _quiescenceDelay > TimeSpan.FromSeconds(1))
        {
            throw new ArgumentOutOfRangeException(nameof(quiescenceDelay));
        }
    }

    public async Task<LegacyManualOperatorSourceSnapshot> ReadAsync(
        CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        try
        {
            var first = await ReadRawSnapshotAsync(cancellationToken).ConfigureAwait(false);
            if (_quiescenceDelay > TimeSpan.Zero)
            {
                await Task.Delay(_quiescenceDelay, cancellationToken).ConfigureAwait(false);
            }

            var second = await ReadRawSnapshotAsync(cancellationToken).ConfigureAwait(false);
            if (!OrderedFileNames.All(name =>
                    first[name].AsSpan().SequenceEqual(second[name])))
            {
                throw new LegacyManualOperatorImportException(
                    LegacyManualOperatorImportFailure.SourceInvalid);
            }

            var individual = LegacyManualOperatorDataCodec.ParseNetSell(second["개인.dat"]);
            var foreign = LegacyManualOperatorDataCodec.ParseNetSell(second["외국인.dat"]);
            var institution = LegacyManualOperatorDataCodec.ParseNetSell(second["기관.dat"]);
            var vi = LegacyManualOperatorDataCodec.ParseVi(second["VI발동.dat"]);
            return new LegacyManualOperatorSourceSnapshot(
                individual,
                foreign,
                institution,
                vi,
                ComputeAggregateHash(second));
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (LegacyManualOperatorImportException)
        {
            throw;
        }
        catch (LegacyManualOperatorDataValidationException)
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceInvalid);
        }
        catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException)
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceUnavailable);
        }
        catch (Exception exception) when (exception is
            IOException or UnauthorizedAccessException or SecurityException or
            ArgumentException or NotSupportedException)
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceInvalid);
        }
    }

    private async Task<IReadOnlyDictionary<string, byte[]>> ReadRawSnapshotAsync(
        CancellationToken cancellationToken)
    {
        S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(_sourceDirectory);
        var result = new Dictionary<string, byte[]>(StringComparer.Ordinal);
        foreach (var fileName in OrderedFileNames)
        {
            cancellationToken.ThrowIfCancellationRequested();
            var maximum = fileName == "VI발동.dat"
                ? LegacyManualOperatorDataCodec.MaximumViSerializedBytes
                : MaximumNetSellFileBytes;
            var allowEmpty = fileName == "VI발동.dat";
            result.Add(
                fileName,
                await ReadFileAsync(
                    GetContainedFilePath(fileName),
                    maximum,
                    allowEmpty,
                    cancellationToken).ConfigureAwait(false));
        }

        S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(_sourceDirectory);
        return result;
    }

    private async Task<byte[]> ReadFileAsync(
        string path,
        int maximumLength,
        bool allowEmpty,
        CancellationToken cancellationToken)
    {
        EnsureSourceFile(path, maximumLength, allowEmpty);
        byte[] bytes;
        await using (var stream = new FileStream(
            path,
            FileMode.Open,
            FileAccess.Read,
            FileShare.Read,
            bufferSize: 4_096,
            FileOptions.Asynchronous | FileOptions.SequentialScan))
        {
            TrustedDirectoryLease.EnsureRegularFileHandle(
                stream.SafeFileHandle,
                path,
                maximumLength,
                allowEmpty);
            if (stream.Length > maximumLength || (!allowEmpty && stream.Length <= 0))
            {
                throw new LegacyManualOperatorImportException(
                    LegacyManualOperatorImportFailure.SourceInvalid);
            }

            bytes = new byte[checked((int)stream.Length)];
            var offset = 0;
            while (offset < bytes.Length)
            {
                var read = await stream.ReadAsync(
                    bytes.AsMemory(offset), cancellationToken).ConfigureAwait(false);
                if (read == 0)
                {
                    throw new LegacyManualOperatorImportException(
                        LegacyManualOperatorImportFailure.SourceInvalid);
                }

                offset += read;
            }

            if (await stream.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false) != 0)
            {
                throw new LegacyManualOperatorImportException(
                    LegacyManualOperatorImportFailure.SourceInvalid);
            }
        }

        S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(_sourceDirectory);
        EnsureSourceFile(path, maximumLength, allowEmpty);
        return bytes;
    }

    private string GetContainedFilePath(string fileName)
    {
        var candidate = Path.GetFullPath(Path.Combine(_sourceDirectory, fileName));
        var prefix = _sourceDirectory + Path.DirectorySeparatorChar;
        if (!candidate.StartsWith(prefix, _pathComparison) ||
            !Path.GetFileName(candidate).Equals(fileName, _pathComparison))
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceInvalid);
        }

        return candidate;
    }

    private static void EnsureSourceFile(string path, int maximumLength, bool allowEmpty)
    {
        var attributes = File.GetAttributes(path);
        if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceInvalid);
        }

        var length = new FileInfo(path).Length;
        if (length > maximumLength || (!allowEmpty && length <= 0))
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceInvalid);
        }
    }

    internal static string ComputeAggregateHash(IReadOnlyDictionary<string, byte[]> files)
    {
        using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
        Span<byte> lengthBytes = stackalloc byte[4];
        foreach (var name in OrderedFileNames)
        {
            var nameBytes = Encoding.UTF8.GetBytes(name);
            BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, nameBytes.Length);
            hash.AppendData(lengthBytes);
            hash.AppendData(nameBytes);
            BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, files[name].Length);
            hash.AppendData(lengthBytes);
            hash.AppendData(files[name]);
        }

        return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
    }

    private static string ResolveDefaultSourceDirectory()
    {
        var profile = Environment.GetFolderPath(
            Environment.SpecialFolder.UserProfile,
            Environment.SpecialFolderOption.DoNotVerify);
        if (string.IsNullOrWhiteSpace(profile))
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceUnavailable);
        }

        return Path.Combine(profile, "source", "repos", FixedRelativeDirectory);
    }
}

/// <summary>
/// Imports one validated legacy snapshot into the private operator-data store.
/// Every destination is required to be absent. Files are staged, moved without
/// overwrite, read back through the production parsers, and the versioned marker
/// is committed last. A partial commit is rolled back without retrying.
/// </summary>
public sealed class LegacyManualOperatorDataImporter : IDisposable
{
    public const int MarkerVersion = 1;
    public const string MarkerFileName = ".legacy-manual-operator-import.v1.json";
    public const string IntentFileName =
        ".legacy-manual-operator-import.v1.in-progress.json";

    private const int MaximumMarkerBytes = 4 * 1024;
    private const int MaximumIntentBytes = 4 * 1024;
    private const string TemporaryPrefix = ".legacy-manual-import-v1-";
    private static readonly string[] DataFileNames =
    [
        "개인.dat",
        "외국인.dat",
        "기관.dat",
        "VI발동.dat"
    ];

    private readonly ILegacyManualOperatorDataSource _source;
    private readonly string _destinationDirectory;
    private readonly TrustedDirectoryLease _destinationLease;
    private readonly Func<DateTimeOffset> _utcNow;
    private readonly Action<int>? _afterDataFileMoved;
    private readonly Action? _afterMarkerCommitted;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private int _disposed;

    public LegacyManualOperatorDataImporter(
        string destinationDirectory,
        ILegacyManualOperatorDataSource source,
        Func<DateTimeOffset>? utcNow = null)
        : this(
            destinationDirectory,
            source,
            utcNow,
            afterDataFileMoved: null,
            afterMarkerCommitted: null)
    {
    }

    internal LegacyManualOperatorDataImporter(
        string destinationDirectory,
        ILegacyManualOperatorDataSource source,
        Func<DateTimeOffset>? utcNow,
        Action<int>? afterDataFileMoved,
        Action? afterMarkerCommitted = null)
    {
        _source = source ?? throw new ArgumentNullException(nameof(source));
        _destinationDirectory = TrustedManualDirectory.NormalizeAbsolutePath(destinationDirectory);
        _destinationLease = TrustedManualDirectory.AcquirePrivateWritableLease(_destinationDirectory);
        _utcNow = utcNow ?? (() => DateTimeOffset.UtcNow);
        _afterDataFileMoved = afterDataFileMoved;
        _afterMarkerCommitted = afterMarkerCommitted;
    }

    internal static IReadOnlyList<string> ExpectedDataFileNames => DataFileNames;

    internal static int MarkerSizeLimit => MaximumMarkerBytes;

    internal static int IntentSizeLimit => MaximumIntentBytes;

    public async Task<LegacyManualOperatorImportResult> ImportAsync(
        CancellationToken cancellationToken = default)
    {
        ObjectDisposedException.ThrowIf(
            Volatile.Read(ref _disposed) != 0,
            this);
        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            EnsureDestinationIsEmpty();
            var snapshot = await _source.ReadAsync(cancellationToken).ConfigureAwait(false);
            var prepared = Prepare(snapshot);
            cancellationToken.ThrowIfCancellationRequested();
            EnsureDestinationIsEmpty();

            var importedAt = _utcNow().ToUniversalTime();
            var productionSha256 =
                FixedLegacyManualOperatorDataSource.ComputeAggregateHash(prepared);
            var markerBytes = JsonSerializer.SerializeToUtf8Bytes(new
            {
                schemaVersion = MarkerVersion,
                sourceSha256 = snapshot.SourceSha256,
                productionSha256,
                importedAt,
                netSellFileCount = 3,
                netSellRowCount = LegacyManualOperatorDataCodec.NetSellRowCount * 3,
                viItemCount = snapshot.ViItems.Count
            });
            if (markerBytes.Length is <= 0 or > MaximumMarkerBytes)
            {
                throw new LegacyManualOperatorImportException(
                    LegacyManualOperatorImportFailure.SourceInvalid);
            }

            var operationId = Guid.NewGuid().ToString("N");
            var intentBytes = CreateIntentBytes(
                operationId,
                snapshot.SourceSha256,
                productionSha256,
                markerBytes);
            var staged = CreateStagedFiles(prepared, markerBytes, operationId);
            try
            {
                foreach (var file in staged)
                {
                    await StageAsync(file, cancellationToken).ConfigureAwait(false);
                }

                cancellationToken.ThrowIfCancellationRequested();
                EnsureFinalDestinationsAreAbsent();
                CommitAndVerify(
                    staged,
                    snapshot,
                    productionSha256,
                    intentBytes);
            }
            finally
            {
                CleanupStagedFiles(staged);
            }

            return new LegacyManualOperatorImportResult(
                MarkerVersion,
                snapshot.SourceSha256,
                importedAt,
                LegacyManualOperatorDataCodec.NetSellRowCount * 3,
                snapshot.ViItems.Count);
        }
        finally
        {
            _gate.Release();
        }
    }

    public void Dispose()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0)
        {
            return;
        }

        _destinationLease.Dispose();
        _gate.Dispose();
    }

    private static IReadOnlyDictionary<string, byte[]> Prepare(
        LegacyManualOperatorSourceSnapshot snapshot)
    {
        if (snapshot is null ||
            snapshot.SourceSha256.Length != 64 ||
            snapshot.SourceSha256.Any(character =>
                !(character is >= '0' and <= '9' or >= 'a' and <= 'f')))
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceInvalid);
        }

        try
        {
            return new Dictionary<string, byte[]>(StringComparer.Ordinal)
            {
                ["개인.dat"] = LegacyManualOperatorDataCodec.SerializeNetSell(snapshot.IndividualRows),
                ["외국인.dat"] = LegacyManualOperatorDataCodec.SerializeNetSell(snapshot.ForeignRows),
                ["기관.dat"] = LegacyManualOperatorDataCodec.SerializeNetSell(snapshot.InstitutionRows),
                ["VI발동.dat"] = LegacyManualOperatorDataCodec.SerializeVi(snapshot.ViItems)
            };
        }
        catch (LegacyManualOperatorDataValidationException)
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceInvalid);
        }
    }

    private static byte[] CreateIntentBytes(
        string operationId,
        string sourceSha256,
        string productionSha256,
        byte[] markerBytes)
    {
        var bytes = JsonSerializer.SerializeToUtf8Bytes(new
        {
            schemaVersion = MarkerVersion,
            operationId,
            sourceSha256,
            productionSha256,
            markerSha256 = Convert.ToHexString(SHA256.HashData(markerBytes))
                .ToLowerInvariant()
        });
        if (bytes.Length is <= 0 or > MaximumIntentBytes)
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.SourceInvalid);
        }

        return bytes;
    }

    private IReadOnlyList<StagedFile> CreateStagedFiles(
        IReadOnlyDictionary<string, byte[]> prepared,
        byte[] markerBytes,
        string operationId)
    {
        var result = DataFileNames.Select(name => new StagedFile(
                name,
                ContainedPath(name),
                ContainedPath($"{TemporaryPrefix}{operationId}-{DataFileNames.ToList().IndexOf(name)}.tmp"),
                prepared[name],
                name == "VI발동.dat"
                    ? LegacyManualOperatorDataCodec.MaximumViSerializedBytes
                    : S5025TrustedManualFileDataSource.MaximumFileSizeBytes,
                AllowEmpty: name == "VI발동.dat"))
            .ToList();
        result.Add(new StagedFile(
            MarkerFileName,
            ContainedPath(MarkerFileName),
            ContainedPath($"{TemporaryPrefix}{operationId}-marker.tmp"),
            markerBytes,
            MaximumMarkerBytes,
            AllowEmpty: false));
        return result.AsReadOnly();
    }

    private async Task StageAsync(StagedFile file, CancellationToken cancellationToken)
    {
        await using var stream = new FileStream(
            file.TemporaryPath,
            FileMode.CreateNew,
            FileAccess.Write,
            FileShare.None,
            bufferSize: 4_096,
            FileOptions.Asynchronous | FileOptions.WriteThrough);
        TrustedDirectoryLease.EnsureRegularFileHandle(
            stream.SafeFileHandle,
            file.TemporaryPath,
            file.MaximumBytes,
            allowEmpty: true);
        await stream.WriteAsync(file.Bytes, cancellationToken).ConfigureAwait(false);
        await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
        stream.Flush(flushToDisk: true);
        EnsureRegularFile(file.TemporaryPath, file.MaximumBytes, file.AllowEmpty);
    }

    private void CommitAndVerify(
        IReadOnlyList<StagedFile> staged,
        LegacyManualOperatorSourceSnapshot snapshot,
        string productionSha256,
        byte[] intentBytes)
    {
        var moved = new List<StagedFile>();
        var intentPath = ContainedPath(IntentFileName);
        var intentCreated = false;
        var markerVerified = false;
        try
        {
            // From this point a failed CreateNew/flush may have left an intent
            // entry. Treat it as import-owned only when its bytes still match.
            intentCreated = true;
            WriteIntentDurably(intentPath, intentBytes);

            foreach (var file in staged.Where(file => file.FileName != MarkerFileName))
            {
                File.Move(file.TemporaryPath, file.FinalPath, overwrite: false);
                moved.Add(file);
                _afterDataFileMoved?.Invoke(moved.Count);
            }

            VerifyImportedData(snapshot).GetAwaiter().GetResult();
            if (!string.Equals(
                    ComputeProductionHash(),
                    productionSha256,
                    StringComparison.Ordinal))
            {
                throw new IOException("The imported data hash did not verify.");
            }

            var marker = staged.Single(file => file.FileName == MarkerFileName);
            File.Move(marker.TemporaryPath, marker.FinalPath, overwrite: false);
            moved.Add(marker);
            FlushOwnedFileToDisk(
                marker.FinalPath,
                MaximumMarkerBytes,
                allowEmpty: false);
            if (!ReadOwnedFile(
                    marker.FinalPath,
                    MaximumMarkerBytes,
                    allowEmpty: false).AsSpan().SequenceEqual(marker.Bytes))
            {
                throw new IOException("The import marker did not verify.");
            }

            markerVerified = true;
            _afterMarkerCommitted?.Invoke();
            if (!DeleteOwnedFile(
                    intentPath,
                    intentBytes,
                    MaximumIntentBytes,
                    allowEmpty: false))
            {
                throw new IOException("The import intent could not be removed.");
            }
        }
        catch (LegacyManualOperatorImportCrashSimulationException)
        {
            // Test-only abrupt-termination seam: deliberately preserve the
            // durable intent and committed prefix exactly as a killed process would.
            throw;
        }
        catch
        {
            if (markerVerified)
            {
                // The completed marker and all four files were durably written and
                // verified. Never roll them back after the commit point; a leftover
                // intent makes startup fail closed until it is reconciled.
                throw new LegacyManualOperatorImportException(
                    LegacyManualOperatorImportFailure.RollbackFailed,
                    outcomeUnknown: true);
            }

            var rollbackSucceeded = Rollback(moved);
            if (rollbackSucceeded && intentCreated &&
                !DeleteOwnedFile(
                    intentPath,
                    intentBytes,
                    MaximumIntentBytes,
                    allowEmpty: false))
            {
                rollbackSucceeded = false;
            }

            if (!rollbackSucceeded)
            {
                throw new LegacyManualOperatorImportException(
                    LegacyManualOperatorImportFailure.RollbackFailed,
                    outcomeUnknown: true);
            }

            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.ImportFailed);
        }
    }

    private void WriteIntentDurably(string path, byte[] bytes)
    {
        using (var stream = new FileStream(
            path,
            FileMode.CreateNew,
            FileAccess.Write,
            FileShare.None,
            bufferSize: 4_096,
            FileOptions.WriteThrough))
        {
            stream.Write(bytes);
            stream.Flush(flushToDisk: true);
            TrustedDirectoryLease.EnsureRegularFileHandle(
                stream.SafeFileHandle,
                path,
                MaximumIntentBytes,
                allowEmpty: false);
        }

        if (!ReadOwnedFile(
                path,
                MaximumIntentBytes,
                allowEmpty: false).AsSpan().SequenceEqual(bytes))
        {
            throw new IOException("The import intent did not verify.");
        }
    }

    private static void FlushOwnedFileToDisk(
        string path,
        int maximumBytes,
        bool allowEmpty)
    {
        using var stream = new FileStream(
            path,
            FileMode.Open,
            FileAccess.ReadWrite,
            FileShare.None,
            bufferSize: 4_096,
            FileOptions.WriteThrough);
        TrustedDirectoryLease.EnsureRegularFileHandle(
            stream.SafeFileHandle,
            path,
            maximumBytes,
            allowEmpty);
        stream.Flush(flushToDisk: true);
    }

    private string ComputeProductionHash()
    {
        var files = DataFileNames.ToDictionary(
            name => name,
            name => ReadOwnedFile(
                ContainedPath(name),
                name == "VI발동.dat"
                    ? LegacyManualOperatorDataCodec.MaximumViSerializedBytes
                    : S5025TrustedManualFileDataSource.MaximumFileSizeBytes,
                allowEmpty: name == "VI발동.dat"),
            StringComparer.Ordinal);
        return FixedLegacyManualOperatorDataSource.ComputeAggregateHash(files);
    }

    private async Task VerifyImportedData(LegacyManualOperatorSourceSnapshot expected)
    {
        var source = new S5025TrustedManualFileDataSource(_destinationDirectory);
        if (!(await source.ReadAsync(S5025ManualAudience.Individual, CancellationToken.None)
                .ConfigureAwait(false)).SequenceEqual(expected.IndividualRows) ||
            !(await source.ReadAsync(S5025ManualAudience.Foreign, CancellationToken.None)
                .ConfigureAwait(false)).SequenceEqual(expected.ForeignRows) ||
            !(await source.ReadAsync(S5025ManualAudience.Institution, CancellationToken.None)
                .ConfigureAwait(false)).SequenceEqual(expected.InstitutionRows))
        {
            throw new IOException("The imported FSell rows did not verify.");
        }

        using var viStore = new ViTrustedManualFileStore(_destinationDirectory);
        var vi = await viStore.ReadAsync(CancellationToken.None).ConfigureAwait(false);
        if (vi.Count != expected.ViItems.Count ||
            !vi.Select(item => (item.Code, item.Name))
                .SequenceEqual(expected.ViItems.Select(item => (item.Code, item.Name))))
        {
            throw new IOException("The imported VI rows did not verify.");
        }
    }

    private bool Rollback(IReadOnlyList<StagedFile> moved)
    {
        var succeeded = true;
        foreach (var file in moved.Reverse())
        {
            try
            {
                if (!EntryExists(file.FinalPath))
                {
                    continue;
                }

                var actualHash = SHA256.HashData(ReadOwnedFile(
                    file.FinalPath,
                    file.MaximumBytes,
                    file.AllowEmpty));
                var expectedHash = SHA256.HashData(file.Bytes);
                if (!CryptographicOperations.FixedTimeEquals(actualHash, expectedHash))
                {
                    succeeded = false;
                    continue;
                }

                File.Delete(file.FinalPath);
            }
            catch (Exception exception) when (exception is
                IOException or UnauthorizedAccessException or SecurityException)
            {
                succeeded = false;
            }
        }

        return succeeded;
    }

    private static bool DeleteOwnedFile(
        string path,
        byte[] expectedBytes,
        int maximumBytes,
        bool allowEmpty)
    {
        try
        {
            if (!EntryExists(path))
            {
                return false;
            }

            var actualHash = SHA256.HashData(ReadOwnedFile(
                path,
                maximumBytes,
                allowEmpty));
            var expectedHash = SHA256.HashData(expectedBytes);
            if (!CryptographicOperations.FixedTimeEquals(actualHash, expectedHash))
            {
                return false;
            }

            File.Delete(path);
            return !EntryExists(path);
        }
        catch (Exception exception) when (exception is
            IOException or UnauthorizedAccessException or SecurityException)
        {
            return false;
        }
    }

    internal static byte[] ReadOwnedFile(
        string path,
        int maximumBytes,
        bool allowEmpty)
    {
        EnsureRegularFile(path, maximumBytes, allowEmpty);
        using var stream = new FileStream(
            path,
            FileMode.Open,
            FileAccess.Read,
            FileShare.Read,
            bufferSize: 4_096,
            FileOptions.SequentialScan);
        TrustedDirectoryLease.EnsureRegularFileHandle(
            stream.SafeFileHandle,
            path,
            maximumBytes,
            allowEmpty);
        if (stream.Length > maximumBytes || (!allowEmpty && stream.Length <= 0))
        {
            throw new IOException("An import-owned file was not regular and bounded.");
        }

        var bytes = new byte[checked((int)stream.Length)];
        stream.ReadExactly(bytes);
        if (stream.ReadByte() != -1)
        {
            throw new IOException("An import-owned file changed while it was read.");
        }

        EnsureRegularFile(path, maximumBytes, allowEmpty);
        return bytes;
    }

    private void CleanupStagedFiles(IEnumerable<StagedFile> staged)
    {
        foreach (var file in staged)
        {
            try
            {
                if (!EntryExists(file.TemporaryPath))
                {
                    continue;
                }

                EnsureRegularFile(file.TemporaryPath, file.MaximumBytes, allowEmpty: true);
                File.Delete(file.TemporaryPath);
            }
            catch (Exception exception) when (exception is
                IOException or UnauthorizedAccessException or SecurityException)
            {
                // Never consume or retry a leftover staging file. The next
                // preflight detects the fixed prefix and fails closed.
            }
        }
    }

    private void EnsureDestinationIsEmpty()
    {
        EnsureFinalDestinationsAreAbsent();
        if (Directory.EnumerateFileSystemEntries(
                _destinationDirectory,
                $"{TemporaryPrefix}*",
                SearchOption.TopDirectoryOnly).Any())
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.DestinationNotEmpty);
        }
    }

    private void EnsureFinalDestinationsAreAbsent()
    {
        S5025TrustedManualFileDataSource.EnsureDirectoryIsTrusted(_destinationDirectory);
        if (EntryExists(ContainedPath(IntentFileName)))
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.InterruptedImport,
                outcomeUnknown: true);
        }

        if (EntryExists(ContainedPath(MarkerFileName)))
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.AlreadyImported);
        }

        if (DataFileNames.Any(name => EntryExists(ContainedPath(name))))
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.DestinationNotEmpty);
        }
    }

    private string ContainedPath(string fileName)
    {
        var path = Path.GetFullPath(Path.Combine(_destinationDirectory, fileName));
        var comparison = OperatingSystem.IsWindows()
            ? StringComparison.OrdinalIgnoreCase
            : StringComparison.Ordinal;
        if (!path.StartsWith(_destinationDirectory + Path.DirectorySeparatorChar, comparison) ||
            !Path.GetFileName(path).Equals(fileName, comparison))
        {
            throw new LegacyManualOperatorImportException(
                LegacyManualOperatorImportFailure.DestinationNotEmpty);
        }

        return path;
    }

    private static bool EntryExists(string path)
    {
        try
        {
            _ = File.GetAttributes(path);
            return true;
        }
        catch (FileNotFoundException)
        {
            return false;
        }
        catch (DirectoryNotFoundException)
        {
            return false;
        }
    }

    private static void EnsureRegularFile(
        string path,
        int maximumBytes,
        bool allowEmpty)
    {
        var attributes = File.GetAttributes(path);
        var length = new FileInfo(path).Length;
        if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
            length > maximumBytes ||
            (!allowEmpty && length <= 0))
        {
            throw new IOException("An import-owned file was not regular and bounded.");
        }
    }

    private sealed record StagedFile(
        string FileName,
        string FinalPath,
        string TemporaryPath,
        byte[] Bytes,
        int MaximumBytes,
        bool AllowEmpty);
}

/// <summary>
/// Fail-closed startup inspection for the four manual operator-data files. An
/// in-progress intent is never removed automatically, even when a completed
/// marker is also present: startup must not guess whether a prior caller saw a
/// successful result. Existing legacy files with neither marker nor intent keep
/// their original behavior.
/// </summary>
public static class LegacyManualOperatorDataRuntimeGuard
{
    public static LegacyManualOperatorDataRuntimeInspection Inspect(string directory)
    {
        try
        {
            var normalized = TrustedManualDirectory.NormalizeAbsolutePath(directory);
            using var lease = TrustedManualDirectory.AcquirePrivateWritableLease(normalized);
            var intentPath = ContainedPath(
                normalized,
                LegacyManualOperatorDataImporter.IntentFileName);
            if (EntryExists(intentPath))
            {
                // Do not read any production file while an interrupted import is
                // possible. Reconciliation is an explicit operator action.
                return new LegacyManualOperatorDataRuntimeInspection(
                    LegacyManualOperatorDataRuntimeState.InterruptedImport);
            }

            var markerPath = ContainedPath(
                normalized,
                LegacyManualOperatorDataImporter.MarkerFileName);
            if (!EntryExists(markerPath))
            {
                return new LegacyManualOperatorDataRuntimeInspection(
                    LegacyManualOperatorDataRuntimeState.ReadyLegacyData);
            }

            var dataPaths = LegacyManualOperatorDataImporter.ExpectedDataFileNames
                .ToDictionary(
                    name => name,
                    name => ContainedPath(normalized, name),
                    StringComparer.Ordinal);
            if (dataPaths.Values.Any(path => !EntryExists(path)))
            {
                return new LegacyManualOperatorDataRuntimeInspection(
                    LegacyManualOperatorDataRuntimeState.InvalidImportedState);
            }

            var markerBytes = LegacyManualOperatorDataImporter.ReadOwnedFile(
                markerPath,
                LegacyManualOperatorDataImporter.MarkerSizeLimit,
                allowEmpty: false);
            if (!TryReadProductionHash(markerBytes, out _))
            {
                return new LegacyManualOperatorDataRuntimeInspection(
                    LegacyManualOperatorDataRuntimeState.InvalidImportedState);
            }

            var files = dataPaths.ToDictionary(
                pair => pair.Key,
                pair => LegacyManualOperatorDataImporter.ReadOwnedFile(
                    pair.Value,
                    pair.Key == "VI발동.dat"
                        ? LegacyManualOperatorDataCodec.MaximumViSerializedBytes
                        : S5025TrustedManualFileDataSource.MaximumFileSizeBytes,
                    allowEmpty: pair.Key == "VI발동.dat"),
                StringComparer.Ordinal);
            // The files remain operator-editable after the one-time import, so
            // the marker hash is an import receipt rather than a perpetual
            // content lock. Validate the current production formats instead of
            // rejecting a legitimate later save whose bytes changed.
            foreach (var name in LegacyManualOperatorDataImporter.ExpectedDataFileNames.Take(3))
            {
                _ = LegacyManualOperatorDataCodec.ParseNetSell(files[name]);
            }
            _ = LegacyManualOperatorDataCodec.ParseVi(files[
                LegacyManualOperatorDataImporter.ExpectedDataFileNames[3]]);
            return new LegacyManualOperatorDataRuntimeInspection(
                LegacyManualOperatorDataRuntimeState.ReadyImportedData);
        }
        catch (Exception exception) when (exception is
            IOException or UnauthorizedAccessException or SecurityException or
            JsonException or ArgumentException or NotSupportedException or
            S5025TrustedManualDataException or
            LegacyManualOperatorDataValidationException)
        {
            return new LegacyManualOperatorDataRuntimeInspection(
                LegacyManualOperatorDataRuntimeState.InvalidImportedState);
        }
    }

    private static bool TryReadProductionHash(
        byte[] markerBytes,
        out string productionSha256)
    {
        productionSha256 = string.Empty;
        using var document = JsonDocument.Parse(markerBytes);
        var root = document.RootElement;
        if (root.ValueKind != JsonValueKind.Object ||
            !root.TryGetProperty("schemaVersion", out var schemaVersion) ||
            schemaVersion.ValueKind != JsonValueKind.Number ||
            !schemaVersion.TryGetInt32(out var version) ||
            version != LegacyManualOperatorDataImporter.MarkerVersion ||
            !root.TryGetProperty("sourceSha256", out var sourceHash) ||
            !IsLowerSha256(sourceHash) ||
            !root.TryGetProperty("netSellFileCount", out var fileCount) ||
            !fileCount.TryGetInt32(out var netSellFileCount) ||
            netSellFileCount != 3 ||
            !root.TryGetProperty("netSellRowCount", out var rowCount) ||
            !rowCount.TryGetInt32(out var netSellRowCount) ||
            netSellRowCount != LegacyManualOperatorDataCodec.NetSellRowCount * 3 ||
            !root.TryGetProperty("viItemCount", out var viCount) ||
            !viCount.TryGetInt32(out var viItemCount) ||
            viItemCount is < 0 or > LegacyManualOperatorDataCodec.MaximumViItemCount ||
            !root.TryGetProperty("importedAt", out var importedAt) ||
            importedAt.ValueKind != JsonValueKind.String ||
            !importedAt.TryGetDateTimeOffset(out _))
        {
            return false;
        }

        var hashPropertyName = root.TryGetProperty("productionSha256", out var productionHash)
            ? "productionSha256"
            : "sourceSha256";
        if (!root.TryGetProperty(hashPropertyName, out var hash) || !IsLowerSha256(hash))
        {
            return false;
        }

        var allowedProperties = hashPropertyName == "productionSha256"
            ? new HashSet<string>(StringComparer.Ordinal)
            {
                "schemaVersion",
                "sourceSha256",
                "productionSha256",
                "importedAt",
                "netSellFileCount",
                "netSellRowCount",
                "viItemCount"
            }
            : new HashSet<string>(StringComparer.Ordinal)
            {
                "schemaVersion",
                "sourceSha256",
                "importedAt",
                "netSellFileCount",
                "netSellRowCount",
                "viItemCount"
            };
        var propertyNames = root.EnumerateObject()
            .Select(property => property.Name)
            .ToArray();
        if (propertyNames.Length != allowedProperties.Count ||
            propertyNames.Distinct(StringComparer.Ordinal).Count() != propertyNames.Length ||
            propertyNames.Any(name => !allowedProperties.Contains(name)))
        {
            return false;
        }

        productionSha256 = hash.GetString()!;
        return true;
    }

    private static bool IsLowerSha256(JsonElement value)
    {
        if (value.ValueKind != JsonValueKind.String)
        {
            return false;
        }

        var text = value.GetString();
        return text is { Length: 64 } && text.All(character =>
            character is >= '0' and <= '9' or >= 'a' and <= 'f');
    }

    private static string ContainedPath(string directory, string fileName)
    {
        var candidate = Path.GetFullPath(Path.Combine(directory, fileName));
        var comparison = OperatingSystem.IsWindows()
            ? StringComparison.OrdinalIgnoreCase
            : StringComparison.Ordinal;
        return candidate.StartsWith(
                directory + Path.DirectorySeparatorChar,
                comparison) &&
            Path.GetFileName(candidate).Equals(fileName, comparison)
                ? candidate
                : throw S5025TrustedManualFileDataSource.InvalidData();
    }

    private static bool EntryExists(string path)
    {
        try
        {
            _ = File.GetAttributes(path);
            return true;
        }
        catch (FileNotFoundException)
        {
            return false;
        }
        catch (DirectoryNotFoundException)
        {
            return false;
        }
    }
}
