using DodoSSH.Client.Import;

namespace DodoSSH.Client.Import.Tests;

/// <summary>
/// Reading real-world <c>ssh_config</c> shapes.
/// </summary>
/// <remarks>
/// Every case here is something a person's actual file contains. The value of an importer is entirely in
/// whether it agrees with <c>ssh</c> about what a file means — an importer that is nearly right produces
/// bookmarks that nearly connect, which is worse than one that refused.
/// </remarks>
public sealed class SshConfigParserTests
{
    [Fact]
    public void APlainBlock_ResolvesItsFields()
    {
        var import = Read("""
            Host prod-db
              HostName db.internal
              User deploy
              Port 2222
            """);

        var host = import.Hosts.ShouldHaveSingleItem();

        host.Alias.ShouldBe("prod-db");
        host.Hostname.ShouldBe("db.internal");
        host.Username.ShouldBe("deploy");
        host.Port.ShouldBe(2222);
        host.Address.ShouldBe("deploy@db.internal:2222");
    }

    /// <remarks>
    /// OpenSSH's own default, and the reason <c>Host db.internal</c> on its own works at all.
    /// </remarks>
    [Fact]
    public void ABlockWithNoHostName_DialsItsAlias()
    {
        var host = Read("Host db.internal").Hosts.ShouldHaveSingleItem();

        host.Hostname.ShouldBe("db.internal");
        host.Port.ShouldBe(22);
        host.Username.ShouldBeNull();
    }

    [Fact]
    public void OneLineNamingSeveralHosts_YieldsOnePerName()
    {
        var import = Read("""
            Host web1 web2 web3
              User deploy
            """);

        import.Hosts.Select(host => host.Alias).ShouldBe(["web1", "web2", "web3"]);
        import.Hosts.ShouldAllBe(host => host.Username == "deploy");
    }

    /// <remarks>
    /// The rule that is not the intuitive one. A <c>Host *</c> block supplies what nothing earlier set and
    /// cannot override what it did — get this backwards and every imported host takes the wildcard's
    /// username.
    /// </remarks>
    [Fact]
    public void AWildcardBlock_SuppliesDefaultsAndDoesNotOverride()
    {
        var import = Read("""
            Host prod-db
              HostName db.internal
              User deploy

            Host *
              User root
              Port 2200
            """);

        var host = import.Hosts.ShouldHaveSingleItem();

        host.Username.ShouldBe("deploy", "the earlier block set it first");
        host.Port.ShouldBe(2200, "nothing earlier set a port");
    }

    [Fact]
    public void AWildcardBlock_IsNotItselfImported()
    {
        var import = Read("""
            Host *.internal
              User deploy

            Host prod-db
              HostName db.internal
            """);

        import.Hosts.ShouldHaveSingleItem().Alias.ShouldBe("prod-db");
        import.SkippedPatterns.ShouldContain("*.internal", StringComparer.Ordinal);
        import.Warnings.ShouldContain(warning => warning.Contains("*.internal", StringComparison.Ordinal));
    }

    [Fact]
    public void AWildcardBlock_AppliesToTheHostsItMatches()
    {
        var import = Read("""
            Host *.internal
              User deploy

            Host db.internal
            Host web.example.com
            """);

        Alias(import, "db.internal").Username.ShouldBe("deploy");
        Alias(import, "web.example.com").Username.ShouldBeNull();
    }

    [Theory]
    [InlineData("Host prod-db\n  Port=2222")]
    [InlineData("Host prod-db\n  Port = 2222")]
    [InlineData("Host prod-db\n\tPort\t2222")]
    public void TheFormsOpenSshAccepts_AllParse(string text)
    {
        Read(text).Hosts.ShouldHaveSingleItem().Port.ShouldBe(2222);
    }

    /// <remarks>
    /// A whole-line <c>#</c> only, which is OpenSSH's rule: a <c>#</c> partway through a line is part of the
    /// value, not the start of a comment. Treating it as one would silently truncate any value containing a
    /// hash.
    /// </remarks>
    [Fact]
    public void CommentsAndBlankLines_AreIgnored()
    {
        var import = Read("""
            # my hosts

            Host prod-db
              HostName db.internal

            #Host commented-out
            #  HostName nowhere
            """);

        import.Hosts.ShouldHaveSingleItem().Alias.ShouldBe("prod-db");
        import.Hosts.ShouldNotContain(host => host.Alias == "commented-out");
    }

    [Fact]
    public void AQuotedValue_KeepsItsSpaces()
    {
        var host = Read("""
            Host prod-db
              IdentityFile "C:\keys\my key"
            """).Hosts.ShouldHaveSingleItem();

        host.IdentityFiles.ShouldHaveSingleItem().ShouldBe(@"C:\keys\my key");
    }

    /// <remarks>
    /// The keyword that legitimately repeats — <c>ssh</c> tries each in turn — so it accumulates rather than
    /// being reported as a duplicate.
    /// </remarks>
    [Fact]
    public void SeveralIdentityFiles_AreAllKept()
    {
        var host = Read("""
            Host prod-db
              IdentityFile ~/.ssh/id_ed25519
              IdentityFile ~/.ssh/id_rsa
            """).Hosts.ShouldHaveSingleItem();

        host.IdentityFiles.Count.ShouldBe(2);
        host.Warnings.ShouldBeEmpty("repeating IdentityFile is not a mistake");
    }

