using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import;

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
/// <remarks>
/// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list
/// and pressed the button, which is what makes reading a file out of the user's home directory an offer
/// rather than an action.
/// </remarks>
internal sealed partial class ImportRowViewModel : ObservableObject
{
    private readonly ImportedHost host;

    internal ImportRowViewModel(ImportedHost host, bool alreadyPresent)
    {
        this.host = host;
        AlreadyPresent = alreadyPresent;

        // A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark
        // for one machine is a thing people genuinely want — but it should take a click rather than be the
        // default.
        IsSelected = !alreadyPresent;
    }

    internal ImportedHost Host => host;

    internal string Alias => host.Alias;

    internal string Address => host.Address;

    /// <summary>Whether a host with this address is already in the keychain.</summary>
    internal bool AlreadyPresent { get; }

    internal string Badge => AlreadyPresent ? "already here" : string.Empty;

    internal bool HasBadge => AlreadyPresent;

    /// <summary>How this would authenticate, in the terms the preview can honestly offer.</summary>
    /// <remarks>
    /// "a key on disk" rather than "a key", because nothing is imported: the path is recorded and the host
    /// will ask for a password until somebody binds it to a keychain key. Saying "key" here would promise a
    /// connection that does not work.
    /// </remarks>
    internal string Authentication => host.IdentityFiles.Count switch
    {
        0 => "password",
        1 => $"a key on disk · {host.IdentityFiles[0]}",
        var count => $"{count} keys on disk · {host.IdentityFiles[0]}",
    };

    internal bool HasWarnings => host.Warnings.Count > 0;

    internal string Warnings => string.Join(" ", host.Warnings);

    [ObservableProperty]
    private bool isSelected;
}

/// <summary>
/// Reading <c>~/.ssh/config</c> and offering what it found.
/// </summary>
/// <remarks>
/// <para>
/// <b>Two steps, and the first one writes nothing.</b> Scanning reads the file and shows what it means;
/// importing is a separate press. That split is the feature: an <c>ssh_config</c> is a file this
/// application did not write and may contain forty entries for machines that no longer exist, so the
/// interesting question is not "can it be parsed" but "which of these did you actually want".
/// </para>
/// <para>
/// <b>Nothing reads a private key.</b> An <c>IdentityFile</c> becomes a directive and a note recording the
/// path. Pulling someone's <c>~/.ssh/id_ed25519</c> into a keychain as a side effect of importing a config
/// is the one thing this screen must not do quietly; there is a GENERATE KEY button on the keychain screen
/// for making one deliberately, and pasting an existing one is a deliberate act too.
/// </para>
/// </remarks>
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
{
    internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];

    /// <summary>What was skipped or flattened, at document level.</summary>
    internal ObservableCollection<string> Warnings { get; } = [];

    /// <summary>The file this would read, shown so nobody has to guess which one it means.</summary>
    internal string ConfigPath => locator.ConfigPath;

    [ObservableProperty]
    private string status = string.Empty;

    [ObservableProperty]
    private bool hasScanned;

    [ObservableProperty]
    private bool isBusy;

    internal bool HasRows => Rows.Count > 0;

    internal bool HasWarnings => Warnings.Count > 0;

    internal int SelectedCount => Rows.Count(row => row.IsSelected);

    internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";

    /// <summary>Reads the file and shows what it found. Writes nothing.</summary>
    [RelayCommand]
    private async Task ScanAsync(CancellationToken cancellationToken)
    {
        Rows.Clear();
        Warnings.Clear();
        HasScanned = false;

        if (!locator.Exists)
        {
            Status = $"There is no {locator.ConfigPath} on this machine.";
            RaiseListState();
            return;
        }

        IsBusy = true;

        try
        {
            var import = await locator.ReadAsync(cancellationToken).ConfigureAwait(true);

            foreach (var host in import.Hosts)
            {
                Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host)));
            }

            foreach (var warning in import.Warnings)
            {
                Warnings.Add(warning);
            }

            HasScanned = true;

            Status = Rows.Count == 0
                ? "Nothing in that file could be imported as a host."
                : $"Found {Rows.Count} host(s). Nothing is stored until you press the button below.";
        }
        catch (IOException failure)
        {
            Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
        }
        catch (UnauthorizedAccessException failure)
        {
            Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
        }
        finally
        {
            IsBusy = false;
            RaiseListState();
        }
    }

    /// <summary>Stores the ticked hosts.</summary>
    [RelayCommand]
    private async Task ImportAsync(CancellationToken cancellationToken)
    {
        var chosen = Rows.Where(row => row.IsSelected).ToList();

        if (chosen.Count == 0)
        {
            Status = "Nothing is ticked.";
            return;
        }

        IsBusy = true;

        try
        {
            var imported = await vault
                .ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], cancellationToken)
                .ConfigureAwait(true);

            // Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
            // pressing the button twice harmless and visible rather than harmless and confusing.
            foreach (var row in Rows.ToList())
            {
                Rows[Rows.IndexOf(row)] = new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host));
            }

            Status = $"Imported {imported} host(s). They are on the Hosts screen.";
        }
        finally
        {
            IsBusy = false;
            RaiseListState();
        }
    }

    /// <summary>Ticks or unticks everything at once.</summary>
    [RelayCommand]
    private void ToggleAll()
    {
        var target = SelectedCount < Rows.Count;

        foreach (var row in Rows)
        {
            row.IsSelected = target;
        }

        RaiseListState();
    }

    internal void NoteSelectionChanged() => RaiseListState();

    /// <remarks>
    /// Matched on where a host points rather than on what it is called. Two entries with different aliases
    /// for one machine are the ordinary shape of an <c>ssh_config</c>, and matching on the name would offer
    /// to import a duplicate of something already stored under another name.
    /// </remarks>
    /// <summary>
    /// Whether this block describes a machine the vault already has.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Compared against the resolved host, not the stored one.</b> A stored host that takes its port and
    /// username from its group is the same machine as an imported block naming them outright — and comparing
    /// the stored fields would leave it unmatched, so the import screen would offer to add a duplicate of
    /// every host that inherits anything. Duplicates offered by a screen whose whole job is to say what is
    /// new are worse than a missed match: they get accepted.
    /// </para>
    /// <para>
    /// <b>An imported block with no <c>Port</c> still pins 22 rather than inheriting</b>, which
    /// <c>SshConfigResolver</c> already does and this deliberately leaves alone. Nothing imported is filed
    /// into a group — there is no group picker here — so an inherited port would resolve to 22 anyway, and
    /// the two would differ only in which of them a later edit to some group could change underneath the
    /// user. An absent <c>Port</c> in an ssh_config means 22; storing that is the faithful reading.
    /// </para>
    /// </remarks>
    private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing =>
        string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase)
        && existing.Resolved.Port.Value == host.Port
        && string.Equals(
            existing.Resolved.Username.Value, host.Username, StringComparison.OrdinalIgnoreCase));

    private void RaiseListState()
    {
        OnPropertyChanged(nameof(HasRows));
        OnPropertyChanged(nameof(HasWarnings));
        OnPropertyChanged(nameof(SelectedCount));
        OnPropertyChanged(nameof(ImportLabel));
    }
}
