using StellaOps.Feedser.Models;

namespace StellaOps.Feedser.Core.Tests;

public sealed class CanonicalMergerTests
{
    private static readonly DateTimeOffset BaseTimestamp = new(2025, 10, 10, 0, 0, 0, TimeSpan.Zero);

    [Fact]
    public void Merge_PrefersGhsaTitleAndSummaryByPrecedence()
    {
        var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));

        var ghsa = CreateAdvisory(
            source: "ghsa",
            advisoryKey: "GHSA-aaaa-bbbb-cccc",
            title: "GHSA Title",
            summary: "GHSA Summary",
            modified: BaseTimestamp.AddHours(1));

        var nvd = CreateAdvisory(
            source: "nvd",
            advisoryKey: "CVE-2025-0001",
            title: "NVD Title",
            summary: "NVD Summary",
            modified: BaseTimestamp);

        var result = merger.Merge("CVE-2025-0001", ghsa, nvd, null);

        Assert.Equal("GHSA Title", result.Advisory.Title);
        Assert.Equal("GHSA Summary", result.Advisory.Summary);

        Assert.Contains(result.Decisions, decision =>
            decision.Field == "summary" &&
            string.Equals(decision.SelectedSource, "ghsa", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));

        Assert.Contains(result.Advisory.Provenance, provenance =>
            string.Equals(provenance.Source, "ghsa", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(provenance.Value, "summary", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(provenance.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Merge_FreshnessOverrideUsesOsvSummaryWhenNewerByThreshold()
    {
        var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(10)));

        var ghsa = CreateAdvisory(
            source: "ghsa",
            advisoryKey: "GHSA-xxxx-yyyy-zzzz",
            title: "Container Escape Vulnerability",
            summary: "Initial GHSA summary.",
            modified: BaseTimestamp);

        var osv = CreateAdvisory(
            source: "osv",
            advisoryKey: "GHSA-xxxx-yyyy-zzzz",
            title: "Container Escape Vulnerability",
            summary: "OSV summary with additional mitigation steps.",
            modified: BaseTimestamp.AddHours(72));

        var result = merger.Merge("CVE-2025-9000", ghsa, null, osv);

        Assert.Equal("OSV summary with additional mitigation steps.", result.Advisory.Summary);

        Assert.Contains(result.Decisions, decision =>
            decision.Field == "summary" &&
            string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(decision.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));

        Assert.Contains(result.Advisory.Provenance, provenance =>
            string.Equals(provenance.Source, "osv", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(provenance.Value, "summary", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(provenance.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Merge_AffectedPackagesPreferOsvPrecedence()
    {
        var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(4)));

        var ghsaPackage = new AffectedPackage(
            AffectedPackageTypes.SemVer,
            "pkg:npm/example@1",
            platform: null,
            versionRanges: new[]
            {
                new AffectedVersionRange(
                    rangeKind: "semver",
                    introducedVersion: null,
                    fixedVersion: "1.2.3",
                    lastAffectedVersion: null,
                    rangeExpression: "<1.2.3",
                    provenance: CreateProvenance("ghsa", ProvenanceFieldMasks.VersionRanges),
                    primitives: null)
            },
            statuses: new[]
            {
                new AffectedPackageStatus(
                    "affected",
                    CreateProvenance("ghsa", ProvenanceFieldMasks.PackageStatuses))
            },
            provenance: new[] { CreateProvenance("ghsa", ProvenanceFieldMasks.AffectedPackages) },
            normalizedVersions: Array.Empty<NormalizedVersionRule>());

        var nvdPackage = new AffectedPackage(
            AffectedPackageTypes.SemVer,
            "pkg:npm/example@1",
            platform: null,
            versionRanges: new[]
            {
                new AffectedVersionRange(
                    rangeKind: "semver",
                    introducedVersion: null,
                    fixedVersion: "1.2.4",
                    lastAffectedVersion: null,
                    rangeExpression: "<1.2.4",
                    provenance: CreateProvenance("nvd", ProvenanceFieldMasks.VersionRanges),
                    primitives: null)
            },
            statuses: Array.Empty<AffectedPackageStatus>(),
            provenance: new[] { CreateProvenance("nvd", ProvenanceFieldMasks.AffectedPackages) },
            normalizedVersions: Array.Empty<NormalizedVersionRule>());

        var osvPackage = new AffectedPackage(
            AffectedPackageTypes.SemVer,
            "pkg:npm/example@1",
            platform: null,
            versionRanges: new[]
            {
                new AffectedVersionRange(
                    rangeKind: "semver",
                    introducedVersion: "1.0.0",
                    fixedVersion: "1.2.5",
                    lastAffectedVersion: null,
                    rangeExpression: ">=1.0.0,<1.2.5",
                    provenance: CreateProvenance("osv", ProvenanceFieldMasks.VersionRanges),
                    primitives: null)
            },
            statuses: Array.Empty<AffectedPackageStatus>(),
            provenance: new[] { CreateProvenance("osv", ProvenanceFieldMasks.AffectedPackages) },
            normalizedVersions: Array.Empty<NormalizedVersionRule>());

        var ghsa = CreateAdvisory("ghsa", "GHSA-1234", "GHSA Title", modified: BaseTimestamp.AddHours(1), packages: new[] { ghsaPackage });
        var nvd = CreateAdvisory("nvd", "CVE-2025-1111", "NVD Title", modified: BaseTimestamp.AddHours(2), packages: new[] { nvdPackage });
        var osv = CreateAdvisory("osv", "OSV-2025-xyz", "OSV Title", modified: BaseTimestamp.AddHours(3), packages: new[] { osvPackage });

        var result = merger.Merge("CVE-2025-1111", ghsa, nvd, osv);

        var package = Assert.Single(result.Advisory.AffectedPackages);
        Assert.Equal("pkg:npm/example@1", package.Identifier);
        Assert.Contains(package.Provenance, provenance =>
            string.Equals(provenance.Source, "osv", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(provenance.Kind, "merge", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(provenance.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));

        Assert.Contains(result.Decisions, decision =>
            decision.Field.StartsWith("affectedPackages", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Merge_CvssMetricsOrderedByPrecedence()
    {
        var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(5)));

        var nvdMetric = new CvssMetric("3.1", "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", 9.8, "critical", CreateProvenance("nvd", ProvenanceFieldMasks.CvssMetrics));
        var ghsaMetric = new CvssMetric("3.0", "CVSS:3.0/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H", 7.5, "high", CreateProvenance("ghsa", ProvenanceFieldMasks.CvssMetrics));

        var nvd = CreateAdvisory("nvd", "CVE-2025-2000", "NVD Title", severity: null, modified: BaseTimestamp, metrics: new[] { nvdMetric });
        var ghsa = CreateAdvisory("ghsa", "GHSA-9999", "GHSA Title", severity: null, modified: BaseTimestamp.AddHours(1), metrics: new[] { ghsaMetric });

        var result = merger.Merge("CVE-2025-2000", ghsa, nvd, null);

        Assert.Equal(2, result.Advisory.CvssMetrics.Length);
        Assert.Equal("nvd", result.Decisions.Single(decision => decision.Field == "cvssMetrics").SelectedSource);
        Assert.Equal("critical", result.Advisory.Severity);
        Assert.Contains(result.Advisory.CvssMetrics, metric => metric.Vector == "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H");
        Assert.Contains(result.Advisory.CvssMetrics, metric => metric.Vector == "CVSS:3.0/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:H");
        Assert.Equal("3.1|CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", result.Advisory.CanonicalMetricId);
    }

    [Fact]
    public void Merge_ReferencesNormalizedAndFreshnessOverrides()
    {
        var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(80)));

        var ghsa = CreateAdvisory(
            source: "ghsa",
            advisoryKey: "GHSA-ref",
            title: "GHSA Title",
            references: new[]
            {
                new AdvisoryReference(
                    "http://Example.COM/path/resource?b=2&a=1#section",
                    kind: "advisory",
                    sourceTag: null,
                    summary: null,
                    CreateProvenance("ghsa", ProvenanceFieldMasks.References))
            },
            modified: BaseTimestamp);

        var osv = CreateAdvisory(
            source: "osv",
            advisoryKey: "OSV-ref",
            title: "OSV Title",
            references: new[]
            {
                new AdvisoryReference(
                    "https://example.com/path/resource?a=1&b=2",
                    kind: "advisory",
                    sourceTag: null,
                    summary: null,
                    CreateProvenance("osv", ProvenanceFieldMasks.References))
            },
            modified: BaseTimestamp.AddHours(80));

        var result = merger.Merge("CVE-REF-2025-01", ghsa, null, osv);

        var reference = Assert.Single(result.Advisory.References);
        Assert.Equal("https://example.com/path/resource?a=1&b=2", reference.Url);

        var unionDecision = Assert.Single(result.Decisions.Where(decision => decision.Field == "references"));
        Assert.Null(unionDecision.SelectedSource);
        Assert.Equal("union", unionDecision.DecisionReason);

        var itemDecision = Assert.Single(result.Decisions.Where(decision => decision.Field.StartsWith("references[", StringComparison.OrdinalIgnoreCase)));
        Assert.Equal("osv", itemDecision.SelectedSource);
        Assert.Equal("freshness_override", itemDecision.DecisionReason);
        Assert.Contains("https://example.com/path/resource?a=1&b=2", itemDecision.Field, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public void Merge_DescriptionFreshnessOverride()
    {
        var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(12)));

        var ghsa = CreateAdvisory(
            source: "ghsa",
            advisoryKey: "GHSA-desc",
            title: "GHSA Title",
            summary: "Summary",
            description: "Initial GHSA description",
            modified: BaseTimestamp.AddHours(1));

        var nvd = CreateAdvisory(
            source: "nvd",
            advisoryKey: "CVE-2025-5555",
            title: "NVD Title",
            summary: "Summary",
            description: "NVD baseline description",
            modified: BaseTimestamp.AddHours(2));

        var osv = CreateAdvisory(
            source: "osv",
            advisoryKey: "OSV-2025-desc",
            title: "OSV Title",
            summary: "Summary",
            description: "OSV fresher description",
            modified: BaseTimestamp.AddHours(72));

        var result = merger.Merge("CVE-2025-5555", ghsa, nvd, osv);

        Assert.Equal("OSV fresher description", result.Advisory.Description);
        Assert.Contains(result.Decisions, decision =>
            decision.Field == "description" &&
            string.Equals(decision.SelectedSource, "osv", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(decision.DecisionReason, "freshness_override", StringComparison.OrdinalIgnoreCase));
    }

    [Fact]
    public void Merge_CwesPreferNvdPrecedence()
    {
        var merger = new CanonicalMerger(new FixedTimeProvider(BaseTimestamp.AddHours(6)));

        var ghsaWeakness = CreateWeakness("ghsa", "CWE-79", "cross-site scripting", BaseTimestamp.AddHours(1));
        var nvdWeakness = CreateWeakness("nvd", "CWE-79", "Cross-Site Scripting", BaseTimestamp.AddHours(2));
        var osvWeakness = CreateWeakness("osv", "CWE-79", "XSS", BaseTimestamp.AddHours(3));

        var ghsa = CreateAdvisory("ghsa", "GHSA-weakness", "GHSA Title", weaknesses: new[] { ghsaWeakness }, modified: BaseTimestamp.AddHours(1));
        var nvd = CreateAdvisory("nvd", "CVE-2025-7777", "NVD Title", weaknesses: new[] { nvdWeakness }, modified: BaseTimestamp.AddHours(2));
        var osv = CreateAdvisory("osv", "OSV-weakness", "OSV Title", weaknesses: new[] { osvWeakness }, modified: BaseTimestamp.AddHours(3));

        var result = merger.Merge("CVE-2025-7777", ghsa, nvd, osv);

        var weakness = Assert.Single(result.Advisory.Cwes);
        Assert.Equal("CWE-79", weakness.Identifier);
        Assert.Equal("Cross-Site Scripting", weakness.Name);
        Assert.Contains(result.Decisions, decision =>
            decision.Field == "cwes[cwe|CWE-79]" &&
            string.Equals(decision.SelectedSource, "nvd", StringComparison.OrdinalIgnoreCase) &&
            string.Equals(decision.DecisionReason, "precedence", StringComparison.OrdinalIgnoreCase));
    }

    private static Advisory CreateAdvisory(
        string source,
        string advisoryKey,
        string title,
        string? summary = null,
        string? description = null,
        DateTimeOffset? modified = null,
        string? severity = null,
        IEnumerable<AffectedPackage>? packages = null,
        IEnumerable<CvssMetric>? metrics = null,
        IEnumerable<AdvisoryReference>? references = null,
        IEnumerable<AdvisoryWeakness>? weaknesses = null,
        string? canonicalMetricId = null)
    {
        var provenance = new AdvisoryProvenance(
            source,
            kind: "map",
            value: advisoryKey,
            recordedAt: modified ?? BaseTimestamp,
            fieldMask: new[] { ProvenanceFieldMasks.Advisory });

        return new Advisory(
            advisoryKey,
            title,
            summary,
            language: "en",
            published: modified,
            modified: modified,
            severity: severity,
            exploitKnown: false,
            aliases: new[] { advisoryKey },
            credits: Array.Empty<AdvisoryCredit>(),
            references: references ?? Array.Empty<AdvisoryReference>(),
            affectedPackages: packages ?? Array.Empty<AffectedPackage>(),
            cvssMetrics: metrics ?? Array.Empty<CvssMetric>(),
            provenance: new[] { provenance },
            description: description,
            cwes: weaknesses ?? Array.Empty<AdvisoryWeakness>(),
            canonicalMetricId: canonicalMetricId);
    }

    private static AdvisoryProvenance CreateProvenance(string source, string fieldMask)
        => new(
            source,
            kind: "map",
            value: source,
            recordedAt: BaseTimestamp,
            fieldMask: new[] { fieldMask });

    private static AdvisoryWeakness CreateWeakness(string source, string identifier, string? name, DateTimeOffset recordedAt)
    {
        var provenance = new AdvisoryProvenance(
            source,
            kind: "map",
            value: identifier,
            recordedAt: recordedAt,
            fieldMask: new[] { ProvenanceFieldMasks.Weaknesses });

        return new AdvisoryWeakness("cwe", identifier, name, uri: null, provenance: new[] { provenance });
    }

    private sealed class FixedTimeProvider : TimeProvider
    {
        private readonly DateTimeOffset _utcNow;

        public FixedTimeProvider(DateTimeOffset utcNow)
        {
            _utcNow = utcNow;
        }

        public override DateTimeOffset GetUtcNow() => _utcNow;
    }
}
