using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Layout;
using Avalonia.VisualTree;

namespace DodoSSH.Client.App.Layout.Tests;

/// <summary>
/// The button shapes that centre their caption do, and the two that deliberately do not still fill.
/// </summary>
/// <remarks>
/// <para>
/// ◆ THE DEFECT THIS EXISTS FOR was read as a height problem and was not one. <c>App.axaml</c>'s
/// <c>Button.ghost, Button.accent, Button.danger</c> rule set <c>VerticalAlignment</c> — where the button
/// sits in its parent — and never <c>VerticalContentAlignment</c>, where the caption sits in the button.
/// Avalonia's default for the second is <c>Stretch</c>, so on any of these given a fixed <c>Height</c> the
/// content presenter stretched the caption's <see cref="TextBlock"/> to the whole content box, and a
/// <see cref="TextBlock"/> draws its line at the TOP of its bounds. The hosts toolbar's three 40-pixel
/// buttons measured 9 pixels above the ink and 20 below it. Every box was the height it declared, which is
/// exactly why it read as one being wrong: nothing was mis-sized, the labels sat in the top third.
/// </para>
/// <para>
/// A bare <see cref="Button"/> rather than a screen, because <c>Application.Styles</c> is global — the same
/// property <see cref="LayoutHarnessTests"/> leans on — so this measures the style rule itself rather than
/// one of the hundred-odd places it lands. A screen-level test would pin one toolbar and leave the dialogs,
/// the drawer's Save/Cancel pair and the import screen's buttons uncovered by the thing that fixed them all.
/// </para>
/// <para>
/// ◆ THE LINE BOX IS MEASURED, NOT THE TextBlock's ARRANGED BOUNDS, and the difference is the whole test.
/// A stretched caption's <see cref="Visual.Bounds"/> fill the content box, so they are symmetrical about
/// the button's middle under the defect just as they are under the fix — the first draft of this suite
/// asserted on them, passed on both, and was caught only by
/// <see cref="AStretchedCaption_IsNotCentred_AndIsCaught"/>. What actually moves is where the line sits
/// INSIDE those bounds: <see cref="TextBlock"/> draws at the top of whatever it is given, so the ink is
/// centred only when the box it is drawn in is its own line box.
/// </para>
/// <para>
/// The gaps are compared to each other rather than to a number. What the caption's own line box measures is
/// a property of JetBrains Mono at whichever size the caller set — 11.5 by default and 13.5 on the primary
/// action — so an absolute expectation would be a font metric written down in a test file, and it would move
/// the day the face does. "Centred" survives both.
/// </para>
/// <para>
/// The five shapes beyond the original three were swept in afterwards, and none of them was misbehaving
/// when it was: every one is content-sized everywhere it is used today, so <c>Stretch</c> and <c>Center</c>
/// agreed and the change moved nothing — 113 buttons across 29 screens measured byte-identical before and
/// after. What the sweep buys is that the day any of them is given a height, it is already right. That is
/// also why <see cref="AStretchingShapeStillFillsItsButton"/> matters more than it looks: the same
/// reasoning applied to <c>flat</c> or <c>cat</c> would break a pill and a strip that are currently
/// correct.
/// </para>
/// </remarks>
public sealed class ButtonCaptionTests
{
    /// <summary>Taller than any caption these carry, which is the condition that exposes the defect.</summary>
    /// <remarks>
    /// The hosts and keychain toolbars' own number. A button left to size itself cannot show this at all:
    /// its content box is its caption's line box exactly, so <c>Stretch</c> and <c>Center</c> agree and a
    /// test written against one would pass under either.
    /// </remarks>
    private const double FixedHeight = 40;

    /// <remarks>One pixel, for a content box whose odd leftover cannot be halved evenly.</remarks>
    private const double Tolerance = 1;

    private static CancellationToken Token => TestContext.Current.CancellationToken;

    [Theory]
    [InlineData("ghost")]
    [InlineData("accent")]
    [InlineData("danger")]
    [InlineData("navuser")]
    [InlineData("poprow")]
    [InlineData("panechip")]
    [InlineData("chiptoggle")]
    [InlineData("choice")]
    public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
    {
        await MeasureAsync(
            shape,
            alignment: null,
            (above, below) =>
                Math.Abs(above - below).ShouldBeLessThanOrEqualTo(
                    Tolerance,
                    string.Create(
                        CultureInfo.InvariantCulture,
                        $"Button.{shape} left {above:0.#} above the caption and {below:0.#} below it.")));
    }

