using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
using MongoDB.Bson;
using StellaOps.Feedser.Models;
using StellaOps.Feedser.Source.Common;
using StellaOps.Feedser.Source.Osv;
using StellaOps.Feedser.Source.Osv.Internal;
using StellaOps.Feedser.Storage.Mongo.Documents;
using StellaOps.Feedser.Storage.Mongo.Dtos;
using Xunit;

namespace StellaOps.Feedser.Source.Osv.Tests;

public sealed class OsvGhsaParityRegressionTests
{
    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);

    // Curated GHSA identifiers spanning multiple ecosystems (PyPI, npm/go, Maven) for parity coverage.
    private static readonly string[] GhsaIds =
    {
        "GHSA-wv4w-6qv2-qqfg", // PyPI – social-auth-app-django
        "GHSA-cjjf-27cc-pvmv", // PyPI – pyload-ng
        "GHSA-77vh-xpmg-72qh", // Go    – opencontainers/image-spec
        "GHSA-7rjr-3q55-vv33"  // Maven – log4j-core / pax-logging
    };

    [Fact]
    public void FixtureParity_NoIssues_EmitsMetrics()
    {
        RegenerateFixturesIfRequested();

        var osvAdvisories = LoadOsvAdvisories();
        var ghsaAdvisories = LoadGhsaAdvisories();

        if (File.Exists(RebuildSentinelPath))
        {
            WriteFixture("osv-ghsa.osv.json", osvAdvisories);
            WriteFixture("osv-ghsa.ghsa.json", ghsaAdvisories);
            File.Delete(RebuildSentinelPath);
        }

        AssertSnapshot("osv-ghsa.osv.json", osvAdvisories);
        AssertSnapshot("osv-ghsa.ghsa.json", ghsaAdvisories);

        var measurements = new List<MeasurementRecord>();
        using var listener = CreateListener(measurements);

        var report = OsvGhsaParityInspector.Compare(osvAdvisories, ghsaAdvisories);

        if (report.HasIssues)
        {
            foreach (var issue in report.Issues)
            {
                Console.WriteLine($"[Parity] Issue: {issue.GhsaId} {issue.IssueKind} {issue.Detail}");
            }
        }

        Assert.False(report.HasIssues);
        Assert.Equal(GhsaIds.Length, report.TotalGhsaIds);

        OsvGhsaParityDiagnostics.RecordReport(report, "fixtures");
        listener.Dispose();

        var total = Assert.Single(measurements, entry => string.Equals(entry.Instrument, "feedser.osv_ghsa.total", StringComparison.Ordinal));
        Assert.Equal(GhsaIds.Length, total.Value);
        Assert.Equal("fixtures", Assert.IsType<string>(total.Tags["dataset"]));

        Assert.DoesNotContain(measurements, entry => string.Equals(entry.Instrument, "feedser.osv_ghsa.issues", StringComparison.Ordinal));
    }

    private static MeterListener CreateListener(List<MeasurementRecord> buffer)
    {
        var listener = new MeterListener
        {
            InstrumentPublished = (instrument, l) =>
            {
                if (instrument.Meter.Name.StartsWith("StellaOps.Feedser.Models.OsvGhsaParity", StringComparison.Ordinal))
                {
                    l.EnableMeasurementEvents(instrument);
                }
            }
        };

        listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
        {
            var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
            foreach (var tag in tags)
            {
                dict[tag.Key] = tag.Value;
            }

            buffer.Add(new MeasurementRecord(instrument.Name, measurement, dict));
        });

        listener.Start();
        return listener;
    }

    private static IReadOnlyList<Advisory> LoadOsvAdvisories()
    {
        var path = ResolveFixturePath("osv-ghsa.raw-osv.json");
        using var document = JsonDocument.Parse(File.ReadAllText(path));
        var advisories = new List<Advisory>();
        foreach (var element in document.RootElement.EnumerateArray())
        {
            advisories.Add(MapOsvAdvisory(element.GetRawText()));
        }
        advisories.Sort((a, b) => string.Compare(a.AdvisoryKey, b.AdvisoryKey, StringComparison.OrdinalIgnoreCase));
        return advisories;
    }

    private static IReadOnlyList<Advisory> LoadGhsaAdvisories()
    {
        var path = ResolveFixturePath("osv-ghsa.raw-ghsa.json");
        using var document = JsonDocument.Parse(File.ReadAllText(path));
        var advisories = new List<Advisory>();
        foreach (var element in document.RootElement.EnumerateArray())
        {
            advisories.Add(MapGhsaAdvisory(element.GetRawText()));
        }
        advisories.Sort((a, b) => string.Compare(a.AdvisoryKey, b.AdvisoryKey, StringComparison.OrdinalIgnoreCase));
        return advisories;
    }

    private static void RegenerateFixturesIfRequested()
    {
        var flag = Environment.GetEnvironmentVariable("UPDATE_PARITY_FIXTURES");
        Console.WriteLine($"[Parity] UPDATE_PARITY_FIXTURES={flag ?? "(null)"}");

        var rawOsvPath = ResolveFixturePath("osv-ghsa.raw-osv.json");
        var rawGhsaPath = ResolveFixturePath("osv-ghsa.raw-ghsa.json");
        var shouldBootstrap = !File.Exists(rawOsvPath) || !File.Exists(rawGhsaPath);

        if (!string.Equals(flag, "1", StringComparison.Ordinal) && !shouldBootstrap)
        {
            return;
        }

        // regeneration trigger
        Console.WriteLine(shouldBootstrap
            ? $"[Parity] Raw fixtures missing – regenerating OSV/GHSA snapshots for {GhsaIds.Length} advisories."
            : $"[Parity] Regenerating OSV/GHSA fixtures for {GhsaIds.Length} advisories.");

        using var client = new HttpClient();
        client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("StellaOpsParityFixtures", "1.0"));

        var osvAdvisories = new List<Advisory>(GhsaIds.Length);
        var ghsaAdvisories = new List<Advisory>(GhsaIds.Length);
        var rawOsv = new List<JsonElement>(GhsaIds.Length);
        var rawGhsa = new List<JsonElement>(GhsaIds.Length);

        foreach (var ghsaId in GhsaIds)
        {
            var osvJson = FetchJson(client, $"https://api.osv.dev/v1/vulns/{ghsaId}");
            var ghsaJson = FetchJson(client, $"https://api.github.com/advisories/{ghsaId}");

            using (var osvDocument = JsonDocument.Parse(osvJson))
            {
                rawOsv.Add(osvDocument.RootElement.Clone());
            }
            using (var ghsaDocument = JsonDocument.Parse(ghsaJson))
            {
                rawGhsa.Add(ghsaDocument.RootElement.Clone());
            }

            var osv = MapOsvAdvisory(osvJson);
            var ghsa = MapGhsaAdvisory(ghsaJson);

            osvAdvisories.Add(osv);
            ghsaAdvisories.Add(ghsa);
        }

        osvAdvisories.Sort((a, b) => string.Compare(a.AdvisoryKey, b.AdvisoryKey, StringComparison.OrdinalIgnoreCase));
        ghsaAdvisories.Sort((a, b) => string.Compare(a.AdvisoryKey, b.AdvisoryKey, StringComparison.OrdinalIgnoreCase));

        WriteRawFixture("osv-ghsa.raw-osv.json", rawOsv);
        WriteRawFixture("osv-ghsa.raw-ghsa.json", rawGhsa);
        WriteFixture("osv-ghsa.osv.json", osvAdvisories);
        WriteFixture("osv-ghsa.ghsa.json", ghsaAdvisories);
    }

    private static string FetchJson(HttpClient client, string uri)
    {
        try
        {
            return client.GetStringAsync(uri).GetAwaiter().GetResult();
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException($"Failed to download '{uri}'.", ex);
        }
    }

    private static Advisory MapOsvAdvisory(string json)
    {
        var dto = JsonSerializer.Deserialize<OsvVulnerabilityDto>(json, SerializerOptions)
            ?? throw new InvalidOperationException("Unable to deserialize OSV payload.");

        var documentId = Guid.NewGuid();
        var identifier = dto.Id ?? throw new InvalidOperationException("OSV payload missing id.");
        var ecosystem = dto.Affected?.FirstOrDefault()?.Package?.Ecosystem ?? "osv";
        var fetchedAt = dto.Published ?? dto.Modified ?? DateTimeOffset.UtcNow;
        var sha = ComputeSha256Hex(json);

        var document = new DocumentRecord(
            documentId,
            OsvConnectorPlugin.SourceName,
            $"https://osv.dev/vulnerability/{identifier}",
            fetchedAt,
            sha,
            DocumentStatuses.PendingMap,
            "application/json",
            null,
            new Dictionary<string, string>(StringComparer.Ordinal) { ["osv.ecosystem"] = ecosystem },
            null,
            dto.Modified,
            null);

        var payload = BsonDocument.Parse(json);
        var dtoRecord = new DtoRecord(Guid.NewGuid(), document.Id, OsvConnectorPlugin.SourceName, "osv.v1", payload, DateTimeOffset.UtcNow);

        return OsvMapper.Map(dto, document, dtoRecord, ecosystem);
    }

    private static Advisory MapGhsaAdvisory(string json)
    {
        using var document = JsonDocument.Parse(json);
        var root = document.RootElement;

        var ghsaId = GetString(root, "ghsa_id");
        if (string.IsNullOrWhiteSpace(ghsaId))
        {
            throw new InvalidOperationException("GHSA payload missing ghsa_id.");
        }

        var summary = GetString(root, "summary");
        var description = GetString(root, "description");
        var severity = GetString(root, "severity")?.ToLowerInvariant();
        var published = GetDateTime(root, "published_at");
        var updated = GetDateTime(root, "updated_at");
        var recordedAt = updated ?? DateTimeOffset.UtcNow;

        var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ghsaId };
        if (root.TryGetProperty("identifiers", out var identifiers) && identifiers.ValueKind == JsonValueKind.Array)
        {
            foreach (var identifier in identifiers.EnumerateArray())
            {
                var value = identifier.TryGetProperty("value", out var valueElem) ? valueElem.GetString() : null;
                if (!string.IsNullOrWhiteSpace(value))
                {
                    aliases.Add(value);
                }
            }
        }

        var references = new List<AdvisoryReference>();
        if (root.TryGetProperty("references", out var referencesElem) && referencesElem.ValueKind == JsonValueKind.Array)
        {
            foreach (var referenceElem in referencesElem.EnumerateArray())
            {
                var url = referenceElem.GetString();
                if (string.IsNullOrWhiteSpace(url))
                {
                    continue;
                }

                var provenance = new AdvisoryProvenance("ghsa", "reference", url, recordedAt, new[] { ProvenanceFieldMasks.References });
                references.Add(new AdvisoryReference(url, DetermineReferenceKind(url), DetermineSourceTag(url), null, provenance));
            }
        }

        references = references
            .DistinctBy(reference => reference.Url, StringComparer.OrdinalIgnoreCase)
            .OrderBy(reference => reference.Url, StringComparer.Ordinal)
            .ToList();

        var affectedPackages = BuildGhsaPackages(root, recordedAt);
        var cvssMetrics = BuildGhsaCvss(root, recordedAt);

        var advisoryProvenance = new AdvisoryProvenance("ghsa", "map", ghsaId, recordedAt, new[] { ProvenanceFieldMasks.Advisory });

        return new Advisory(
            ghsaId,
            string.IsNullOrWhiteSpace(summary) ? ghsaId : summary!,
            string.IsNullOrWhiteSpace(description) ? summary : description,
            language: "en",
            published,
            updated,
            severity,
            exploitKnown: false,
            aliases,
            references,
            affectedPackages,
            cvssMetrics,
            new[] { advisoryProvenance });
    }

    private static IReadOnlyList<AffectedPackage> BuildGhsaPackages(JsonElement root, DateTimeOffset recordedAt)
    {
        if (!root.TryGetProperty("vulnerabilities", out var vulnerabilitiesElem) || vulnerabilitiesElem.ValueKind != JsonValueKind.Array)
        {
            return Array.Empty<AffectedPackage>();
        }

        var packages = new List<AffectedPackage>();
        foreach (var entry in vulnerabilitiesElem.EnumerateArray())
        {
            if (!entry.TryGetProperty("package", out var packageElem) || packageElem.ValueKind != JsonValueKind.Object)
            {
                continue;
            }

            var ecosystem = GetString(packageElem, "ecosystem");
            var name = GetString(packageElem, "name");
            if (string.IsNullOrWhiteSpace(name))
            {
                continue;
            }

            var identifier = BuildIdentifier(ecosystem, name);
            var packageProvenance = new AdvisoryProvenance("ghsa", "package", identifier, recordedAt, new[] { ProvenanceFieldMasks.AffectedPackages });

            var rangeExpression = GetString(entry, "vulnerable_version_range");
            string? firstPatched = null;
            if (entry.TryGetProperty("first_patched_version", out var firstPatchedElem) && firstPatchedElem.ValueKind == JsonValueKind.Object)
            {
                firstPatched = GetString(firstPatchedElem, "identifier");
            }

            var ranges = ParseVersionRanges(rangeExpression, firstPatched, identifier, recordedAt);

            packages.Add(new AffectedPackage(
                AffectedPackageTypes.SemVer,
                identifier,
                ecosystem,
                ranges,
                Array.Empty<AffectedPackageStatus>(),
                new[] { packageProvenance }));
        }

        return packages.OrderBy(package => package.Identifier, StringComparer.Ordinal).ToArray();
    }

    private static IReadOnlyList<AffectedVersionRange> ParseVersionRanges(string? vulnerableVersionRange, string? firstPatchedVersion, string identifier, DateTimeOffset recordedAt)
    {
        if (string.IsNullOrWhiteSpace(vulnerableVersionRange) && string.IsNullOrWhiteSpace(firstPatchedVersion))
        {
            return Array.Empty<AffectedVersionRange>();
        }

        var ranges = new List<AffectedVersionRange>();

        var expressions = vulnerableVersionRange?
            .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
            .ToArray() ?? Array.Empty<string>();

        string? introduced = null;
        string? fixedVersion = firstPatchedVersion;
        string? lastAffected = null;

        foreach (var expression in expressions)
        {
            if (expression.StartsWith(">=", StringComparison.Ordinal))
            {
                introduced = expression[(expression.IndexOf('=') + 1)..].Trim();
            }
            else if (expression.StartsWith(">", StringComparison.Ordinal))
            {
                introduced = expression[1..].Trim();
            }
            else if (expression.StartsWith("<=", StringComparison.Ordinal))
            {
                lastAffected = expression[(expression.IndexOf('=') + 1)..].Trim();
            }
            else if (expression.StartsWith("<", StringComparison.Ordinal))
            {
                fixedVersion = expression[1..].Trim();
            }
        }

        var provenance = new AdvisoryProvenance("ghsa", "range", identifier, recordedAt, new[] { ProvenanceFieldMasks.VersionRanges });
        ranges.Add(new AffectedVersionRange("semver", NullIfWhitespace(introduced), NullIfWhitespace(fixedVersion), NullIfWhitespace(lastAffected), vulnerableVersionRange, provenance));

        return ranges;
    }

    private static IReadOnlyList<CvssMetric> BuildGhsaCvss(JsonElement root, DateTimeOffset recordedAt)
    {
        if (!root.TryGetProperty("cvss_severities", out var severitiesElem) || severitiesElem.ValueKind != JsonValueKind.Object)
        {
            return Array.Empty<CvssMetric>();
        }

        var metrics = new List<CvssMetric>();
        if (severitiesElem.TryGetProperty("cvss_v3", out var cvssElem) && cvssElem.ValueKind == JsonValueKind.Object)
        {
            var vector = GetString(cvssElem, "vector_string");
            if (!string.IsNullOrWhiteSpace(vector))
            {
                var score = cvssElem.TryGetProperty("score", out var scoreElem) && scoreElem.ValueKind == JsonValueKind.Number
                    ? scoreElem.GetDouble()
                    : 0d;
                var provenance = new AdvisoryProvenance("ghsa", "cvss", vector, recordedAt, new[] { ProvenanceFieldMasks.CvssMetrics });
                var version = vector.StartsWith("CVSS:4.0", StringComparison.OrdinalIgnoreCase) ? "4.0" : "3.1";
                var severity = GetString(root, "severity")?.ToLowerInvariant() ?? "unknown";
                metrics.Add(new CvssMetric(version, vector, score, severity, provenance));
            }
        }

        return metrics;
    }

    private static string BuildIdentifier(string? ecosystem, string name)
    {
        if (string.IsNullOrWhiteSpace(ecosystem))
        {
            return name;
        }

        var key = ecosystem.Trim().ToLowerInvariant();
        return key switch
        {
            "pypi" => $"pkg:pypi/{name.Replace('_', '-').ToLowerInvariant()}",
            "npm" => $"pkg:npm/{name.ToLowerInvariant()}",
            "maven" => $"pkg:maven/{name.Replace(':', '/')}",
            "go" or "golang" => $"pkg:golang/{name}",
            _ => name
        };
    }

    private static string? DetermineReferenceKind(string url)
    {
        if (url.Contains("/commit/", StringComparison.OrdinalIgnoreCase) ||
            url.Contains("/pull/", StringComparison.OrdinalIgnoreCase) ||
            url.Contains("/releases/tag/", StringComparison.OrdinalIgnoreCase) ||
            url.Contains("/pull-requests/", StringComparison.OrdinalIgnoreCase))
        {
            return "patch";
        }

        if (url.Contains("advisories", StringComparison.OrdinalIgnoreCase) ||
            url.Contains("security", StringComparison.OrdinalIgnoreCase) ||
            url.Contains("cve", StringComparison.OrdinalIgnoreCase))
        {
            return "advisory";
        }

        return null;
    }

    private static string? DetermineSourceTag(string url)
    {
        if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
        {
            return uri.Host;
        }

        return null;
    }

    private static string? GetString(JsonElement element, string propertyName)
    {
        if (element.TryGetProperty(propertyName, out var property))
        {
            if (property.ValueKind == JsonValueKind.String)
            {
                return property.GetString();
            }
        }

        return null;
    }

    private static DateTimeOffset? GetDateTime(JsonElement element, string propertyName)
    {
        if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String)
        {
            if (property.TryGetDateTimeOffset(out var value))
            {
                return value;
            }
        }

        return null;
    }

    private static void WriteFixture(string filename, IReadOnlyList<Advisory> advisories)
    {
        var path = ResolveFixturePath(filename);
        var directory = Path.GetDirectoryName(path);
        if (!string.IsNullOrEmpty(directory))
        {
            Directory.CreateDirectory(directory);
        }

        var snapshot = SnapshotSerializer.ToSnapshot(advisories);
        File.WriteAllText(path, snapshot);
    }

    private static void WriteRawFixture(string filename, IReadOnlyList<JsonElement> elements)
    {
        var path = ResolveFixturePath(filename);
        var directory = Path.GetDirectoryName(path);
        if (!string.IsNullOrEmpty(directory))
        {
            Directory.CreateDirectory(directory);
        }

        var json = JsonSerializer.Serialize(elements, new JsonSerializerOptions
        {
            WriteIndented = true
        });
        File.WriteAllText(path, json);
    }

    private static void AssertSnapshot(string filename, IReadOnlyList<Advisory> advisories)
    {
        var path = ResolveFixturePath(filename);
        var actual = File.ReadAllText(path).Trim().ReplaceLineEndings("\n");
        var expected = SnapshotSerializer.ToSnapshot(advisories).Trim().ReplaceLineEndings("\n");

        var normalizedActual = NormalizeRecordedAt(actual);
        var normalizedExpected = NormalizeRecordedAt(expected);

        if (!string.Equals(normalizedExpected, normalizedActual, StringComparison.Ordinal))
        {
            var shouldUpdate = string.Equals(Environment.GetEnvironmentVariable("UPDATE_PARITY_FIXTURES"), "1", StringComparison.Ordinal);
            if (shouldUpdate)
            {
                var normalized = expected.Replace("\n", Environment.NewLine, StringComparison.Ordinal);
                File.WriteAllText(path, normalized);
                actual = expected;
                normalizedActual = normalizedExpected;
            }
        }

        Assert.Equal(normalizedExpected, normalizedActual);
    }

    private static string ResolveFixturePath(string filename)
        => Path.Combine(ProjectFixtureDirectory, filename);

    private static string NormalizeRecordedAt(string input)
        => RecordedAtRegex.Replace(input, "\"recordedAt\": \"#normalized#\"");

    private static string ProjectFixtureDirectory { get; } = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "Fixtures"));

    private static string RebuildSentinelPath => Path.Combine(ProjectFixtureDirectory, ".rebuild");

    private static readonly Regex RecordedAtRegex = new("\"recordedAt\": \"[^\"]+\"", RegexOptions.CultureInvariant | RegexOptions.Compiled);

    private static string ComputeSha256Hex(string payload)
    {
        var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload));
        return Convert.ToHexString(bytes);
    }

    private static string? NullIfWhitespace(string? value)
        => string.IsNullOrWhiteSpace(value) ? null : value.Trim();

    private sealed record MeasurementRecord(string Instrument, long Value, IReadOnlyDictionary<string, object?> Tags);

}
