using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Feedser.Models;

namespace StellaOps.Feedser.Core;

/// <summary>
/// Resolves conflicts between GHSA, NVD, and OSV advisories into a single canonical advisory following
/// <c>DEDUP_CONFLICTS_RESOLUTION_ALGO.md</c>.
/// </summary>
public sealed class CanonicalMerger
{
    private const string GhsaSource = "ghsa";
    private const string NvdSource = "nvd";
    private const string OsvSource = "osv";

    private static readonly ImmutableDictionary<string, string[]> FieldPrecedence = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
    {
        ["title"] = new[] { GhsaSource, NvdSource, OsvSource },
        ["summary"] = new[] { GhsaSource, NvdSource, OsvSource },
        ["description"] = new[] { GhsaSource, NvdSource, OsvSource },
        ["language"] = new[] { GhsaSource, NvdSource, OsvSource },
        ["severity"] = new[] { NvdSource, GhsaSource, OsvSource },
        ["references"] = new[] { GhsaSource, NvdSource, OsvSource },
        ["credits"] = new[] { GhsaSource, OsvSource, NvdSource },
        ["affectedPackages"] = new[] { OsvSource, GhsaSource, NvdSource },
        ["cvssMetrics"] = new[] { NvdSource, GhsaSource, OsvSource },
        ["cwes"] = new[] { NvdSource, GhsaSource, OsvSource },
    }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);

    private static readonly ImmutableHashSet<string> FreshnessSensitiveFields = ImmutableHashSet.Create(
        StringComparer.OrdinalIgnoreCase,
        "title",
        "summary",
        "description",
        "references",
        "credits",
        "affectedPackages");

    private static readonly ImmutableDictionary<string, int> SourceOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
    {
        [GhsaSource] = 0,
        [NvdSource] = 1,
        [OsvSource] = 2,
    }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);

    private readonly TimeProvider _timeProvider;
    private readonly TimeSpan _freshnessThreshold;

    public CanonicalMerger(TimeProvider? timeProvider = null, TimeSpan? freshnessThreshold = null)
    {
        _timeProvider = timeProvider ?? TimeProvider.System;
        _freshnessThreshold = freshnessThreshold ?? TimeSpan.FromHours(48);
    }

    public CanonicalMergeResult Merge(string advisoryKey, Advisory? ghsa, Advisory? nvd, Advisory? osv)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);

        var candidates = BuildCandidates(ghsa, nvd, osv);
        if (candidates.Count == 0)
        {
            throw new ArgumentException("At least one advisory must be provided.", nameof(advisoryKey));
        }

        var now = _timeProvider.GetUtcNow();
        var decisions = new List<FieldDecision>();
        var provenanceSet = new HashSet<AdvisoryProvenance>();

        foreach (var candidate in candidates)
        {
            foreach (var existingProvenance in candidate.Advisory.Provenance)
            {
                provenanceSet.Add(existingProvenance);
            }
        }

        var titleSelection = SelectStringField("title", candidates, advisory => advisory.Title, isFreshnessSensitive: true);
        if (titleSelection.HasValue)
        {
            decisions.Add(titleSelection.Decision);
            AddMergeProvenance(provenanceSet, titleSelection, now, ProvenanceFieldMasks.Advisory);
        }

        var summarySelection = SelectStringField("summary", candidates, advisory => advisory.Summary, isFreshnessSensitive: true);
        if (summarySelection.HasValue)
        {
            decisions.Add(summarySelection.Decision);
            AddMergeProvenance(provenanceSet, summarySelection, now, ProvenanceFieldMasks.Advisory);
        }

        var descriptionSelection = SelectStringField("description", candidates, advisory => advisory.Description, isFreshnessSensitive: true);
        if (descriptionSelection.HasValue)
        {
            decisions.Add(descriptionSelection.Decision);
            AddMergeProvenance(provenanceSet, descriptionSelection, now, ProvenanceFieldMasks.Advisory);
        }

        var languageSelection = SelectStringField("language", candidates, advisory => advisory.Language, isFreshnessSensitive: false);
        if (languageSelection.HasValue)
        {
            decisions.Add(languageSelection.Decision);
            AddMergeProvenance(provenanceSet, languageSelection, now, ProvenanceFieldMasks.Advisory);
        }

        var topLevelSeveritySelection = SelectStringField("severity", candidates, advisory => advisory.Severity, isFreshnessSensitive: false);
        if (topLevelSeveritySelection.HasValue)
        {
            decisions.Add(topLevelSeveritySelection.Decision);
            AddMergeProvenance(provenanceSet, topLevelSeveritySelection, now, ProvenanceFieldMasks.Advisory);
        }

        var aliases = MergeAliases(candidates);
        var creditsResult = MergeCredits(candidates);
        if (creditsResult.UnionDecision is not null)
        {
            decisions.Add(creditsResult.UnionDecision);
        }
        decisions.AddRange(creditsResult.Decisions);

        var referencesResult = MergeReferences(candidates);
        if (referencesResult.UnionDecision is not null)
        {
            decisions.Add(referencesResult.UnionDecision);
        }
        decisions.AddRange(referencesResult.Decisions);

        var weaknessesResult = MergeWeaknesses(candidates, now);
        decisions.AddRange(weaknessesResult.Decisions);
        foreach (var weaknessProvenance in weaknessesResult.AdditionalProvenance)
        {
            provenanceSet.Add(weaknessProvenance);
        }

        var packagesResult = MergePackages(candidates, now);
        decisions.AddRange(packagesResult.Decisions);
        foreach (var packageProvenance in packagesResult.AdditionalProvenance)
        {
            provenanceSet.Add(packageProvenance);
        }

        var metricsResult = MergeCvssMetrics(candidates);
        if (metricsResult.Decision is not null)
        {
            decisions.Add(metricsResult.Decision);
        }

        var exploitKnown = candidates.Any(candidate => candidate.Advisory.ExploitKnown);
        var published = candidates
            .Select(candidate => candidate.Advisory.Published)
            .Where(static value => value.HasValue)
            .Select(static value => value!.Value)
            .DefaultIfEmpty()
            .Min();
        var modified = candidates
            .Select(candidate => candidate.Advisory.Modified)
            .Where(static value => value.HasValue)
            .Select(static value => value!.Value)
            .DefaultIfEmpty()
            .Max();

        var title = titleSelection.Value ?? ghsa?.Title ?? nvd?.Title ?? osv?.Title ?? advisoryKey;
        var summary = summarySelection.Value ?? ghsa?.Summary ?? nvd?.Summary ?? osv?.Summary;
        var description = descriptionSelection.Value ?? ghsa?.Description ?? nvd?.Description ?? osv?.Description;
        var language = languageSelection.Value ?? ghsa?.Language ?? nvd?.Language ?? osv?.Language;
        var severity = topLevelSeveritySelection.Value ?? metricsResult.CanonicalSeverity ?? ghsa?.Severity ?? nvd?.Severity ?? osv?.Severity;
        var canonicalMetricId = metricsResult.CanonicalMetricId ?? ghsa?.CanonicalMetricId ?? nvd?.CanonicalMetricId ?? osv?.CanonicalMetricId;

        if (string.IsNullOrWhiteSpace(title))
        {
            title = advisoryKey;
        }

        var provenance = provenanceSet
            .OrderBy(static p => p.Source, StringComparer.Ordinal)
            .ThenBy(static p => p.Kind, StringComparer.Ordinal)
            .ThenBy(static p => p.RecordedAt)
            .ToImmutableArray();

        var advisory = new Advisory(
            advisoryKey,
            title,
            summary,
            language,
            published == DateTimeOffset.MinValue ? null : published,
            modified == DateTimeOffset.MinValue ? null : modified,
            severity,
            exploitKnown,
            aliases,
            creditsResult.Credits,
            referencesResult.References,
            packagesResult.Packages,
            metricsResult.Metrics,
            provenance,
            description,
            weaknessesResult.Weaknesses,
            canonicalMetricId);

        return new CanonicalMergeResult(
            advisory,
            decisions
                .OrderBy(static d => d.Field, StringComparer.Ordinal)
                .ThenBy(static d => d.SelectedSource, StringComparer.Ordinal)
                .ToImmutableArray());
    }

    private ImmutableArray<string> MergeAliases(List<AdvisorySnapshot> candidates)
    {
        var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var candidate in candidates)
        {
            foreach (var alias in candidate.Advisory.Aliases)
            {
                if (!string.IsNullOrWhiteSpace(alias))
                {
                    set.Add(alias);
                }
            }
        }

        return set.Count == 0 ? ImmutableArray<string>.Empty : set.OrderBy(static value => value, StringComparer.Ordinal).ToImmutableArray();
    }

    private CreditsMergeResult MergeCredits(List<AdvisorySnapshot> candidates)
    {
        var precedence = GetPrecedence("credits");
        var isFreshnessSensitive = FreshnessSensitiveFields.Contains("credits");
        var map = new Dictionary<string, CreditSelection>(StringComparer.OrdinalIgnoreCase);
        var considered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var decisions = new List<FieldDecision>();

        foreach (var candidate in candidates)
        {
            foreach (var credit in candidate.Advisory.Credits)
            {
                var key = $"{credit.DisplayName}|{credit.Role}";
                considered.Add(candidate.Source);

                if (!map.TryGetValue(key, out var existing))
                {
                    map[key] = new CreditSelection(credit, candidate.Source, candidate.Modified);
                    continue;
                }

                var candidateRank = GetRank(candidate.Source, precedence);
                var existingRank = GetRank(existing.Source, precedence);
                var reason = EvaluateReplacementReason(candidateRank, existingRank, candidate.Modified, existing.Modified, isFreshnessSensitive);
                if (reason is null)
                {
                    continue;
                }

                var consideredSources = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
                {
                    existing.Source,
                    candidate.Source,
                };

                map[key] = new CreditSelection(credit, candidate.Source, candidate.Modified);
                decisions.Add(new FieldDecision(
                    Field: $"credits[{key}]",
                    SelectedSource: candidate.Source,
                    DecisionReason: reason,
                    SelectedModified: candidate.Modified,
                    ConsideredSources: consideredSources.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray()));
            }
        }

        var credits = map.Values.Select(static s => s.Credit).ToImmutableArray();
        FieldDecision? decision = null;

        if (considered.Count > 0)
        {
            decision = new FieldDecision(
                Field: "credits",
                SelectedSource: null,
                DecisionReason: "union",
                SelectedModified: null,
                ConsideredSources: considered.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray());
        }

        return new CreditsMergeResult(credits, decision, decisions);
    }

    private ReferencesMergeResult MergeReferences(List<AdvisorySnapshot> candidates)
    {
        var precedence = GetPrecedence("references");
        var isFreshnessSensitive = FreshnessSensitiveFields.Contains("references");
        var map = new Dictionary<string, ReferenceSelection>(StringComparer.OrdinalIgnoreCase);
        var considered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var decisions = new List<FieldDecision>();

        foreach (var candidate in candidates)
        {
            foreach (var reference in candidate.Advisory.References)
            {
                if (string.IsNullOrWhiteSpace(reference.Url))
                {
                    continue;
                }

                var key = NormalizeReferenceKey(reference.Url);
                considered.Add(candidate.Source);

                if (!map.TryGetValue(key, out var existing))
                {
                    map[key] = new ReferenceSelection(reference, candidate.Source, candidate.Modified);
                    continue;
                }

                var candidateRank = GetRank(candidate.Source, precedence);
                var existingRank = GetRank(existing.Source, precedence);
                var reason = EvaluateReplacementReason(candidateRank, existingRank, candidate.Modified, existing.Modified, isFreshnessSensitive);
                if (reason is null)
                {
                    continue;
                }

                var consideredSources = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
                {
                    existing.Source,
                    candidate.Source,
                };

                map[key] = new ReferenceSelection(reference, candidate.Source, candidate.Modified);
                decisions.Add(new FieldDecision(
                    Field: $"references[{key}]",
                    SelectedSource: candidate.Source,
                    DecisionReason: reason,
                    SelectedModified: candidate.Modified,
                    ConsideredSources: consideredSources.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray()));
            }
        }

        var references = map.Values.Select(static s => s.Reference).ToImmutableArray();
        FieldDecision? decision = null;

        if (considered.Count > 0)
        {
            decision = new FieldDecision(
                Field: "references",
                SelectedSource: null,
                DecisionReason: "union",
                SelectedModified: null,
                ConsideredSources: considered.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray());
        }

        return new ReferencesMergeResult(references, decision, decisions);
    }

    private PackagesMergeResult MergePackages(List<AdvisorySnapshot> candidates, DateTimeOffset now)
    {
        var precedence = GetPrecedence("affectedPackages");
        var isFreshnessSensitive = FreshnessSensitiveFields.Contains("affectedPackages");
        var map = new Dictionary<string, PackageSelection>(StringComparer.OrdinalIgnoreCase);
        var decisions = new List<FieldDecision>();
        var additionalProvenance = new List<AdvisoryProvenance>();

        foreach (var candidate in candidates)
        {
            foreach (var package in candidate.Advisory.AffectedPackages)
            {
                var key = CreatePackageKey(package);
                var consideredSources = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { candidate.Source };

                if (!map.TryGetValue(key, out var existing))
                {
                    var enriched = AppendMergeProvenance(package, candidate.Source, "precedence", now);
                    additionalProvenance.Add(enriched.MergeProvenance);
                    map[key] = new PackageSelection(enriched.Package, candidate.Source, candidate.Modified);

                decisions.Add(new FieldDecision(
                    Field: $"affectedPackages[{key}]",
                    SelectedSource: candidate.Source,
                    DecisionReason: "precedence",
                    SelectedModified: candidate.Modified,
                    ConsideredSources: consideredSources.ToImmutableArray()));
                    continue;
                }

                consideredSources.Add(existing.Source);

                var candidateRank = GetRank(candidate.Source, precedence);
                var existingRank = GetRank(existing.Source, precedence);
                var reason = EvaluateReplacementReason(candidateRank, existingRank, candidate.Modified, existing.Modified, isFreshnessSensitive);
                if (reason is null)
                {
                    continue;
                }

                var enrichedPackage = AppendMergeProvenance(package, candidate.Source, reason, now);
                additionalProvenance.Add(enrichedPackage.MergeProvenance);
                map[key] = new PackageSelection(enrichedPackage.Package, candidate.Source, candidate.Modified);

                decisions.Add(new FieldDecision(
                    Field: $"affectedPackages[{key}]",
                    SelectedSource: candidate.Source,
                    DecisionReason: reason,
                    SelectedModified: candidate.Modified,
                    ConsideredSources: consideredSources.ToImmutableArray()));
            }
        }

        var packages = map.Values.Select(static s => s.Package).ToImmutableArray();
        return new PackagesMergeResult(packages, decisions, additionalProvenance);
    }

    private WeaknessMergeResult MergeWeaknesses(List<AdvisorySnapshot> candidates, DateTimeOffset now)
    {
        var precedence = GetPrecedence("cwes");
        var map = new Dictionary<string, WeaknessSelection>(StringComparer.OrdinalIgnoreCase);
        var decisions = new List<FieldDecision>();
        var additionalProvenance = new List<AdvisoryProvenance>();

        foreach (var candidate in candidates)
        {
            var candidateWeaknesses = candidate.Advisory.Cwes.IsDefaultOrEmpty
                ? ImmutableArray<AdvisoryWeakness>.Empty
                : candidate.Advisory.Cwes;

            foreach (var weakness in candidateWeaknesses)
            {
                var key = $"{weakness.Taxonomy}|{weakness.Identifier}";
                var consideredSources = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { candidate.Source };

                if (!map.TryGetValue(key, out var existing))
                {
                    var enriched = AppendWeaknessProvenance(weakness, candidate.Source, "precedence", now);
                    map[key] = new WeaknessSelection(enriched.Weakness, candidate.Source, candidate.Modified);
                    additionalProvenance.Add(enriched.MergeProvenance);

                    decisions.Add(new FieldDecision(
                        Field: $"cwes[{key}]",
                        SelectedSource: candidate.Source,
                        DecisionReason: "precedence",
                        SelectedModified: candidate.Modified,
                        ConsideredSources: consideredSources.ToImmutableArray()));
                    continue;
                }

                consideredSources.Add(existing.Source);

                var candidateRank = GetRank(candidate.Source, precedence);
                var existingRank = GetRank(existing.Source, precedence);
                var decisionReason = string.Empty;
                var shouldReplace = false;

                if (candidateRank < existingRank)
                {
                    shouldReplace = true;
                    decisionReason = "precedence";
                }
                else if (candidateRank == existingRank && candidate.Modified > existing.Modified)
                {
                    shouldReplace = true;
                    decisionReason = "tie_breaker";
                }

                if (!shouldReplace)
                {
                    continue;
                }

                var enrichedWeakness = AppendWeaknessProvenance(weakness, candidate.Source, decisionReason, now);
                map[key] = new WeaknessSelection(enrichedWeakness.Weakness, candidate.Source, candidate.Modified);
                additionalProvenance.Add(enrichedWeakness.MergeProvenance);

                decisions.Add(new FieldDecision(
                    Field: $"cwes[{key}]",
                    SelectedSource: candidate.Source,
                    DecisionReason: decisionReason,
                    SelectedModified: candidate.Modified,
                    ConsideredSources: consideredSources.ToImmutableArray()));
            }
        }

        var mergedWeaknesses = map.Values
            .Select(static value => value.Weakness)
            .OrderBy(static value => value.Taxonomy, StringComparer.Ordinal)
            .ThenBy(static value => value.Identifier, StringComparer.Ordinal)
            .ThenBy(static value => value.Name, StringComparer.Ordinal)
            .ToImmutableArray();

        return new WeaknessMergeResult(mergedWeaknesses, decisions, additionalProvenance);
    }

    private CvssMergeResult MergeCvssMetrics(List<AdvisorySnapshot> candidates)
    {
        var precedence = GetPrecedence("cvssMetrics");
        var map = new Dictionary<string, MetricSelection>(StringComparer.OrdinalIgnoreCase);
        var considered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        foreach (var candidate in candidates)
        {
            foreach (var metric in candidate.Advisory.CvssMetrics)
            {
                var key = $"{metric.Version}|{metric.Vector}";
                considered.Add(candidate.Source);

                if (!map.TryGetValue(key, out var existing))
                {
                    map[key] = new MetricSelection(metric, candidate.Source, candidate.Modified);
                    continue;
                }

                var candidateRank = GetRank(candidate.Source, precedence);
                var existingRank = GetRank(existing.Source, precedence);

                if (candidateRank < existingRank ||
                    (candidateRank == existingRank && candidate.Modified > existing.Modified))
                {
                    map[key] = new MetricSelection(metric, candidate.Source, candidate.Modified);
                }
            }
        }

        var orderedMetrics = map
            .Values
            .OrderBy(selection => GetRank(selection.Source, precedence))
            .ThenByDescending(selection => selection.Modified)
            .Select(static selection => selection.Metric)
            .ToImmutableArray();

        FieldDecision? decision = null;
        string? canonicalMetricId = null;
        string? canonicalSelectedSource = null;
        DateTimeOffset? canonicalSelectedModified = null;

        var canonical = orderedMetrics.FirstOrDefault();
        if (canonical is not null)
        {
            canonicalMetricId = $"{canonical.Version}|{canonical.Vector}";
            if (map.TryGetValue(canonicalMetricId, out var selection))
            {
                canonicalSelectedSource = selection.Source;
                canonicalSelectedModified = selection.Modified;
            }
        }

        if (considered.Count > 0)
        {
            decision = new FieldDecision(
                Field: "cvssMetrics",
                SelectedSource: canonicalSelectedSource,
                DecisionReason: "precedence",
                SelectedModified: canonicalSelectedModified,
                ConsideredSources: considered.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase).ToImmutableArray());
        }

        var severity = canonical?.BaseSeverity;
        return new CvssMergeResult(orderedMetrics, severity, canonicalMetricId, decision);
    }

    private static string CreatePackageKey(AffectedPackage package)
        => string.Join('|', package.Type ?? string.Empty, package.Identifier ?? string.Empty, package.Platform ?? string.Empty);

    private static (AffectedPackage Package, AdvisoryProvenance MergeProvenance) AppendMergeProvenance(
        AffectedPackage package,
        string source,
        string decisionReason,
        DateTimeOffset recordedAt)
    {
        var provenance = new AdvisoryProvenance(
            source,
            kind: "merge",
            value: CreatePackageKey(package),
            recordedAt: recordedAt,
            fieldMask: new[] { ProvenanceFieldMasks.AffectedPackages },
            decisionReason: decisionReason);

        var provenanceList = package.Provenance.ToBuilder();
        provenanceList.Add(provenance);

        var packageWithProvenance = new AffectedPackage(
            package.Type,
            package.Identifier,
            package.Platform,
            package.VersionRanges,
            package.Statuses,
            provenanceList,
            package.NormalizedVersions);

        return (packageWithProvenance, provenance);
    }

    private static string NormalizeReferenceKey(string url)
    {
        var trimmed = url?.Trim();
        if (string.IsNullOrEmpty(trimmed))
        {
            return string.Empty;
        }

        if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
        {
            return trimmed;
        }

        var builder = new StringBuilder();
        var scheme = uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ? "https" : uri.Scheme.ToLowerInvariant();
        builder.Append(scheme).Append("://").Append(uri.Host.ToLowerInvariant());

        if (!uri.IsDefaultPort)
        {
            builder.Append(':').Append(uri.Port);
        }

        var path = uri.AbsolutePath;
        if (!string.IsNullOrEmpty(path) && path != "/")
        {
            if (!path.StartsWith('/'))
            {
                builder.Append('/');
            }

            builder.Append(path.TrimEnd('/'));
        }

        var query = uri.Query;
        if (!string.IsNullOrEmpty(query))
        {
            var parameters = query.TrimStart('?')
                .Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
            Array.Sort(parameters, StringComparer.Ordinal);
            builder.Append('?').Append(string.Join('&', parameters));
        }

        return builder.ToString();
    }

    private string? EvaluateReplacementReason(int candidateRank, int existingRank, DateTimeOffset candidateModified, DateTimeOffset existingModified, bool isFreshnessSensitive)
    {
        if (candidateRank < existingRank)
        {
            return "precedence";
        }

        if (isFreshnessSensitive && candidateRank > existingRank && candidateModified - existingModified >= _freshnessThreshold)
        {
            return "freshness_override";
        }

        if (candidateRank == existingRank && candidateModified > existingModified)
        {
            return "tie_breaker";
        }

        return null;
    }

    private static (AdvisoryWeakness Weakness, AdvisoryProvenance MergeProvenance) AppendWeaknessProvenance(
        AdvisoryWeakness weakness,
        string source,
        string decisionReason,
        DateTimeOffset recordedAt)
    {
        var provenance = new AdvisoryProvenance(
            source,
            kind: "merge",
            value: $"{weakness.Taxonomy}:{weakness.Identifier}",
            recordedAt: recordedAt,
            fieldMask: new[] { ProvenanceFieldMasks.Weaknesses },
            decisionReason: decisionReason);

        var provenanceList = weakness.Provenance.IsDefaultOrEmpty
            ? ImmutableArray.Create(provenance)
            : weakness.Provenance.Add(provenance);

        var weaknessWithProvenance = new AdvisoryWeakness(
            weakness.Taxonomy,
            weakness.Identifier,
            weakness.Name,
            weakness.Uri,
            provenanceList);

        return (weaknessWithProvenance, provenance);
    }

    private FieldSelection<string> SelectStringField(
        string field,
        List<AdvisorySnapshot> candidates,
        Func<Advisory, string?> selector,
        bool isFreshnessSensitive)
    {
        var precedence = GetPrecedence(field);
        var valueCandidates = new List<ValueCandidate>();

        foreach (var candidate in candidates)
        {
            var value = Validation.TrimToNull(selector(candidate.Advisory));
            if (!string.IsNullOrEmpty(value))
            {
                valueCandidates.Add(new ValueCandidate(candidate, value));
            }
        }

        if (valueCandidates.Count == 0)
        {
            return FieldSelection<string>.Empty;
        }

        var consideredSources = valueCandidates
            .Select(vc => vc.Candidate.Source)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .OrderBy(static source => source, StringComparer.OrdinalIgnoreCase)
            .ToImmutableArray();

        var best = valueCandidates
            .OrderBy(vc => GetRank(vc.Candidate.Source, precedence))
            .ThenByDescending(vc => vc.Candidate.Modified)
            .First();

        var decisionReason = "precedence";

        if (isFreshnessSensitive)
        {
            var freshnessOverride = valueCandidates
                .Where(vc => GetRank(vc.Candidate.Source, precedence) > GetRank(best.Candidate.Source, precedence))
                .Where(vc => vc.Candidate.Modified - best.Candidate.Modified >= _freshnessThreshold)
                .OrderByDescending(vc => vc.Candidate.Modified)
                .ThenBy(vc => GetRank(vc.Candidate.Source, precedence))
                .FirstOrDefault();

            if (freshnessOverride is not null)
            {
                best = freshnessOverride;
                decisionReason = "freshness_override";
            }
        }

        var sameRankCandidates = valueCandidates
            .Where(vc => GetRank(vc.Candidate.Source, precedence) == GetRank(best.Candidate.Source, precedence))
            .ToList();

        if (sameRankCandidates.Count > 1)
        {
            var tied = sameRankCandidates
                .OrderBy(vc => vc.Value.Length)
                .ThenBy(vc => vc.Value, StringComparer.Ordinal)
                .ThenBy(vc => ComputeStableHash(vc.Value))
                .First();

            if (!ReferenceEquals(tied, best))
            {
                best = tied;
                decisionReason = "tie_breaker";
            }
        }

        var decision = new FieldDecision(
            field,
            best.Candidate.Source,
            decisionReason,
            best.Candidate.Modified,
            consideredSources);

        return new FieldSelection<string>(field, best.Value, best.Candidate, decisionReason, decision);
    }

    private static void AddMergeProvenance(
        HashSet<AdvisoryProvenance> provenanceSet,
        FieldSelection<string> selection,
        DateTimeOffset recordedAt,
        string fieldMask)
    {
        if (!selection.HasValue || selection.Winner is null)
        {
            return;
        }

        var provenance = new AdvisoryProvenance(
            selection.Winner.Source,
            kind: "merge",
            value: selection.Field,
            recordedAt: recordedAt,
            fieldMask: new[] { fieldMask },
            decisionReason: selection.DecisionReason);

        provenanceSet.Add(provenance);
    }

    private static List<AdvisorySnapshot> BuildCandidates(Advisory? ghsa, Advisory? nvd, Advisory? osv)
    {
        var list = new List<AdvisorySnapshot>(capacity: 3);
        if (ghsa is not null)
        {
            list.Add(CreateSnapshot(GhsaSource, ghsa));
        }

        if (nvd is not null)
        {
            list.Add(CreateSnapshot(NvdSource, nvd));
        }

        if (osv is not null)
        {
            list.Add(CreateSnapshot(OsvSource, osv));
        }

        return list;
    }

    private static AdvisorySnapshot CreateSnapshot(string source, Advisory advisory)
    {
        var modified = advisory.Modified
            ?? advisory.Published
            ?? DateTimeOffset.UnixEpoch;
        return new AdvisorySnapshot(source, advisory, modified);
    }

    private static ImmutableDictionary<string, int> GetPrecedence(string field)
    {
        if (FieldPrecedence.TryGetValue(field, out var order))
        {
            return order
                .Select((source, index) => (source, index))
                .ToImmutableDictionary(item => item.source, item => item.index, StringComparer.OrdinalIgnoreCase);
        }

        return SourceOrder;
    }

    private static int GetRank(string source, ImmutableDictionary<string, int> precedence)
        => precedence.TryGetValue(source, out var rank) ? rank : int.MaxValue;

    private static string ComputeStableHash(string value)
    {
        var bytes = Encoding.UTF8.GetBytes(value);
        var hash = SHA256.HashData(bytes);
        return Convert.ToHexString(hash);
    }

    private sealed class FieldSelection<T>
    {
        public FieldSelection(string field, T? value, AdvisorySnapshot? winner, string decisionReason, FieldDecision decision)
        {
            Field = field;
            Value = value;
            Winner = winner;
            DecisionReason = decisionReason;
            Decision = decision;
        }

        public string Field { get; }

        public T? Value { get; }

        public AdvisorySnapshot? Winner { get; }

        public string DecisionReason { get; }

        public FieldDecision Decision { get; }

        public bool HasValue => Winner is not null;

        public static FieldSelection<T> Empty { get; } = new FieldSelection<T>(
            string.Empty,
            default,
            null,
            string.Empty,
            new FieldDecision(string.Empty, null, string.Empty, null, ImmutableArray<string>.Empty));
    }

    private sealed record AdvisorySnapshot(string Source, Advisory Advisory, DateTimeOffset Modified);

    private sealed record ValueCandidate(AdvisorySnapshot Candidate, string Value);

    private readonly record struct PackageSelection(AffectedPackage Package, string Source, DateTimeOffset Modified);

    private readonly record struct ReferenceSelection(AdvisoryReference Reference, string Source, DateTimeOffset Modified);

    private readonly record struct CreditSelection(AdvisoryCredit Credit, string Source, DateTimeOffset Modified);

    private readonly record struct MetricSelection(CvssMetric Metric, string Source, DateTimeOffset Modified);

    private readonly record struct WeaknessSelection(AdvisoryWeakness Weakness, string Source, DateTimeOffset Modified);

    private readonly record struct CreditsMergeResult(ImmutableArray<AdvisoryCredit> Credits, FieldDecision? UnionDecision, IReadOnlyList<FieldDecision> Decisions);

    private readonly record struct ReferencesMergeResult(ImmutableArray<AdvisoryReference> References, FieldDecision? UnionDecision, IReadOnlyList<FieldDecision> Decisions);

    private readonly record struct PackagesMergeResult(
        ImmutableArray<AffectedPackage> Packages,
        IReadOnlyList<FieldDecision> Decisions,
        IReadOnlyList<AdvisoryProvenance> AdditionalProvenance);

    private readonly record struct WeaknessMergeResult(
        ImmutableArray<AdvisoryWeakness> Weaknesses,
        IReadOnlyList<FieldDecision> Decisions,
        IReadOnlyList<AdvisoryProvenance> AdditionalProvenance);

    private readonly record struct CvssMergeResult(
        ImmutableArray<CvssMetric> Metrics,
        string? CanonicalSeverity,
        string? CanonicalMetricId,
        FieldDecision? Decision);
}