    /// <summary>
    /// The instrument's own calibration: the check fails on the arrangement the fix replaced.
    /// </summary>
    /// <remarks>
    /// The same practice <see cref="LayoutHarnessTests"/> states for the clipping harness, and it earns its
    /// place here for the same reason. Everything above passes if <c>VerticalContentAlignment</c> is Center
    /// AND it passes if the caption happens to fill its box, so without this a rule quietly reverted to
    /// Stretch would have to be caught by eye again. Stretch is set inline here rather than by editing the
    /// style sheet, because <c>Application.Styles</c> is global and a test that mutated it would be changing
    /// every other test in the assembly out from under itself.
    /// </remarks>
    [Fact]
    public async Task AStretchedCaption_IsNotCentred_AndIsCaught()
    {
        await MeasureAsync(
            "ghost",
            VerticalAlignment.Stretch,
            (above, below) => (below - above).ShouldBeGreaterThan(
                Tolerance,
                "the caption should sit high, which is the defect this suite was written for"));
    }

    /// <summary>
    /// <c>flat</c> and <c>cat</c> are excluded from the rule above, and must stay excluded.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Both stretch their content on purpose, and both would be silently broken by a later pass that
    /// "finished" the sweep the rest of these classes belong to — which is exactly why this is a test and
    /// not a comment.
    /// </para>
    /// <para>
    /// <c>flat</c> carries the titlebar's search pill, a <c>Border.searchpill</c> with no height of its own
    /// that is meant to fill all 35 pixels of the button; the usage states
    /// <c>HorizontalContentAlignment="Stretch"</c> and relies on the vertical default matching it. Centring
    /// from the style would shrink that pill to its caption's line box inside a button twice as tall.
    /// <c>cat</c> carries the keychain rail's accent strip, a <c>Border.rowmark</c> whose style sets
    /// <c>Width="2"</c> and no height at all — "at full row height", says the rule's own remark — so its
    /// height is the stretch and nothing else.
    /// </para>
    /// <para>
    /// Asserted as "the content fills the button", not as "the caption is off-centre": what these two need
    /// is the fill, and a test phrased the other way would still pass if the fill broke in some new way.
    /// </para>
    /// </remarks>
    [Theory]
    [InlineData("flat")]
    [InlineData("cat")]
    public async Task AStretchingShapeStillFillsItsButton(string shape)
    {
        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                // A bare Border is what both of them actually hold: no height, sized only by its parent.
                var fill = new Border();
                var button = new Button { Content = fill, Height = FixedHeight };
                button.Classes.Add(shape);

                var window = LayoutHarness.HostAtMinimumSize(
                    button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);

                try
                {
                    // The slot read off the presenter rather than recomputed from the button's Padding:
                    // these shapes differ in whether their presenter also draws a border, and a hand-rolled
                    // sum was two pixels out on Button.cat for exactly that reason.
                    var presenter = button.GetVisualDescendants()
                        .OfType<ContentPresenter>()
                        .Single(p => string.Equals(p.Name, "PART_ContentPresenter", StringComparison.Ordinal));

                    var slot = presenter.Bounds.Height
                        - presenter.Padding.Top - presenter.Padding.Bottom
                        - presenter.BorderThickness.Top - presenter.BorderThickness.Bottom;

                    fill.Bounds.Height.ShouldBe(
                        slot,
                        Tolerance,
                        $"Button.{shape} must stretch its content — the search pill and the rail's accent "
                        + "strip have no height of their own");
                }
                finally
                {
                    window.Close();
                }
            },
            Token);
    }

    private static Task MeasureAsync(
        string shape, VerticalAlignment? alignment, Action<double, double> assert) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var button = new Button { Content = "Group ▾", Height = FixedHeight };
                button.Classes.Add(shape);

                if (alignment is { } forced)
                {
                    button.VerticalContentAlignment = forced;
                }

                var window = LayoutHarness.HostAtMinimumSize(
                    button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);

                try
                {
                    var caption = button.GetVisualDescendants().OfType<TextBlock>().Single();
                    var origin = caption.TranslatePoint(default, button);

                    origin.ShouldNotBeNull("the caption is not in the button's visual tree");

                    // The laid-out line rather than caption.Bounds.Height, which under Stretch is the
                    // content box and so is symmetrical whether or not the ink in it is — see the remark on
                    // the class.
                    var line = caption.TextLayout.Height;

                    var above = origin.Value.Y;
                    var below = button.Bounds.Height - above - line;

                    assert(above, below);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);
}
