using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Sunfish.Auth;
using Sunfish.Config;
using Sunfish.Net;
using Sunfish.Qr;
using Xunit;

namespace Sunfish.Tests;

public class AddressRulesTests
{
    [Theory]
    [InlineData("127.0.0.1", "127.0.0.1", true)]
    [InlineData("127.0.0.1", "127.0.0.2", false)]
    [InlineData("192.168.1.0/24", "192.168.1.77", true)]
    [InlineData("192.168.1.0/24", "192.168.2.1", false)]
    [InlineData("10.0.0.0/8", "10.200.3.4", true)]
    [InlineData("172.16.0.0/12", "172.31.255.1", true)]
    [InlineData("172.16.0.0/12", "172.32.0.1", false)]
    [InlineData("::1", "::1", true)]
    [InlineData("fd00::/8", "fd12:3456::1", true)]
    [InlineData("fd00::/8", "fe80::1", false)]
    [InlineData("127.0.0.1", "::ffff:127.0.0.1", true)] // IPv4 vista como IPv6
    [InlineData("*", "203.0.113.9", true)]
    public void MatchesAddressesAndNetworks(string rule, string address, bool expected) =>
        Assert.Equal(expected, AddressRules.Matches([rule], IPAddress.Parse(address)));

    [Theory]
    [InlineData("192.168.1.0/24", true)]
    [InlineData("::1", true)]
    [InlineData("*", true)]
    [InlineData("192.168.1.0/33", false)]
    [InlineData("localhost", false)]
    [InlineData("", false)]
    public void ValidatesRules(string rule, bool valid) => Assert.Equal(valid, AddressRules.IsValid(rule));
}

public class LegacyImportTests
{
    [Fact]
    public void ImportsTheV2Configuration()
    {
        using var dir = new TempDir();
        string json = $$"""
        {
          "Port": 9090, "Active": true, "SunfishRoot": false, "AdminPanel": true, "AdminPwd": "1234",
          "Services": [
            { "Type": "WebService", "Name": "Fotos", "Enabled": true, "Location": "fotos",
              "Settings": { "path": "{{dir.Path.Replace("\\", "\\\\")}}", "defaultFile": "index.html", "folderShare": true, "readOnly": false, "allowDeletion": true, "allowServerExecution": true } },
            { "Type": "WebService", "Name": "Solo lectura", "Enabled": false, "Location": "/docs",
              "Settings": [ { "Key": "path", "Value": "{{dir.Path.Replace("\\", "\\\\")}}" }, { "Key": "readOnly", "Value": true }, { "Key": "allowDeletion", "Value": true } ] },
            { "Type": "Sunfish.Plugins.HelloWorld", "Name": "Hola" }
          ]
        }
        """;
        var notes = new List<string>();
        var config = LegacyImport.FromV2(json, notes);
        Assert.NotNull(config);
        Assert.Equal(9090, config.Server.HttpPort);
        Assert.False(config.Server.RootMenu);
        Assert.False(config.Admin.Enabled);
        Assert.Equal(2, config.Shares.Count);

        var fotos = config.Shares[0];
        Assert.Equal("Fotos", fotos.Name);
        Assert.Equal("/fotos", fotos.Location);
        Assert.Equal(dir.Path, fotos.Path);
        Assert.Equal("index.html", fotos.Index);
        Assert.True(fotos.Listing && fotos.Upload && fotos.Overwrite && fotos.Rename && fotos.Delete);

        var ro = config.Shares[1];
        Assert.Equal("Sololectura", ro.Name); // sin espacios: no valen en el INI
        Assert.True(ShareSettings.IsValidName(ro.Name));
        Assert.False(ro.Enabled);
        Assert.False(ro.Upload || ro.Overwrite || ro.Rename || ro.Delete); // solo lectura: tampoco borra

        Assert.Equal(3, notes.Count); // panel, ejecucion y el servicio que no es web
        Assert.Null(SunfishConfig.Parse(config.ToText()).Errors.FirstOrDefault());
    }

    [Fact]
    public void LowPortsAreReplaced()
    {
        var notes = new List<string>();
        var config = LegacyImport.FromV2("{\"Port\": 90}", notes);
        Assert.Equal(8080, config!.Server.HttpPort);
        Assert.Single(notes);
    }

    [Fact]
    public void RejectsNonJson() => Assert.Null(LegacyImport.FromV2("[Server]\nName = x", []));
}

