using System.Text.Json;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Common;

namespace DeepDrftTests;

/// <summary>
/// Unit tests for the Phase 22 SEO typed builders (<see cref="SeoModel"/> factories + <see cref="SeoJsonLd"/>
/// nodes + <see cref="SeoUrls"/>). These are pure functions over the DTOs a page already holds — the
/// medium→schema mapping (AC3), graceful partial data (AC4), JSON-LD validity (AC5), and canonical
/// correctness (AC7) are all testable here without rendering. The JSON-LD is parsed back to a document so
/// each assertion checks real structure, not a substring.
/// </summary>
[TestFixture]
public class SeoModelTests
{
    private static readonly SeoOptions Options = new()
    {
        BaseUrl = "https://deepdrft.com",
        SiteName = "Deep DRFT",
        TitleSuffix = "Deep DRFT",
        DefaultDescription = "default description",
        DefaultImageUrl = "/img/og-default.png",
        Genre = "Electronic",
        SameAs = ["https://instagram.com/deepdrft.music"],
    };

    private static ReleaseDto Release(
        ReleaseMedium medium,
        string? image = "cover.jpg",
        string? description = "desc",
        string title = "Test Release",
        string artist = "Aphex Twin") => new()
    {
        EntryKey = "abc-key",
        Title = title,
        Artist = artist,
        Genre = "House",
        Description = description,
        ImagePath = image,
        ReleaseDate = new DateOnly(2026, 3, 14),
        Medium = medium,
    };

    private static JsonElement Parse(string? json)
    {
        Assert.That(json, Is.Not.Null.And.Not.Empty, "expected a JSON-LD body");
        return JsonDocument.Parse(json!).RootElement;
    }

    // --- AC3: per-medium schema -------------------------------------------------

