using System.Threading.Channels;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;

namespace DodoSSH.Client.Session;

/// <summary>A connection that has started and has no log entry yet, because it has not ended.</summary>
/// <param name="HostLabel">What the host is called.</param>
/// <param name="Address">The address as dialled.</param>
/// <param name="StartedAt">When it opened.</param>
public sealed record OpenConnection(string HostLabel, string Address, DateTimeOffset StartedAt);

/// <summary>
/// Records connections into whichever vault is open, without ever making the caller wait.
/// </summary>
/// <remarks>
/// <para>
/// <b>A process-lifetime object with session-scoped contents</b>, exactly like <see cref="VaultKnownHostStore"/>
/// and for the same reason: the workspace that calls it is composed once at startup and outlives every lock,
/// so a recorder created per session would have to be threaded through an object that must not know about
/// vaults at all. <see cref="Open"/> on unlock, <see cref="Close"/> on lock.
/// </para>
/// <para>
/// <b>Nothing on the calling thread does any work.</b> Both interface methods take a lock, touch a
/// dictionary, and post to a bounded channel; one background task drains it and does the encrypting and
/// writing. That is not tidiness — <c>Closed</c> is called from a <c>finally</c> unwinding on a thread-pool
/// thread while the application is shutting down, once per open tab, and an encrypt-and-write there is
/// exactly how closing an application comes to take four seconds.
/// </para>
/// <para>
/// <b>A shell can outlive the vault, so close-out has to as well.</b> A tab opened before a lock and closed
/// after it still deserves its entry — the connection genuinely happened — so the ticket keeps the repository
/// it was opened against rather than reading whichever one is current. The write then fails if the session
/// behind it has been disposed, which is swallowed like every other failure here: an advisory log line is
/// never worth surfacing an error over.
/// </para>
/// <para>
/// <b>The queue is bounded and drops the oldest when full.</b> An unbounded one would turn a stuck write into
/// unbounded memory, and blocking would turn it into a hung shutdown. Losing the oldest few entries of a
/// backlog that is already thousands deep is the least bad of the three, and it is the direction that keeps
/// the newest — which is what somebody reading a log actually wants.
/// </para>
/// </remarks>
public sealed class ConnectionRecorder : IConnectionLogSink, IAsyncDisposable
{
    /// <summary>
    /// How many close-outs may be waiting to be written.
    /// </summary>
    /// <remarks>
    /// Far more than the tabs anybody has open, so the cap is only ever reached by a write path that has
    /// stopped draining — which is the case it exists for.
    /// </remarks>
    private const int QueueDepth = 256;

