// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
// 
// Permission is hereby granted, free of charge, to any person obtaining a 
// copy of this software and associated documentation files (the "Software"), 
// to deal in the Software without restriction, including without limitation 
// the rights to use, copy, modify, merge, publish, distribute, sublicense, 
// and/or sell copies of the Software, and to permit persons to whom the 
// Software is furnished to do so, subject to the following conditions:
// 
// The above copyright notice and this permission notice shall be included in 
// all copies or substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.Web;
using Duplicati.Server.Database;
using Duplicati.Server.Serialization.Interface;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;

namespace Duplicati.UnitTest;

public class BackupConfigMaskingTests
{
    [Test]
    public void MaskAndUnmaskBackupConfigRoundtrip()
    {
        var placeholder = Connection.PASSWORD_PLACEHOLDER;

        var original = new Backup
        {
            ID = "1",
            Name = "Test",
            Description = string.Empty,
            Tags = Array.Empty<string>(),
            TargetURL = "http://example.com?authid=secret&foo=bar",
            Sources = Array.Empty<string>(),
            Settings = new ISetting[]
            {
                new Setting { Name = "passphrase", Value = "secretpass", Filter = string.Empty },
                new Setting { Name = "nonsecret", Value = "keepme", Filter = string.Empty }
            },
            Filters = Array.Empty<IFilter>(),
            Metadata = new Dictionary<string, string>()
        };

        var backupConfig = new Backup
        {
            ID = original.ID,
            Name = original.Name,
            Description = original.Description,
            Tags = original.Tags.ToArray(),
            TargetURL = original.TargetURL,
            Sources = original.Sources.ToArray(),
            Settings = original.Settings.Select(s => new Setting { Name = s.Name, Value = s.Value, Filter = s.Filter }).ToArray(),
            Filters = Array.Empty<IFilter>(),
            Metadata = new Dictionary<string, string>(original.Metadata)
        };

        backupConfig.MaskSensitiveInformation();

        Assert.That(backupConfig.TargetURL, Does.Not.Contain("secret"));
        Assert.That(backupConfig.TargetURL, Does.Contain(placeholder));
        Assert.That(backupConfig.Settings.Single(s => s.Name == "passphrase").Value, Is.EqualTo(placeholder));
        Assert.That(backupConfig.Settings.Single(s => s.Name == "nonsecret").Value, Is.EqualTo("keepme"));

        var connectionStrings = new Dictionary<long, string>();
        backupConfig.UnmaskSensitiveInformation(original, connectionStrings);

        Assert.That(EqualsIgnoreQueryOrder(backupConfig.TargetURL, original.TargetURL));
        Assert.That(backupConfig.Settings.Single(s => s.Name == "passphrase").Value, Is.EqualTo("secretpass"));
    }

    [Test]
    public void QuerystringMaskingRoundtrip()
    {

        var placeholder = Connection.PASSWORD_PLACEHOLDER;
        var protectedNames = new[] { "authid" }.ToHashSet(StringComparer.OrdinalIgnoreCase);
        var original = "http://example.com?authid=secret&foo=bar";

        var masked = Server.QuerystringMasking.Mask(original, protectedNames);
        Assert.That(masked, Does.Not.Contain("secret"));
        Assert.That(masked, Does.Contain(placeholder));

        var restored = Server.QuerystringMasking.Unmask(masked, original);
        Assert.That(EqualsIgnoreQueryOrder(restored, original));
    }

