using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;

namespace ClawdDotNet.Core.Tests.Security;

/// <summary>
/// S7 aus der Bestandsaufnahme: OpenRouter-Schlüssel, Datenbank-Verbindungszeichenfolgen
/// samt Passwort, Mail-Zugangsdaten und das Telegram-2FA-Passwort lagen im Klartext in
/// den JSON-Dateien. Wer die Dateien lesen konnte, hatte alle Zugänge.
///
/// Mit der Linux-Portierung kam <c>enc:v2</c> dazu (AES-GCM statt DPAPI). Die Tests
/// laufen deshalb gegen einen eigenen Schlüssel in einem Wegwerfverzeichnis — sonst
/// würden sie den echten Benutzerschlüssel anlegen oder lesen.
/// </summary>
[Collection(SecretKeyCollection.Name)]
public sealed class SecretProtectorTests : IDisposable
{
    private readonly string _keyDir;

    public SecretProtectorTests()
    {
        _keyDir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
        SecretKeyStore.UseDirectory(_keyDir);
    }

    public void Dispose()
    {
        SecretKeyStore.UseDirectory(null);
        try { Directory.Delete(_keyDir, recursive: true); }
        catch { /* Aufräumen ist Nebensache */ }
    }

    [Fact]
    public void Ein_verschluesselter_Wert_laesst_sich_wieder_lesen()
    {
        const string secret = "sk-or-v1-sehr-geheim-12345";

        var protectedValue = SecretProtector.Protect(secret);

        SecretProtector.Unprotect(protectedValue).ShouldBe(secret);
    }

    [Fact]
    public void Der_verschluesselte_Wert_enthaelt_den_Klartext_nicht()
    {
        const string secret = "sk-or-v1-sehr-geheim-12345";

        var protectedValue = SecretProtector.Protect(secret);

        protectedValue.ShouldNotContain(secret);
        protectedValue.ShouldNotBe(secret);
    }

    [Fact]
    public void Verschluesselte_Werte_sind_als_solche_erkennbar()
    {
        SecretProtector.IsProtected(SecretProtector.Protect("geheim")).ShouldBeTrue();
        SecretProtector.IsProtected("klartext").ShouldBeFalse();
    }

    [Fact]
    public void Mehrfaches_Verschluesseln_veraendert_nichts()
    {
        // Wichtig, weil beim Speichern nicht bekannt ist, ob ein Wert schon
        // verschlüsselt war.
        var once = SecretProtector.Protect("geheim");
        var twice = SecretProtector.Protect(once);

        twice.ShouldBe(once);
        SecretProtector.Unprotect(twice).ShouldBe("geheim");
    }