    /// <summary>How long <see cref="DisposeAsync"/> waits for the queue to be written.</summary>
    /// <remarks>
    /// Long enough for the handful of entries a normal exit produces — each is one encrypt and one local
    /// write — and short enough that a stuck cache cannot become a window that will not close.
    /// </remarks>
    private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);

    private readonly Channel<PendingEntry> pending = Channel.CreateBounded<PendingEntry>(
        new BoundedChannelOptions(QueueDepth)
        {
            FullMode = BoundedChannelFullMode.DropOldest,
            SingleReader = true,
        });

    private readonly Dictionary<uint, OpenTicket> tickets = [];
    private readonly Lock gate = new();
    private readonly TimeProvider clock;
    private readonly string deviceName;
    private readonly Task drain;
    private readonly CancellationTokenSource lifetime = new();

    private Binding? binding;
    private int disposed;

    /// <param name="clock">Time source. Used only for a duration this type did not receive.</param>
    /// <param name="deviceName">What this machine calls itself, recorded on every entry.</param>
    public ConnectionRecorder(TimeProvider clock, string deviceName)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);

        this.clock = clock;
        this.deviceName = deviceName;

        drain = DrainAsync(lifetime.Token);
    }

    /// <summary>
    /// The connections that have opened and not yet been recorded.
    /// </summary>
    /// <remarks>
    /// For the logs screen, which shows these above the finished entries. It reads them from here rather
    /// than from the tab strip because these are exactly the tickets the log is waiting to close — so a row
    /// on that screen appears and disappears in step with the entry that will replace it, rather than in
    /// step with a tab, which is a different thing that merely usually agrees.
    /// </remarks>
    public IReadOnlyList<OpenConnection> Open()
    {
        lock (gate)
        {
            return
            [
                .. tickets.Values
                    .Select(ticket => new OpenConnection(
                        ticket.HostLabel, ticket.Address, ticket.StartedAt))
                    .OrderByDescending(open => open.StartedAt),
            ];
        }
    }

    /// <summary>Whether a vault is open behind this recorder.</summary>
    public bool IsOpen
    {
        get
        {
            lock (gate)
            {
                return binding is not null;
            }
        }
    }

    /// <summary>Starts recording into an unlocked vault.</summary>
    /// <param name="session">The unlocked session. Its active vault is the one written to.</param>
    /// <param name="actorUserId">Which account this is, recorded on every entry.</param>
    public void Open(VaultSession session, Guid actorUserId)
    {
        ArgumentNullException.ThrowIfNull(session);

        lock (gate)
        {
            binding = new Binding(session.ConnectionLog, session.ActiveVaultId, actorUserId);
        }
    }

    /// <summary>
    /// Stops recording new connections.
    /// </summary>
    /// <remarks>
    /// Open tickets are deliberately <em>not</em> discarded. Each already holds the repository it was opened
    /// against, so a shell still running when the vault locks closes out into the vault it was made from —
    /// which is the honest record. What is dropped is the ability to <em>start</em> a ticket, because a
    /// connection made while locked has no vault to belong to.
    /// </remarks>
    public void Close()
    {
        lock (gate)
        {
            binding = null;
        }
    }

    /// <inheritdoc />
    public void Opened(uint sessionId, string address, DateTimeOffset startedAt)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(address);

        lock (gate)
        {
            if (binding is not { } open)
            {
                return;
            }

            // The address stands in for the name until Identify supplies one, so a connection made by
            // something that never calls it is still recorded — with a worse label, which beats no entry.
            tickets[sessionId] = new OpenTicket(
                open, address, address, HostId: null, ConnectionKind.Terminal, startedAt);
        }
    }

    /// <summary>
    /// Names the host an already-open session belongs to.
    /// </summary>
    /// <param name="sessionId">The session, as the workspace knows it.</param>
    /// <param name="hostLabel">What the host is called in the keychain.</param>
    /// <param name="hostId">The host item.</param>
    /// <remarks>
    /// <para>
    /// The workspace takes an <c>SshConnectionRequest</c>, which has no notion of a keychain item, so it
    /// knows an address and nothing else. The label and the id arrive here instead, from the view model that
    /// does know — and as an amendment rather than a second ticket, so the start time stays the one the
    /// workspace recorded rather than the slightly later one this call would carry.
    /// </para>
    /// <para>
    /// A session id with no ticket is ignored, which is what a connection made while the vault was locked
    /// looks like.
    /// </para>
    /// </remarks>
    public void Identify(uint sessionId, string hostLabel, Guid? hostId)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);

        lock (gate)
        {
            if (tickets.TryGetValue(sessionId, out var ticket))
            {
                tickets[sessionId] = ticket with { HostLabel = hostLabel, HostId = hostId };
            }
        }
    }

    /// <inheritdoc />
    public void Closed(uint sessionId, DateTimeOffset endedAt)
    {
        OpenTicket ticket;

        lock (gate)
        {
            if (!tickets.Remove(sessionId, out var found))
            {
                // Never opened, already closed, or opened while the vault was locked. All three mean there
                // is nothing to record, and none of them is an error.
                return;
            }

            ticket = found;
        }

        Queue(ticket, endedAt, ConnectionOutcome.Closed);
    }

    /// <summary>
    /// Records a connection that was never a workspace session.
    /// </summary>
    /// <param name="address">The address that was dialled.</param>
    /// <param name="hostLabel">What the host is called.</param>
    /// <param name="hostId">The host item, if there was one.</param>
    /// <param name="kind">Which sort of session it was.</param>
    /// <param name="startedAt">When it began.</param>
    /// <param name="endedAt">When it ended, which is the same instant for an attempt that failed.</param>
    /// <param name="outcome">How it ended.</param>
    /// <remarks>
    /// <para>
    /// Two callers, both outside the terminal workspace's id space, which is why this takes no session id:
    /// a connection that never opened — the workspace throws out of <c>ConnectAsync</c> before an id exists,
    /// so there is nothing to open a ticket for — and an SFTP session, which is a separate connection
    /// entirely and would collide with a terminal's id if it borrowed one.
    /// </para>
    /// <para>
    /// A run of refusals against one host is the single most interesting thing a connection log can show,
    /// which is why the failures are recorded at all rather than only the sessions that worked.
    /// </para>
    /// </remarks>
    public void Record(
        string address,
        string hostLabel,
        Guid? hostId,
        ConnectionKind kind,
        DateTimeOffset startedAt,
        DateTimeOffset endedAt,
        ConnectionOutcome outcome)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(address);
        ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);

        Binding open;

        lock (gate)
        {
            if (binding is not { } current)
            {
                return;
            }

            open = current;
        }

        Queue(new OpenTicket(open, address, hostLabel, hostId, kind, startedAt), endedAt, outcome);
    }

    /// <summary>
    /// Closes out every still-open connection and writes what is queued, within a bounded wait.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Closing the application is the ordinary way a session ends</b>, and without this every one of them
    /// would be lost: the workspace's own close-outs happen while it tears its sessions down, which is after
    /// the vault they would be written into has gone. So the tickets are closed here instead, while there is
    /// still something to write to, and the durations run to the moment of exit — which is what actually
    /// happened.
    /// </para>
    /// <para>
    /// <b>The wait is bounded and the remainder is dropped.</b> An advisory log is never worth making a
    /// process refuse to exit, so a queue that will not drain costs its entries rather than the user's
    /// patience.
    /// </para>
    /// </remarks>
    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref disposed, 1) == 1)
        {
            return;
        }

        OpenTicket[] remaining;

        lock (gate)
        {
            remaining = [.. tickets.Values];
            tickets.Clear();
            binding = null;
        }

        var at = clock.GetUtcNow();

        foreach (var ticket in remaining)
        {
            Queue(ticket, at, ConnectionOutcome.Closed);
        }

        pending.Writer.TryComplete();

        try
        {
            await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
        }
        catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
        {
            // Whatever is left goes unwritten. Stated rather than logged: there is nowhere left to log it.
        }

        await lifetime.CancelAsync().ConfigureAwait(false);

        try
        {
            await drain.ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            // Expected: cancelling is how the loop is asked to stop.
        }

        lifetime.Dispose();
    }

    private void Queue(OpenTicket ticket, DateTimeOffset endedAt, ConnectionOutcome outcome)
    {
        // A duration rather than an end time, and clamped at zero: the two stamps come from the same clock,
        // but a machine that resumed from sleep between them can still produce a negative one, and the
        // payload refuses those outright.
        var duration = endedAt > ticket.StartedAt ? endedAt - ticket.StartedAt : TimeSpan.Zero;

        var entry = new ConnectionLogSecret
        {
            HostLabel = ticket.HostLabel,
            Address = ticket.Address,
            HostId = ticket.HostId,
            Kind = ticket.Kind,
            StartedAt = ticket.StartedAt,
            Duration = duration,
            Outcome = outcome,
            DeviceName = deviceName,
            ActorUserId = ticket.Binding.ActorUserId,
        };

        // TryWrite, never WriteAsync. The whole contract of this type is that the caller does not wait, and
        // a bounded channel with DropOldest never refuses anyway.
        pending.Writer.TryWrite(new PendingEntry(ticket.Binding, entry));
    }

    private async Task DrainAsync(CancellationToken cancellationToken)
    {
        try
        {
            await foreach (var item in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
            {
                try
                {
                    await item.Binding.Log
                        .CreateAsync(item.Binding.VaultId, item.Entry, cancellationToken)
                        .ConfigureAwait(false);
                }
                catch (Exception exception) when (exception is not OperationCanceledException)
                {
                    // Swallowed, and this is the rule rather than an omission: a log entry is advisory, and
                    // there is no caller left to tell. The realistic failures are a session disposed between
                    // the queue and the write — a shell closed after the vault locked — and a cache that has
                    // gone away underneath it. Neither is worth an unobserved exception on a background task.
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Shutting down.
        }
    }

    /// <summary>Which vault entries go to, and who is making them.</summary>
    private sealed record Binding(ConnectionLogRepository Log, Guid VaultId, Guid ActorUserId);

    /// <summary>A connection that has started and not yet been recorded.</summary>
    /// <remarks>
    /// It carries its own <see cref="Binding"/> rather than reading the current one at close time, which is
    /// what lets a session outlive the vault it was opened in without being filed into the next one.
    /// </remarks>
    private sealed record OpenTicket(
        Binding Binding,
        string Address,
        string HostLabel,
        Guid? HostId,
        ConnectionKind Kind,
        DateTimeOffset StartedAt);

    private sealed record PendingEntry(Binding Binding, ConnectionLogSecret Entry);
}