    /// <summary>
    /// Helper to compare two URLs ignoring the order of query parameters
    /// </summary>
    /// <param name="url1">The first URL</param>
    /// <param name="url2">The second URL</param>
    /// <returns>True if the URLs are equal ignoring query parameter order</returns>
    public static bool EqualsIgnoreQueryOrder(string url1, string url2)
    {
        if (string.IsNullOrEmpty(url1) || string.IsNullOrEmpty(url2))
            return string.Equals(url1, url2, StringComparison.Ordinal);

        var uri1 = new Uri(url1, UriKind.RelativeOrAbsolute);
        var uri2 = new Uri(url2, UriKind.RelativeOrAbsolute);

        // Compare everything except query string
        if (!string.Equals(uri1.Scheme, uri2.Scheme, StringComparison.OrdinalIgnoreCase)) return false;
        if (!string.Equals(uri1.Host, uri2.Host, StringComparison.OrdinalIgnoreCase)) return false;
        if (uri1.Port != uri2.Port) return false;
        if (!string.Equals(uri1.AbsolutePath, uri2.AbsolutePath, StringComparison.Ordinal)) return false;
        if (!string.Equals(uri1.Fragment, uri2.Fragment, StringComparison.Ordinal)) return false;
        if (!string.Equals(uri1.UserInfo, uri2.UserInfo, StringComparison.Ordinal)) return false;

        // Normalize queries
        var q1 = HttpUtility.ParseQueryString(uri1.Query ?? "");
        var q2 = HttpUtility.ParseQueryString(uri2.Query ?? "");

        if (q1.Count != q2.Count) return false;

        foreach (var key in q1.AllKeys)
        {
            if (key == null) continue;

            var v1 = q1.GetValues(key) ?? Array.Empty<string>();
            var v2 = q2.GetValues(key) ?? Array.Empty<string>();

            // Must match value counts
            if (v1.Length != v2.Length) return false;

            // Order-insensitive value comparison
            var set1 = v1.OrderBy(x => x, StringComparer.Ordinal).ToArray();
            var set2 = v2.OrderBy(x => x, StringComparer.Ordinal).ToArray();

            if (!set1.SequenceEqual(set2, StringComparer.Ordinal)) return false;
        }

        return true;
    }

    [Test]
    public void ValidateBackupDetectsPlaceholder([Values(0, 1, 2)] int type)
    {
        var placeholder = type switch
        {
            0 => Connection.PASSWORD_PLACEHOLDER,
            1 => "***",
            2 => "%2A%2a%2A",
            _ => throw new ArgumentOutOfRangeException(nameof(type))
        };

        var backup = new Backup
        {
            ID = "1",
            Name = "Test",
            Description = string.Empty,
            Tags = Array.Empty<string>(),
            TargetURL = $"http://example.com?authid={placeholder}",
            Sources = new[] { "/source" },
            Settings = new ISetting[]
            {
                new Setting { Name = "passphrase", Value = placeholder, Filter = string.Empty }
            },
            Filters = Array.Empty<IFilter>(),
            Metadata = new Dictionary<string, string>()
        };

        // Suppress as we are in test code
#pragma warning disable SYSLIB0050 // Type or member is obsolete
        var conn = (Connection)FormatterServices.GetUninitializedObject(typeof(Connection));
#pragma warning restore SYSLIB0050 // Type or member is obsolete
        var err = conn.ValidateBackup(backup, null);

        if (string.IsNullOrEmpty(err))
            Assert.Ignore("ValidateBackup does not detect placeholder values");

        Assert.That(err, Is.Not.Null.And.Not.Empty);
    }

    [Test]
    public void Unmask_WithConnectionStringIdChanged_UsesConnectionStringForUnmasking()
    {
        var placeholder = Connection.PASSWORD_PLACEHOLDER;

        // Previous backup has connection string ID 1
        var previous = new Backup
        {
            ID = "1",
            Name = "Test",
            Description = string.Empty,
            Tags = Array.Empty<string>(),
            TargetURL = "http://old.example.com?authid=old-secret&foo=bar",
            ConnectionStringID = 1,
            Sources = Array.Empty<string>(),
            Settings = Array.Empty<ISetting>(),
            Filters = Array.Empty<IFilter>(),
            Metadata = new Dictionary<string, string>()
        };

        // New backup has different connection string ID 2 (changed)
        // The masked URL should be unmasked using the connection string, not the previous URL
        var newBackup = new Backup
        {
            ID = "1",
            Name = "Test",
            Description = string.Empty,
            Tags = Array.Empty<string>(),
            TargetURL = $"http://new.example.com?authid={placeholder}&foo=bar",
            ConnectionStringID = 2, // Changed from 1
            Sources = Array.Empty<string>(),
            Settings = Array.Empty<ISetting>(),
            Filters = Array.Empty<IFilter>(),
            Metadata = new Dictionary<string, string>()
        };

        // Connection strings dictionary - connection string ID 1 has the credentials
        var connectionStrings = new Dictionary<long, string>
        {
            [1] = "http://connection-string.example.com?authid=connection-secret&foo=bar"
        };

        newBackup.UnmaskSensitiveInformation(previous, connectionStrings);

        // Should use the connection string value, not the previous URL value
        Assert.That(newBackup.TargetURL, Does.Contain("authid=connection-secret"));
        Assert.That(newBackup.TargetURL, Does.Not.Contain("old-secret"));
        Assert.That(newBackup.TargetURL, Does.Not.Contain(placeholder));
    }