    [Fact]
    public void Klartext_aus_alten_Konfigurationen_wird_unveraendert_gelesen()
    {
        // Zwei Gründe, nicht nur einer:
        //
        // 1. Bestehende Installationen müssen ohne Zutun weiterlaufen.
        // 2. Die Erstinstallation über den update-agent schreibt Lizenzschlüssel und
        //    Deploymentcenter-Token als Klartext in die Settings.json — der Installer
        //    kennt unsere Verschlüsselung nicht. Beim ersten Speichern werden sie
        //    übernommen und verschlüsselt. Wer diesen Zweig entfernt, weil er nach
        //    Altlast aussieht, sperrt jede frisch installierte Instanz aus.
        SecretProtector.Unprotect("sk-or-alter-klartext").ShouldBe("sk-or-alter-klartext");
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    public void Leere_Werte_bleiben_leer(string? value)
    {
        SecretProtector.Protect(value).ShouldBe(value);
        SecretProtector.Unprotect(value).ShouldBe(value);
    }

    [Fact]
    public void Ein_beschaedigter_Wert_wird_gemeldet_statt_stillschweigend_durchgereicht()
    {
        // Nach Benutzer- oder Rechnerwechsel ist der Wert hier nicht lesbar. Ihn als
        // Klartext auszugeben würde einen unbrauchbaren Schlüssel an die API schicken.
        Should.Throw<SecretProtectionException>(
            () => SecretProtector.Unprotect("enc:v1:das-ist-kein-gueltiger-block"));

        Should.Throw<SecretProtectionException>(
            () => SecretProtector.Unprotect("enc:v2:das-ist-kein-gueltiger-block"));
    }

    [Fact]
    public void Umlaute_und_Sonderzeichen_ueberstehen_den_Vorgang()
    {
        const string secret = "P@ssw0rt mit Grüßen & Ümlauten 🦀";

        SecretProtector.Unprotect(SecretProtector.Protect(secret)).ShouldBe(secret);
    }

    // ═══════════════════════════════════════════════════════════
    //  Plattformübergreifendes Format (enc:v2)
    // ═══════════════════════════════════════════════════════════

    [Fact]
    public void Neue_Werte_werden_im_plattformuebergreifenden_Format_geschrieben()
    {
        // Der Kern der Portierung: Unter Linux gab die vorige Fassung hier den Klartext
        // zurück. Auf einem Server, der gesichert wird, war das schlechter als nichts.
        var value = SecretProtector.Protect("sk-or-v1-geheim");

        value.ShouldStartWith("enc:v2:");
    }

    [Fact]
    public void Dieselbe_Eingabe_ergibt_zweimal_verschiedene_Ausgaben()
    {
        // AES-GCM mit zufälligem Nonce: Aus gleichen Werten dürfen keine gleichen
        // Blöcke werden, sonst verrät die Konfigurationsdatei, wo dasselbe Passwort
        // mehrfach benutzt wird.
        var a = SecretProtector.Protect("dasselbe-passwort");
        var b = SecretProtector.Protect("dasselbe-passwort");

        a.ShouldNotBe(b);
        SecretProtector.Unprotect(a).ShouldBe("dasselbe-passwort");
        SecretProtector.Unprotect(b).ShouldBe("dasselbe-passwort");
    }

    [Fact]
    public void Ein_veraenderter_Block_wird_erkannt_und_nicht_entschluesselt()
    {
        // Der Zweck der Authentifizierung in AES-GCM: Ein manipulierter Block darf
        // keinen halb geratenen Klartext ergeben.
        var value = SecretProtector.Protect("sk-or-v1-geheim")!;
        var payload = Convert.FromBase64String(value["enc:v2:".Length..]);
        payload[^1] ^= 0xFF;
        var tampered = "enc:v2:" + Convert.ToBase64String(payload);

        Should.Throw<SecretProtectionException>(() => SecretProtector.Unprotect(tampered));
    }

    [Fact]
    public void Ein_fremder_Schluessel_kann_den_Wert_nicht_lesen()
    {
        // Genau die Eigenschaft, die den Schutz ausmacht: Die Konfigurationsdatei allein
        // nützt auf einem anderen Rechner nichts, weil der Schlüssel nicht mitreist.
        var value = SecretProtector.Protect("sk-or-v1-geheim");

        var fremd = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
        try
        {
            SecretKeyStore.UseDirectory(fremd);
            Should.Throw<SecretProtectionException>(() => SecretProtector.Unprotect(value));
        }
        finally
        {
            SecretKeyStore.UseDirectory(_keyDir);
            try { Directory.Delete(fremd, recursive: true); } catch { }
        }
    }

    [Fact]
    public void Der_Schluessel_wird_einmal_angelegt_und_dann_wiederverwendet()
    {
        var first = SecretProtector.Protect("geheim");
        var keyBytes = File.ReadAllBytes(SecretKeyStore.KeyFilePath);

        var second = SecretProtector.Protect("noch-geheimer");

        File.ReadAllBytes(SecretKeyStore.KeyFilePath).ShouldBe(keyBytes);
        SecretProtector.Unprotect(first).ShouldBe("geheim");
        SecretProtector.Unprotect(second).ShouldBe("noch-geheimer");
    }

    [LinuxFact]
    public void Die_Schluesseldatei_ist_nur_fuer_den_Besitzer_lesbar()
    {
        SecretProtector.Protect("geheim");

        File.GetUnixFileMode(SecretKeyStore.KeyFilePath)
            .ShouldBe(UnixFileMode.UserRead | UnixFileMode.UserWrite);
    }

    [LinuxFact]
    public void Ein_DPAPI_Wert_aus_einer_Windows_Instanz_wird_mit_Begruendung_abgewiesen()
    {
        // Der Umzugsfall. Ihn als Klartext durchzureichen würde einen unbrauchbaren
        // Schlüssel an die API schicken — die Meldung muss sagen, was zu tun ist.
        var ex = Should.Throw<SecretProtectionException>(
            () => SecretProtector.Unprotect("enc:v1:" + Convert.ToBase64String([1, 2, 3, 4])));

        ex.Message.ShouldContain("neu eingetragen");
    }
}

[Collection(SecretKeyCollection.Name)]
public sealed class ConfigSecretsTests : IDisposable
{
    private readonly string _keyDir;

    public ConfigSecretsTests()
    {
        _keyDir = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
        SecretKeyStore.UseDirectory(_keyDir);
    }

    public void Dispose()
    {
        SecretKeyStore.UseDirectory(null);
        try { Directory.Delete(_keyDir, recursive: true); }
        catch { /* Aufräumen ist Nebensache */ }
    }

