using System.Text.Json;
using System.Text.Json.Serialization;

namespace DodoSSH.Client.Session;

/// <summary>
/// Preferences that belong to this machine rather than to the vault.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deliberately not in the cache, and deliberately not synced.</b> The cache is encrypted and cannot be
/// read until a vault is unlocked, and a font size is needed by the first terminal a locked launch draws.
/// Syncing it would be worse than useless: the size that suits a phone is not the size that suits a
/// 27-inch monitor, and one following the other around is a preference nobody asked for.
/// </para>
/// <para>
/// Nothing secret goes in here, and that is a rule rather than a description of what happens to be here
/// today. The file is plaintext in the profile directory; anything that would matter if it were read
/// belongs in the cache, behind the passphrase.
/// </para>
/// </remarks>
public sealed record ClientSettings
{
    /// <summary>The size a terminal draws at with nothing stored.</summary>
    /// <remarks>
    /// Matches the renderer's own default, and has to: the page creates panes at its own constant until
    /// the host tells it otherwise, so a different value here would show as a resize in front of the user
    /// on every launch.
    /// </remarks>
    public const int DefaultTerminalFontSize = 13;

    /// <summary>Smallest terminal font size the shell will set.</summary>
    /// <remarks>
    /// Not zero, and not one. Below about eight pixels a monospace grid stops being legible and starts
    /// being a texture — and every column of it is still a column the remote is being told exists.
    /// </remarks>
    public const int MinimumTerminalFontSize = 8;

    /// <summary>Largest terminal font size the shell will set.</summary>
    /// <remarks>
    /// A phone in portrait at 32px is about twenty columns, which is already narrower than most command
    /// output survives. The cap is what stops "bigger" arriving at a terminal that cannot show a prompt.
    /// </remarks>
    public const int MaximumTerminalFontSize = 32;

    /// <summary>The terminal font size, in CSS pixels.</summary>
    public int TerminalFontSize { get; init; } = DefaultTerminalFontSize;

    /// <summary>Brings a value inside the range this type will store.</summary>
    public static int ClampTerminalFontSize(int pixels) =>
        Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
}

/// <summary>
/// Reads and writes <see cref="ClientSettings"/> as a small JSON file.
/// </summary>
/// <remarks>
/// <para>
/// Every failure resolves to the defaults, and none of them throw. A missing file is the first launch, a
/// truncated one is a machine that lost power mid-write, and an unreadable one is a profile directory
/// somebody has been editing by hand. None of those is a reason to refuse to start an SSH client, and a
/// preference that cannot be read is a preference that was never set.
/// </para>
/// <para>
/// Written through a temporary file and moved into place, so an interrupted write leaves the previous
/// settings rather than half of the new ones.
/// </para>
/// </remarks>
public sealed class ClientSettingsStore(ClientPaths paths)
{
    /// <summary>Reads the stored settings, or the defaults.</summary>
    public ClientSettings Read()
    {
        try
        {
            if (!File.Exists(paths.SettingsFile))
            {
                return new ClientSettings();
            }

            var json = File.ReadAllText(paths.SettingsFile);

            var stored = JsonSerializer.Deserialize(json, ClientSettingsJsonContext.Default.ClientSettings);

            return stored is null
                ? new ClientSettings()
                : stored with
                {
                    // Clamped on the way in as well as on the way out. The file is editable by hand, and a
                    // 400-pixel terminal is a pane with no columns in it rather than an amusing mistake.
                    TerminalFontSize = ClientSettings.ClampTerminalFontSize(stored.TerminalFontSize),
                };
        }
        catch (Exception exception) when (exception is IOException
            or UnauthorizedAccessException
            or JsonException)
        {
            return new ClientSettings();
        }
    }

    /// <summary>Stores the settings, best effort.</summary>
    /// <returns>
    /// Whether they reached the disk. False is worth having rather than silence: it is the difference
    /// between a preference that will be there next launch and one that will not, and the caller is the
    /// only thing that could say so.
    /// </returns>
    public bool Write(ClientSettings settings)
    {
        ArgumentNullException.ThrowIfNull(settings);

        try
        {
            paths.EnsureCreated();

            var temporary = paths.SettingsFile + ".tmp";

            File.WriteAllText(
                temporary,
                JsonSerializer.Serialize(settings, ClientSettingsJsonContext.Default.ClientSettings));

            File.Move(temporary, paths.SettingsFile, overwrite: true);

            return true;
        }
        catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
        {
            return false;
        }
    }
}

[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    WriteIndented = true,
    UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ClientSettings))]
internal sealed partial class ClientSettingsJsonContext : JsonSerializerContext;
