using DodoSSH.Client.Ssh;

namespace DodoSSH.Client.Transfer.Tests;

/// <summary>
/// A remote filesystem in a dictionary.
/// </summary>
/// <remarks>
/// Here rather than a container because what this suite is about is the queue's behaviour when a transfer
/// goes wrong halfway — a read that dies after so many bytes, a destination that appears while a transfer is
/// queued — and neither can be asked of a real server on cue. That the real server behaves as this fake
/// pretends is established separately, against sshd, in <c>SftpSessionTests</c>.
/// </remarks>
internal sealed class FakeSftpSession : ISftpSession
{
    private readonly Dictionary<string, byte[]> files = new(StringComparer.Ordinal);
    private readonly HashSet<string> directories = new(StringComparer.Ordinal) { "/", "/home/dodo" };
    private readonly Lock gate = new();

    /// <inheritdoc />
    public bool IsConnected => true;

    /// <inheritdoc />
    public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");

    /// <inheritdoc />
    public string Cipher { get; } = "aes256-gcm@openssh.com";

    /// <inheritdoc />
    public string HomeDirectory => "/home/dodo";

    /// <summary>Throws once, this many bytes into the next read, and then stops doing so.</summary>
    /// <remarks>
    /// One-shot on purpose: every resume test is "it broke, then it did not", and a fake that kept failing
    /// would need turning off at exactly the point the assertion is about.
    /// </remarks>
    public int? FailReadAfter { get; set; }

    /// <summary>The offsets <see cref="OpenReadAsync"/> was asked to start at, in order.</summary>
    /// <remarks>
    /// The only direct evidence that a resume resumed. A test can see the right bytes on disk at the end
    /// whether the second attempt started at the offset or at zero.
    /// </remarks>
    public List<long> ReadOffsets { get; } = [];

    /// <summary>Puts a file on the fake host.</summary>
    public void Seed(string path, byte[] content)
    {
        lock (gate)
        {
            files[path] = content;
            directories.Add(SftpPath.Parent(path));
        }
    }

    /// <summary>What is at a path, or null.</summary>
    public byte[]? Read(string path)
    {
        lock (gate)
        {
            return files.GetValueOrDefault(path);
        }
    }

    /// <summary>Whether anything is at a path.</summary>
    public bool Exists(string path)
    {
        lock (gate)
        {
            return files.ContainsKey(path) || directories.Contains(path);
        }
    }

    /// <inheritdoc />
    public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
    {
        lock (gate)
        {
            if (!directories.Contains(path))
            {
                throw new SftpPathException(path, $"{path} is not there.");
            }

            IReadOnlyList<SftpEntry> entries =
            [
                .. files
                    .Where(file => string.Equals(SftpPath.Parent(file.Key), path, StringComparison.Ordinal))
                    .Select(file => Describe(file.Key, file.Value.Length)),
            ];

            return Task.FromResult(entries);
        }
    }

    /// <inheritdoc />
    public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
    {
        lock (gate)
        {
            if (files.TryGetValue(path, out var content))
            {
                return Task.FromResult<SftpEntry?>(Describe(path, content.Length));
            }

            return Task.FromResult<SftpEntry?>(
                directories.Contains(path)
                    ? new SftpEntry(
                        SftpPath.Name(path),
                        path,
                        SftpEntryKind.Directory,
                        0,
                        DateTimeOffset.UnixEpoch,
                        "drwxr-xr-x")
                    : null);
        }
    }

    /// <inheritdoc />
    public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken)
    {
        lock (gate)
        {
            ReadOffsets.Add(offset);

            if (!files.TryGetValue(path, out var content))
            {
                throw new SftpPathException(path, $"{path} is not there.");
            }

            var failAfter = FailReadAfter;
            FailReadAfter = null;

            return Task.FromResult<Stream>(
                new BrittleStream(content.AsSpan((int)offset).ToArray(), failAfter));
        }
    }

    /// <inheritdoc />
    public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
    {
        lock (gate)
        {
            var existing = files.GetValueOrDefault(path, []);

            return Task.FromResult<Stream>(new CommittingStream(
                existing.AsSpan(0, (int)Math.Min(offset, existing.Length)).ToArray(),
                content =>
                {
                    lock (gate)
                    {
                        files[path] = content;
                    }
                }));
        }
    }

    /// <inheritdoc />
    public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
    {
        lock (gate)
        {
            directories.Add(path);
        }

        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public Task DeleteAsync(string path, CancellationToken cancellationToken)
    {
        lock (gate)
        {
            if (!files.Remove(path) && !directories.Remove(path))
            {
                throw new SftpPathException(path, $"{path} is not there.");
            }
        }

        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
    {
        lock (gate)
        {
            if (!files.TryGetValue(fromPath, out var content))
            {
                throw new SftpPathException(fromPath, $"{fromPath} is not there.");
            }

            // SFTP's rename does not replace, and the queue's promise never to overwrite rests on it. A fake
            // that clobbered would make the one test about that promise pass for the wrong reason.
            if (files.ContainsKey(toPath))
            {
                throw new SftpPathException(toPath, $"{toPath} is already there.");
            }

            files.Remove(fromPath);
            files[toPath] = content;
        }

        return Task.CompletedTask;
    }

    /// <inheritdoc />
    public ValueTask DisposeAsync() => ValueTask.CompletedTask;

    private static SftpEntry Describe(string path, int length) => new(
        SftpPath.Name(path),
        path,
        SftpEntryKind.File,
        length,
        DateTimeOffset.UnixEpoch,
        "-rw-r--r--");

    /// <summary>A read that dies partway through, the way a dropped connection does.</summary>
    private sealed class BrittleStream(byte[] content, int? failAfter) : MemoryStream(content, writable: false)
    {
        public override int Read(Span<byte> buffer)
        {
            Guard();

            return base.Read(buffer);
        }

        public override ValueTask<int> ReadAsync(
            Memory<byte> buffer,
            CancellationToken cancellationToken = default)
        {
            Guard();

            return base.ReadAsync(buffer, cancellationToken);
        }

        private void Guard()
        {
            if (failAfter is { } limit && Position >= limit)
            {
                throw new IOException("The connection dropped.");
            }
        }
    }

    /// <summary>A write that lands on the fake host when it is disposed.</summary>
    private sealed class CommittingStream(byte[] prefix, Action<byte[]> commit) : MemoryStream()
    {
        private bool committed;

        public override void Close()
        {
            if (!committed)
            {
                committed = true;
                commit([.. prefix, .. ToArray()]);
            }

            base.Close();
        }
    }
}