    [Test]
    public void ForRelease_Cut_IsMusicAlbum_StudioAlbum_WithOrderedTrackList()
    {
        var tracks = new List<TrackDto>
        {
            new() { TrackName = "Second", TrackNumber = 2, DurationSeconds = 60 },
            new() { TrackName = "First", TrackNumber = 1, DurationSeconds = 30 },
        };

        var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks);
        var node = Parse(model.JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"));
            Assert.That(node.GetProperty("albumProductionType").GetString(), Is.EqualTo("https://schema.org/StudioAlbum"));
            Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicAlbum));

            var trackArray = node.GetProperty("track");
            Assert.That(trackArray.GetArrayLength(), Is.EqualTo(2));
            // Ordered by TrackNumber, not input order.
            Assert.That(trackArray[0].GetProperty("name").GetString(), Is.EqualTo("First"));
            Assert.That(trackArray[1].GetProperty("name").GetString(), Is.EqualTo("Second"));
            Assert.That(trackArray[0].GetProperty("duration").GetString(), Is.EqualTo("PT30S"));
        });
    }

    [Test]
    public void ForRelease_Session_IsMusicAlbum_LiveAlbum()
    {
        var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Session));
        var node = Parse(model.JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"));
            Assert.That(node.GetProperty("albumProductionType").GetString(), Is.EqualTo("https://schema.org/LiveAlbum"));
            Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicAlbum));
        });
    }

    [Test]
    public void ForRelease_Mix_IsMusicRecording_WithIsoDuration()
    {
        var tracks = new List<TrackDto> { new() { TrackName = "The Mix", DurationSeconds = 3723 } }; // 1h 2m 3s
        var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks);
        var node = Parse(model.JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"));
            Assert.That(node.GetProperty("duration").GetString(), Is.EqualTo("PT1H2M3S"));
            Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicSong));
            Assert.That(model.DurationSeconds, Is.EqualTo(3723));
            // A mix is one recording, not an album — it carries no track list.
            Assert.That(node.TryGetProperty("track", out _), Is.False);
        });
    }

    [Test]
    public void ForRelease_AllNodes_DeclareSchemaOrgContext()
    {
        foreach (var medium in Enum.GetValues<ReleaseMedium>())
        {
            var node = Parse(SeoModel.ForRelease(Options, Release(medium)).JsonLd);
            Assert.That(node.GetProperty("@context").GetString(), Is.EqualTo("https://schema.org"),
                $"{medium} node must declare the schema.org context");
        }
    }

    // --- AC4: graceful partial data ---------------------------------------------

    [Test]
    public void ForRelease_NoDescription_FallsBackToDefault()
    {
        var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, description: null));
        Assert.That(model.Description, Is.EqualTo(Options.DefaultDescription));
    }

    [Test]
    public void ForRelease_NoCover_OmitsImagePath_SoHeadFallsBackToDefault()
    {
        var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, image: null));
        // The model carries no relative ImagePath; the JSON-LD image is absolutised to the default.
        var node = Parse(model.JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(model.ImagePath, Is.Null);
            Assert.That(node.GetProperty("image").GetString(), Is.EqualTo("https://deepdrft.com/img/og-default.png"));
        });
    }

    [Test]
    public void ForRelease_NoGenre_OmitsGenreProperty_NoEmptyValue()
    {
        var release = Release(ReleaseMedium.Cut);
        release.Genre = null;

        var node = Parse(SeoModel.ForRelease(Options, release).JsonLd);
        Assert.That(node.TryGetProperty("genre", out _), Is.False, "a null genre must be omitted, not emitted empty");
    }

    [Test]
    public void ForRelease_Mix_NoTrack_OmitsDuration()
    {
        var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks: null).JsonLd);
        Assert.That(node.TryGetProperty("duration", out _), Is.False, "a mix with no track must omit duration");
    }

    // --- AC7: canonical correctness ---------------------------------------------

    [TestCase(ReleaseMedium.Cut, "https://deepdrft.com/cuts/abc-key")]
    [TestCase(ReleaseMedium.Session, "https://deepdrft.com/sessions/abc-key")]
    [TestCase(ReleaseMedium.Mix, "https://deepdrft.com/mixes/abc-key")]
    public void ForRelease_CanonicalPath_IsDedicatedRoute_AndJsonLdUrlAgrees(ReleaseMedium medium, string expectedAbsolute)
    {
        var model = SeoModel.ForRelease(Options, Release(medium));
        var node = Parse(model.JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(SeoUrls.Absolute(Options, model.CanonicalPath!), Is.EqualTo(expectedAbsolute));
            // The JSON-LD url must be the same absolute canonical (AC7: canonical == og:url == node url).
            Assert.That(node.GetProperty("url").GetString(), Is.EqualTo(expectedAbsolute));
        });
    }

    // --- Home / About / Browse / NotFound ---------------------------------------

    [Test]
    public void ForHome_IsMusicGroup_WithSameAs()
    {
        var model = SeoModel.ForHome(Options);
        var node = Parse(model.JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"));
            Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Deep DRFT"));
            Assert.That(node.GetProperty("sameAs")[0].GetString(), Is.EqualTo("https://instagram.com/deepdrft.music"));
            Assert.That(model.CanonicalPath, Is.EqualTo("/"));
        });
    }

    [Test]
    public void ForAbout_IsMusicGroup()
    {
        var node = Parse(SeoModel.ForAbout(Options).JsonLd);
        Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"));
    }

    [TestCase(ReleaseMedium.Cut, "/cuts")]
    [TestCase(ReleaseMedium.Session, "/sessions")]
    [TestCase(ReleaseMedium.Mix, "/mixes")]
    public void ForBrowse_IsCollectionPage_WithAbsoluteUrl(ReleaseMedium medium, string path)
    {
        var model = SeoModel.ForBrowse(Options, medium, path);
        var node = Parse(model.JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("CollectionPage"));
            Assert.That(node.GetProperty("url").GetString(), Is.EqualTo($"https://deepdrft.com{path}"));
            Assert.That(model.CanonicalPath, Is.EqualTo(path));
        });
    }

    [Test]
    public void ForBrowse_NullMedium_IsArchive()
    {
        var model = SeoModel.ForBrowse(Options, null, "/archive");
        Assert.That(model.Title, Is.EqualTo("Archive"));
    }

    [Test]
    public void ForNotFound_IsNoindex_NoCanonical_NoJsonLd()
    {
        var model = SeoModel.ForNotFound(Options);

        Assert.Multiple(() =>
        {
            Assert.That(model.Robots, Is.EqualTo("noindex,follow"));
            Assert.That(model.CanonicalPath, Is.Null);
            Assert.That(model.JsonLd, Is.Null);
        });
    }

    // --- byArtist consistency: per-release artist, matching music:musician -------

    [TestCase(ReleaseMedium.Cut)]
    [TestCase(ReleaseMedium.Session)]
    [TestCase(ReleaseMedium.Mix)]
    public void ForRelease_ByArtist_IsPerReleaseArtist_NotCollectiveName(ReleaseMedium medium)
    {
        var model = SeoModel.ForRelease(Options, Release(medium, artist: "Aphex Twin"));
        var node = Parse(model.JsonLd);

        Assert.Multiple(() =>
        {
            // byArtist mirrors the OG music:musician value (the release's own artist), not the SiteName.
            Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.EqualTo("Aphex Twin"));
            Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.Not.EqualTo(Options.SiteName));
            Assert.That(model.Artist, Is.EqualTo("Aphex Twin"));
        });
    }

    [Test]
    public void ForRelease_Cut_AlbumTracks_ByArtist_IsPerReleaseArtist()
    {
        var tracks = new List<TrackDto> { new() { TrackName = "Track", TrackNumber = 1, DurationSeconds = 30 } };
        var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, artist: "Aphex Twin"), tracks).JsonLd);

        Assert.That(node.GetProperty("track")[0].GetProperty("byArtist").GetProperty("name").GetString(),
            Is.EqualTo("Aphex Twin"));
    }

    // --- AC5 regression: no stray CLR `Type` property emitted alongside `@type` ---
    // System.Text.Json previously emitted both `@type` (from the base [JsonPropertyName]) and `Type`
    // (the raw CLR override name) on concrete derived nodes, failing the schema.org validator.
    // The fix: repeat [JsonPropertyName("@type")] directly on each concrete override.

    [Test]
    public void MusicAlbumNode_SerializesAtType_OnlyOnce_NoBareTypeProperty()
    {
        var tracks = new List<TrackDto> { new() { TrackName = "T", TrackNumber = 1, DurationSeconds = 30 } };
        var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks).JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"),
                "MusicAlbumNode must emit @type");
            Assert.That(node.TryGetProperty("Type", out _), Is.False,
                "MusicAlbumNode must NOT emit a bare Type property");
        });
    }

    [Test]
    public void MusicRecordingNode_TopLevel_SerializesAtType_NoBareTypeProperty()
    {
        var tracks = new List<TrackDto> { new() { TrackName = "The Mix", DurationSeconds = 3600 } };
        var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks).JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"),
                "MusicRecordingNode must emit @type");
            Assert.That(node.TryGetProperty("Type", out _), Is.False,
                "MusicRecordingNode must NOT emit a bare Type property");
        });
    }

    [Test]
    public void MusicRecordingNode_NestedTrack_SerializesAtType_NoBareTypeProperty()
    {
        // The nested track[] MusicRecordingNode is a different code path from the top-level mix node.
        var tracks = new List<TrackDto> { new() { TrackName = "T", TrackNumber = 1, DurationSeconds = 30 } };
        var albumNode = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks).JsonLd);
        var trackNode = albumNode.GetProperty("track")[0];

        Assert.Multiple(() =>
        {
            Assert.That(trackNode.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"),
                "nested MusicRecordingNode must emit @type");
            Assert.That(trackNode.TryGetProperty("Type", out _), Is.False,
                "nested MusicRecordingNode must NOT emit a bare Type property");
        });
    }

    [Test]
    public void MusicGroupNode_SerializesAtType_NoBareTypeProperty()
    {
        var node = Parse(SeoModel.ForHome(Options).JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"),
                "MusicGroupNode must emit @type");
            Assert.That(node.TryGetProperty("Type", out _), Is.False,
                "MusicGroupNode must NOT emit a bare Type property");
        });
    }

    [Test]
    public void CollectionPageNode_SerializesAtType_NoBareTypeProperty()
    {
        var node = Parse(SeoModel.ForBrowse(Options, ReleaseMedium.Cut, "/cuts").JsonLd);

        Assert.Multiple(() =>
        {
            Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("CollectionPage"),
                "CollectionPageNode must emit @type");
            Assert.That(node.TryGetProperty("Type", out _), Is.False,
                "CollectionPageNode must NOT emit a bare Type property");
        });
    }

    [Test]
    public void ArtistRef_NestedByArtist_SerializesAtType_NoBareTypeProperty()
    {
        // ArtistRef (byArtist) was already clean — this asserts it stays clean after the fix.
        var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut)).JsonLd);
        var byArtist = node.GetProperty("byArtist");

        Assert.Multiple(() =>
        {
            Assert.That(byArtist.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"),
                "ArtistRef must emit @type");
            Assert.That(byArtist.TryGetProperty("Type", out _), Is.False,
                "ArtistRef must NOT emit a bare Type property");
        });
    }

    [Test]
    public void AllNodes_ContextIsPresent_AndSchemaOrg()
    {
        // Belt-and-suspenders: @context must not regress alongside the @type fix.
        var nodes = new[]
        {
            Parse(SeoModel.ForHome(Options).JsonLd),
            Parse(SeoModel.ForAbout(Options).JsonLd),
            Parse(SeoModel.ForBrowse(Options, ReleaseMedium.Cut, "/cuts").JsonLd),
            Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut)).JsonLd),
            Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Session)).JsonLd),
            Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix)).JsonLd),
        };

        foreach (var node in nodes)
            Assert.That(node.GetProperty("@context").GetString(), Is.EqualTo("https://schema.org"),
                "@context must remain present after the @type fix");
    }

    // --- Critical: inline JSON-LD script-breakout escaping (XSS) -----------------

    [Test]
    public void ForRelease_TitleWithScriptClose_DoesNotEmitRawAngleBracket()
    {
        // A CMS-authored title containing </script> / < must not survive raw in the inline script body —
        // that is a breakout/XSS vector. The escaped < keeps the JSON parseable while neutralising it.
        var release = Release(ReleaseMedium.Cut, title: "Evil</script><script>alert(1)</script>");
        var body = SeoModel.ForRelease(Options, release).JsonLd;

        Assert.That(body, Is.Not.Null);
        Assert.Multiple(() =>
        {
            Assert.That(body, Does.Not.Contain("<"), "no raw < may appear in the inline JSON-LD body");
            Assert.That(body, Does.Not.Contain(">"), "no raw > may appear in the inline JSON-LD body");
            Assert.That(body, Does.Contain("\\u003C"), "< must be emitted as its \\u003C JSON escape");
            // The escaped body still parses as JSON and round-trips the original value.
            var node = Parse(body);
            Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Evil</script><script>alert(1)</script>"));
        });
    }

    [Test]
    public void ForRelease_TitleWithAmpersand_EscapesAmpersand_StillParses()
    {
        // The title is serialized into the JSON-LD `name`, so an ampersand there exercises the & escape.
        var release = Release(ReleaseMedium.Mix, title: "Sound & Vision");
        var body = SeoModel.ForRelease(Options, release).JsonLd;

        Assert.That(body, Is.Not.Null);
        Assert.Multiple(() =>
        {
            Assert.That(body, Does.Not.Contain("&"), "no raw & may appear in the inline JSON-LD body");
            Assert.That(body, Does.Contain("\\u0026"), "& must be emitted as its \\u0026 JSON escape");
            Assert.That(Parse(body).GetProperty("name").GetString(), Is.EqualTo("Sound & Vision"));
        });
    }
}