    /// <remarks>
    /// <c>HostOptions</c> is unique by name and cannot represent a repeat, which its own remarks call an M1
    /// limitation the import path must surface rather than quietly resolve. This is that surfacing.
    /// </remarks>
    [Fact]
    public void ARepeatedKeyword_KeepsTheFirstAndSaysSo()
    {
        var host = Read("""
            Host prod-db
              ServerAliveInterval 30
              serveraliveinterval 60
            """).Hosts.ShouldHaveSingleItem();

        host.Options
            .Single(option => string.Equals(option.Name, "ServerAliveInterval", StringComparison.Ordinal))
            .Value.ShouldBe("30");
        host.Warnings.ShouldContain(warning =>
            warning.Contains("ServerAliveInterval", StringComparison.OrdinalIgnoreCase));
    }

    /// <remarks>
    /// Whether a <c>Match</c> applies depends on what is being connected to, or on a command's output.
    /// Neither is knowable from the file, so its directives are dropped — the failure to avoid is
    /// attributing them to whichever block happened to come before, which is what a parser that only knows
    /// about <c>Host</c> does.
    /// </remarks>
    [Fact]
    public void AMatchBlock_IsIgnoredAndItsDirectivesDoNotLeak()
    {
        var import = Read("""
            Host prod-db
              HostName db.internal

            Match host bastion
              User root
              Port 2200
            """);

        var host = import.Hosts.ShouldHaveSingleItem();

        host.Username.ShouldBeNull("a Match block's directives belong to nobody");
        host.Port.ShouldBe(22);

        import.Warnings.ShouldContain(warning => warning.Contains("Match", StringComparison.Ordinal));
    }

    [Fact]
    public void AnInclude_IsReadInPlace()
    {
        var import = SshConfigResolver.Resolve(SshConfigParser.Parse(
            """
            Include conf.d/*.conf

            Host *
              User fallback
            """,
            _ =>
            [
                """
                Host prod-db
                  HostName db.internal
                  User deploy
                """,
            ]));

        var host = import.Hosts.ShouldHaveSingleItem();

        host.Alias.ShouldBe("prod-db");
        host.Username.ShouldBe("deploy", "the include comes before the wildcard block that follows it");
    }

    /// <remarks>
    /// A file that includes itself would otherwise be read to the depth cap, importing the same hosts
    /// sixteen times — which reads as a bug in the importer rather than in the config.
    /// </remarks>
    [Fact]
    public void AnIncludeCycle_StopsAndSaysSo()
    {
        var import = SshConfigResolver.Resolve(SshConfigParser.Parse(
            "Include loop.conf",
            _ =>
            [
                """
                Include loop.conf
                Host prod-db
                """,
            ]));

        import.Hosts.ShouldHaveSingleItem().Alias.ShouldBe("prod-db");
        import.Warnings.ShouldContain(warning => warning.Contains("already being read", StringComparison.Ordinal));
    }

    [Fact]
    public void CrlfAndAByteOrderMark_ParseTheSameAsPlainText()
    {
        var import = Read("\ufeffHost prod-db\r\n  HostName db.internal\r\n  Port 2222\r\n");

        var host = import.Hosts.ShouldHaveSingleItem();

        host.Alias.ShouldBe("prod-db");
        host.Hostname.ShouldBe("db.internal");
        host.Port.ShouldBe(2222);
    }

    [Fact]
    public void AnUnusablePort_FallsBackToTwentyTwoAndSaysSo()
    {
        var host = Read("""
            Host prod-db
              Port not-a-number
            """).Hosts.ShouldHaveSingleItem();

        host.Port.ShouldBe(22);
        host.Warnings.ShouldContain(warning => warning.Contains("not-a-number", StringComparison.Ordinal));
    }

    /// <remarks>
    /// Recorded, and explicitly not honoured: the SSH layer has no jump hosts. A bastion topology that
    /// imported and quietly did not route would be the worst of the three options.
    /// </remarks>
    [Fact]
    public void ProxyJump_IsRecordedAsIntentAndFlagged()
    {
        var host = Read("""
            Host prod-db
              HostName db.internal
              ProxyJump bastion
            """).Hosts.ShouldHaveSingleItem();

        host.ProxyJump.ShouldBe("bastion");

        var secret = host.ToSecret();
        secret.Options.ShouldContain(option => option.Name == "ProxyJump" && option.Value == "bastion");
        secret.Notes.ShouldNotBeNull().ShouldContain("does not route");
    }

    [Fact]
    public void ProxyCommand_IsDroppedRatherThanStoredAsIfItWorked()
    {
        var host = Read("""
            Host prod-db
              ProxyCommand nc %h %p
            """).Hosts.ShouldHaveSingleItem();

        host.Options.ShouldNotContain(option => option.Name == "ProxyCommand");
        host.Warnings.ShouldContain(warning => warning.Contains("ProxyCommand", StringComparison.Ordinal));
    }

    /// <remarks>
    /// A directive that maps onto a first-class field must not <em>also</em> land in Options, or a host has
    /// two places recording its port and one of them will be forgotten on the next edit.
    /// </remarks>
    [Fact]
    public void FirstClassFields_DoNotAlsoAppearAsDirectives()
    {
        var secret = Read("""
            Host prod-db
              HostName db.internal
              User deploy
              Port 2222
              ServerAliveInterval 30
            """).Hosts.ShouldHaveSingleItem().ToSecret();

        secret.Options.Select(option => option.Name).ShouldBe(["ServerAliveInterval"]);
    }

    [Fact]
    public void AnEmptyConfiguration_YieldsNothingAndDoesNotThrow()
    {
        var import = Read("   \n\n# only a comment\n");

        import.Hosts.ShouldBeEmpty();
        import.SkippedPatterns.ShouldBeEmpty();
    }

    private static SshConfigImport Read(string text) =>
        SshConfigResolver.Resolve(SshConfigParser.Parse(text));

    private static ImportedHost Alias(SshConfigImport import, string alias) =>
        import.Hosts.Single(host => string.Equals(host.Alias, alias, StringComparison.Ordinal));
}