public class QrImageTests
{
    [Fact]
    public void PngHasTheExpectedSize()
    {
        var qr = QrCode.Encode("http://10.0.0.1:8080/");
        var png = QrImage.Png(qr, 3);
        Assert.Equal(new byte[] { 0x89, (byte)'P', (byte)'N', (byte)'G' }, png[..4]);
        int width = (png[16] << 24) | (png[17] << 16) | (png[18] << 8) | png[19];
        Assert.Equal((qr.Size + 8) * 3, width);
    }

    [Fact]
    public void HalfBlocksCoverTheWholeCode()
    {
        var qr = QrCode.Encode("http://10.0.0.1:8080/");
        var lines = QrImage.HalfBlocks(qr, 2).ToList();
        Assert.Equal((qr.Size + 4 + 1) / 2, lines.Count);
        Assert.All(lines, l => Assert.Equal(qr.Size + 4, l.Length));
    }
}

/// <summary>Panel de administracion contra el servidor real.</summary>
public class AdminServerTests : IAsyncLifetime
{
    private readonly TempDir _dir = new();
    private readonly TempDir _other = new();
    private TestServer _server = null!;
    private static readonly string Auth = "Authorization: Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:secreto")) + "\r\n";

    public Task InitializeAsync()
    {
        var config = new SunfishConfig();
        config.Admin.Enabled = true;
        config.Admin.PasswordHash = Passwords.Hash("secreto", 1000);
        config.Users.Add(new UserConfig { Name = "ana", PasswordHash = Passwords.Hash("1234", 1000) });
        config.Shares.Add(new ShareConfig { Name = "pub", Location = "/pub", Path = _dir.Path, Users = ["ana"] });
        _server = new TestServer(config);
        return Task.CompletedTask;
    }

    public async Task DisposeAsync()
    {
        await _server.DisposeAsync();
        _dir.Dispose();
        _other.Dispose();
    }

    private SunfishConfig Config => _server.Host.Config;

    private async Task<string> TokenAsync(string action)
    {
        var page = await _server.GetAsync(action == "share-save" ? "/$sunfish/admin/share" : "/$sunfish/admin/", extraHeaders: Auth);
        var m = Regex.Match(page.BodyText, $"NAME=\"a\" VALUE=\"{action}\"><INPUT TYPE=HIDDEN NAME=\"t\" VALUE=\"([^\"]+)\"");
        Assert.True(m.Success, "no form for " + action);
        return m.Groups[1].Value;
    }

    private async Task<RawResponse> PostAsync(string action, string fields, string? token = null)
    {
        token ??= await TokenAsync(action);
        string body = $"a={action}&t={Uri.EscapeDataString(token)}&{fields}";
        return await _server.SendAsync("POST /$sunfish/admin/ HTTP/1.0\r\n" + Auth + "Content-Type: application/x-www-form-urlencoded\r\n" +
                                       $"Content-Length: {Encoding.Latin1.GetByteCount(body)}\r\n\r\n{body}");
    }

    [Fact]
    public async Task RequiresThePassword()
    {
        var none = await _server.GetAsync("/$sunfish/admin/");
        Assert.Equal(401, none.Status);
        Assert.Contains("admin", none.Headers["WWW-Authenticate"]);
        var wrong = await _server.GetAsync("/$sunfish/admin/", extraHeaders: "Authorization: Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:no")) + "\r\n");
        Assert.Equal(401, wrong.Status);
        var ok = await _server.GetAsync("/$sunfish/admin/", extraHeaders: Auth);
        Assert.Equal(200, ok.Status);
        Assert.Contains("/pub", ok.BodyText);
        Assert.Contains("qr?u=http", ok.BodyText.Replace("%3A", ":"), StringComparison.Ordinal);
        Assert.DoesNotContain("<SCRIPT", ok.BodyText, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task DisabledOrNotAllowedIsHidden()
    {
        var copy = Config.Clone();
        copy.Admin.AllowFrom = ["10.0.0.0/8"];
        await _server.Host.ApplyAsync(copy);
        Assert.Equal(403, (await _server.GetAsync("/$sunfish/admin/", extraHeaders: Auth)).Status);

        copy = Config.Clone();
        copy.Admin.Enabled = false;
        await _server.Host.ApplyAsync(copy);
        Assert.Equal(404, (await _server.GetAsync("/$sunfish/admin/", extraHeaders: Auth)).Status);
    }

    [Fact]
    public async Task ChangesTheServerSettings()
    {
        var r = await PostAsync("server", "name=Prueba&ftpport=2999&rootmenu=1");
        Assert.Equal(302, r.Status);
        Assert.Equal("Prueba", Config.Server.Name);
        Assert.False(Config.Server.Ftp);
        Assert.Equal(2999, Config.Server.FtpPort);
        Assert.False(_server.Host.FtpRunning);
        Assert.True(_server.Host.HttpRunning);
    }

    [Fact]
    public async Task InvalidTokenChangesNothing()
    {
        var r = await PostAsync("server", "name=Malo&ftpport=2999", token: "falso");
        Assert.Equal(200, r.Status);
        Assert.NotEqual("Malo", Config.Server.Name);
    }

    [Fact]
    public async Task AddsEditsAndDeletesShares()
    {
        string path = Uri.EscapeDataString(_other.Path);
        var bad = await PostAsync("share-save", "orig=&name=nuevo&location=%2Fnuevo&path=C%3A%5Cno%5Cexiste&enabled=1");
        Assert.Equal(200, bad.Status);
        Assert.DoesNotContain(Config.Shares, s => s.Name == "nuevo");

        var r = await PostAsync("share-save", $"orig=&name=nuevo&location=%2Fnuevo&path={path}&index=&enabled=1&listing=1&upload=1&u.ana=1");
        Assert.Equal(302, r.Status);
        var share = Assert.Single(Config.Shares, s => s.Name == "nuevo");
        Assert.True(share.Upload);
        Assert.False(share.Delete);
        Assert.Equal(["ana"], share.Users);
        Assert.Equal(200, (await _server.GetAsync("/$sunfish/admin/share?n=nuevo", extraHeaders: Auth)).Status);

        r = await PostAsync("share-save", $"orig=nuevo&name=renombrado&location=%2Fnuevo&path={path}&enabled=1");
        Assert.Equal(302, r.Status);
        Assert.Contains(Config.Shares, s => s.Name == "renombrado" && !s.Upload && s.Users.Count == 0);
        Assert.DoesNotContain(Config.Shares, s => s.Name == "nuevo");

        r = await PostAsync("share-del", "n=renombrado");
        Assert.Equal(302, r.Status);
        Assert.DoesNotContain(Config.Shares, s => s.Name == "renombrado");
    }

    [Fact]
    public async Task ManagesUsers()
    {
        Assert.Equal(200, (await PostAsync("user-add", "name=luis&p1=a&p2=b")).Status); // no coinciden
        Assert.DoesNotContain(Config.Users, u => u.Name == "luis");
        Assert.Equal(302, (await PostAsync("user-add", "name=luis&p1=clave&p2=clave")).Status);
        Assert.True(Passwords.Verify("clave", Config.Users.Single(u => u.Name == "luis").PasswordHash));
        Assert.Equal(302, (await PostAsync("user-pass", "name=luis&p1=otra&p2=otra")).Status);
        Assert.True(Passwords.Verify("otra", Config.Users.Single(u => u.Name == "luis").PasswordHash));
        Assert.Equal(200, (await PostAsync("user-add", "name=admin&p1=x&p2=x")).Status); // reservado

        Assert.Equal(302, (await PostAsync("user-del", "name=ana")).Status);
        Assert.DoesNotContain(Config.Users, u => u.Name == "ana");
        Assert.Empty(Config.Shares.Single(s => s.Name == "pub").Users);
    }

    [Fact]
    public async Task PanelSettingsNeedTheCurrentPasswordAndCannotLockOut()
    {
        Assert.Equal(200, (await PostAsync("admin", "allow=127.0.0.1&cur=mala")).Status);
        Assert.Equal(200, (await PostAsync("admin", "allow=10.0.0.0%2F8&cur=secreto")).Status);
        Assert.Contains("127.0.0.1", Config.Admin.AllowFrom);
        Assert.Equal(200, (await PostAsync("admin", "allow=nada&cur=secreto")).Status);

        Assert.Equal(302, (await PostAsync("admin", "allow=127.0.0.1%2C+192.168.0.0%2F16&cur=secreto")).Status);
        Assert.Equal(["127.0.0.1", "192.168.0.0/16"], Config.Admin.AllowFrom);
        Assert.Equal(302, (await PostAsync("admin", "allow=127.0.0.1&cur=secreto&p1=nueva&p2=nueva")).Status);
        Assert.True(Passwords.Verify("nueva", Config.Admin.PasswordHash));
    }

    [Fact]
    public async Task ServesQrAndLog()
    {
        var qr = await _server.GetAsync("/$sunfish/admin/qr?u=" + Uri.EscapeDataString("http://10.0.0.1:8080/"), extraHeaders: Auth);
        Assert.Equal("image/png", qr.Headers["Content-Type"]);
        Assert.Equal(404, (await _server.GetAsync("/$sunfish/admin/qr?u=javascript:alert(1)", extraHeaders: Auth)).Status);
        var log = await _server.GetAsync("/$sunfish/admin/log", extraHeaders: Auth);
        Assert.Contains("<PRE>", log.BodyText);
    }
}