    [Theory]
    [InlineData("password", true)]
    [InlineData("apiKey", true)]
    [InlineData("xApiKey", true)]
    [InlineData("connectionString", true)]
    [InlineData("password2FA", true)]
    [InlineData("APIKEY", true)]
    [InlineData("allowedTables", false)]
    [InlineData("smtpHost", false)]
    [InlineData("rootPath", false)]
    public void Nur_bekannte_Geheimnisfelder_werden_behandelt(string key, bool expected)
    {
        ConfigSecrets.IsSecretKey(key).ShouldBe(expected);
    }

    private static AgentConfig AgentWithSecrets() => new()
    {
        AgentId = "test",
        Tools =
        {
            ["Database"] = new Dictionary<string, object?>
            {
                ["type"] = "mysql",
                ["connectionString"] = "Server=x;Database=y;User=z;Password=geheim;",
                ["allowedTables"] = "prices"
            },
            ["Mail"] = new Dictionary<string, object?>
            {
                ["smtpHost"] = "smtp.example.com",
                ["password"] = "mail-passwort"
            }
        }
    };

    [Fact]
    public void Geheimnisse_einer_Agentenkonfiguration_werden_verschluesselt()
    {
        var agent = AgentWithSecrets();

        ConfigSecrets.Protect(agent);

        agent.Tools["Database"]["connectionString"]!.ToString().ShouldNotContain("geheim");
        agent.Tools["Mail"]["password"]!.ToString().ShouldNotContain("mail-passwort");
    }

    [Fact]
    public void Nicht_geheime_Felder_bleiben_lesbar()
    {
        var agent = AgentWithSecrets();

        ConfigSecrets.Protect(agent);

        agent.Tools["Database"]["type"].ShouldBe("mysql");
        agent.Tools["Mail"]["smtpHost"].ShouldBe("smtp.example.com");
    }

    [Fact]
    public void Der_Rundlauf_stellt_die_Originalwerte_wieder_her()
    {
        var agent = AgentWithSecrets();

        ConfigSecrets.Protect(agent);
        ConfigSecrets.Unprotect(agent);

        agent.Tools["Database"]["connectionString"]
            .ShouldBe("Server=x;Database=y;User=z;Password=geheim;");
        agent.Tools["Mail"]["password"].ShouldBe("mail-passwort");
    }

    [Fact]
    public void Werte_aus_deserialisiertem_JSON_werden_ebenfalls_erfasst()
    {
        // Nach dem Deserialisieren stecken die Werte als JsonElement im Wörterbuch,
        // nicht als String.
        var json = """
            { "agentId": "test",
              "tools": { "Mail": { "smtpHost": "smtp.example.com", "password": "geheim123" } } }
            """;
        var agent = JsonSerializer.Deserialize<AgentConfig>(json)!;

        ConfigSecrets.Protect(agent);

        agent.Tools["Mail"]["password"]!.ToString().ShouldNotContain("geheim123");
        ConfigSecrets.Unprotect(agent);
        agent.Tools["Mail"]["password"].ShouldBe("geheim123");
    }

    [Fact]
    public void Der_Instanzschluessel_und_Telegram_Zugangsdaten_werden_erfasst()
    {
        var config = new InstanceConfig
        {
            OpenRouterApiKey = "sk-or-v1-geheim",
            TelegramClient = new TelegramClientConfig
            {
                ApiHash = "hash-geheim",
                Password2FA = "2fa-geheim"
            }
        };

        ConfigSecrets.Protect(config);

        config.OpenRouterApiKey.ShouldNotContain("sk-or-v1-geheim");
        config.TelegramClient.ApiHash.ShouldNotContain("hash-geheim");
        config.TelegramClient.Password2FA.ShouldNotContain("2fa-geheim");

        ConfigSecrets.Unprotect(config);

        config.OpenRouterApiKey.ShouldBe("sk-or-v1-geheim");
        config.TelegramClient.Password2FA.ShouldBe("2fa-geheim");
    }

    [Fact]
    public void Eine_Konfiguration_ohne_Telegram_stoert_nicht()
    {
        var config = new InstanceConfig { OpenRouterApiKey = "sk-test", TelegramClient = null };

        Should.NotThrow(() => ConfigSecrets.Protect(config));
    }

    [Fact]
    public void Leere_Geheimnisfelder_bleiben_leer()
    {
        var agent = new AgentConfig
        {
            AgentId = "test",
            Tools = { ["Mail"] = new Dictionary<string, object?> { ["password"] = "" } }
        };

        ConfigSecrets.Protect(agent);

        agent.Tools["Mail"]["password"].ShouldBe("");
    }
}