    [Test]
    public void Unmask_WithConnectionStringIdUnchanged_UsesPreviousUrlForUnmasking()
    {
        var placeholder = Connection.PASSWORD_PLACEHOLDER;

        // Previous backup has connection string ID 1
        var previous = new Backup
        {
            ID = "1",
            Name = "Test",
            Description = string.Empty,
            Tags = Array.Empty<string>(),
            TargetURL = "http://old.example.com?authid=old-secret&foo=bar",
            ConnectionStringID = 1,
            Sources = Array.Empty<string>(),
            Settings = Array.Empty<ISetting>(),
            Filters = Array.Empty<IFilter>(),
            Metadata = new Dictionary<string, string>()
        };

        // New backup has same connection string ID (unchanged)
        // The masked URL should be unmasked using the previous URL
        var newBackup = new Backup
        {
            ID = "1",
            Name = "Test",
            Description = string.Empty,
            Tags = Array.Empty<string>(),
            TargetURL = $"http://new.example.com?authid={placeholder}&foo=bar",
            ConnectionStringID = 1, // Same as previous
            Sources = Array.Empty<string>(),
            Settings = Array.Empty<ISetting>(),
            Filters = Array.Empty<IFilter>(),
            Metadata = new Dictionary<string, string>()
        };

        // Connection strings dictionary - should NOT be used when connectionStringID is unchanged
        var connectionStrings = new Dictionary<long, string>
        {
            [1] = "http://connection-string.example.com?authid=connection-secret&foo=bar"
        };

        newBackup.UnmaskSensitiveInformation(previous, connectionStrings);

        // Should use the previous URL value, not the connection string value
        Assert.That(newBackup.TargetURL, Does.Contain("authid=old-secret"));
        Assert.That(newBackup.TargetURL, Does.Not.Contain("connection-secret"));
        Assert.That(newBackup.TargetURL, Does.Not.Contain(placeholder));
    }

    [Test]
    public void Unmask_WithConnectionStringIdChangedButValueInPreviousUrl_CopiesFromPreviousUrl()
    {
        var placeholder = Connection.PASSWORD_PLACEHOLDER;

        // Previous backup has connection string ID 1 with authid in the URL
        var previous = new Backup
        {
            ID = "1",
            Name = "Test",
            Description = string.Empty,
            Tags = Array.Empty<string>(),
            TargetURL = "http://old.example.com?authid=old-secret&custom=value-from-prev",
            ConnectionStringID = 1,
            Sources = Array.Empty<string>(),
            Settings = Array.Empty<ISetting>(),
            Filters = Array.Empty<IFilter>(),
            Metadata = new Dictionary<string, string>()
        };

        // New backup has different connection string ID 2 (changed)
        // The masked URL has authid (should come from connection string) 
        // AND a custom param that only exists in previous URL
        var newBackup = new Backup
        {
            ID = "1",
            Name = "Test",
            Description = string.Empty,
            Tags = Array.Empty<string>(),
            // Note: custom param is masked but doesn't exist in connection string
            TargetURL = $"http://new.example.com?authid={placeholder}&custom={placeholder}",
            ConnectionStringID = 2, // Changed from 1
            Sources = Array.Empty<string>(),
            Settings = Array.Empty<ISetting>(),
            Filters = Array.Empty<IFilter>(),
            Metadata = new Dictionary<string, string>()
        };

        // Connection strings dictionary - has authid but NOT the custom param
        var connectionStrings = new Dictionary<long, string>
        {
            [1] = "http://connection-string.example.com?authid=connection-secret"
        };

        newBackup.UnmaskSensitiveInformation(previous, connectionStrings);

        // authid should come from connection string
        Assert.That(newBackup.TargetURL, Does.Contain("authid=connection-secret"));
        // custom should fall back to previous URL since it's not in connection string
        Assert.That(newBackup.TargetURL, Does.Contain("custom=value-from-prev"));
        Assert.That(newBackup.TargetURL, Does.Not.Contain(placeholder));
    }
}

