using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using ErsatzTV.Middleware;
using Microsoft.AspNetCore.Http;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Middleware;

[TestFixture]
public class SecurityHeadersMiddlewareTests
{
    private static async Task<HttpContext> Invoke(string path)
    {
        var nextCalled = false;
        var middleware = new SecurityHeadersMiddleware(_ =>
        {
            nextCalled = true;
            return Task.CompletedTask;
        });
        var context = new DefaultHttpContext();
        context.Request.Path = path;

        await middleware.InvokeAsync(context);

        nextCalled.ShouldBeTrue();
        return context;
    }

    [Test]
    public async Task Should_Set_Baseline_Security_Headers()
    {
        HttpContext context = await Invoke("/app");

        context.Response.Headers["X-Content-Type-Options"].ToString().ShouldBe("nosniff");
        context.Response.Headers["X-Frame-Options"].ToString().ShouldBe("DENY");
        context.Response.Headers["Referrer-Policy"].ToString().ShouldBe("strict-origin-when-cross-origin");
        context.Response.Headers["Permissions-Policy"].ToString().ShouldNotBeEmpty();
        context.Response.Headers["Cross-Origin-Resource-Policy"].ToString().ShouldBe("same-origin");
    }

    [TestCase("/api/v1/channels")]
    [TestCase("/artwork/x.jpg")]
    [TestCase("/iptv/channels.m3u")]
    [TestCase("/docs")]
    [TestCase("/openapi/v1.json")]
    public async Task Should_Set_Cross_Origin_Resource_Policy_On_Every_Response(string path)
    {
        HttpContext context = await Invoke(path);

        context.Response.Headers["Cross-Origin-Resource-Policy"].ToString().ShouldBe("same-origin");
    }

    [Test]
    public async Task Should_Enforce_Csp_On_Spa_And_Api_Responses()
    {
        foreach (string path in new[] { "/", "/app", "/api/v1/channels", "/artwork/x.jpg", "/iptv/channels.m3u" })
        {
            HttpContext context = await Invoke(path);
            string csp = context.Response.Headers["Content-Security-Policy"].ToString();

            csp.ShouldNotBeEmpty($"CSP should be enforced on {path}");
            csp.ShouldContain("default-src 'self'");
            csp.ShouldContain("script-src 'self' '" + SecurityHeadersMiddleware.SpaInlineScriptHash + "'");
            csp.ShouldContain("connect-src 'self'");
            csp.ShouldContain("frame-ancestors 'none'");
            csp.ShouldContain("object-src 'none'");
            // The SPA CSS @imports the Geist web font from Google (verified by live-E2E).
            csp.ShouldContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com");
            csp.ShouldContain("font-src 'self' data: https://fonts.gstatic.com");
            // No 'unsafe-inline'/'unsafe-eval' in the script directive.
            csp.ShouldNotContain("script-src 'self' 'unsafe");
        }
    }

    [Test]
    public async Task Should_Not_Send_Csp_On_Scalar_Docs_Or_Openapi()
    {
        // Scalar (/docs) and the OpenAPI document (/openapi) rely on inline bootstrap scripts/styles
        // a strict CSP would break; they are intentionally excluded (baseline headers still apply).
        foreach (string path in new[] { "/docs", "/docs/", "/openapi/v1.json" })
        {
            HttpContext context = await Invoke(path);

            context.Response.Headers.ContainsKey("Content-Security-Policy").ShouldBeFalse($"CSP must not be set on {path}");
            // The cheap baseline headers still apply everywhere.
            context.Response.Headers["X-Content-Type-Options"].ToString().ShouldBe("nosniff");
            context.Response.Headers["Cross-Origin-Resource-Policy"].ToString().ShouldBe("same-origin");
        }
    }

    /// <summary>
    ///     Guards <see cref="SecurityHeadersMiddleware.SpaInlineScriptHash" /> against drift from the
    ///     SPA's <c>index.html</c>: every inline <c>&lt;script&gt;</c> the browser will hash-check must
    ///     be allow-listed by the CSP, or the enforcing policy silently breaks the SPA. Hashes the
    ///     built <c>wwwroot/app/index.html</c> when present (the exact bytes the browser hashes), else
    ///     the committed <c>web/index.html</c> source in a fresh CI checkout where the built artifact is
    ///     gitignored/absent (Vite copies the inline script verbatim, so the two agree).
    /// </summary>
    [Test]
    public void Csp_Script_Hash_Should_Match_The_Spa_Index()
    {
        string indexPath = SpaIndexSourcePath();
        File.Exists(indexPath).ShouldBeTrue($"SPA source index.html not found at {indexPath}");

        List<string> inlineHashes = InlineScriptHashes(File.ReadAllText(indexPath));

        inlineHashes.ShouldNotBeEmpty("expected the SPA index.html to contain the inline theme-bootstrap script");
        foreach (string hash in inlineHashes)
        {
            hash.ShouldBe(
                SecurityHeadersMiddleware.SpaInlineScriptHash,
                "an inline <script> in wwwroot/app/index.html is not allow-listed by the CSP — " +
                "update SecurityHeadersMiddleware.SpaInlineScriptHash to this value");
        }
    }

    private static List<string> InlineScriptHashes(string html)
    {
        var hashes = new List<string>();
        foreach (Match m in Regex.Matches(html, "<script([^>]*)>(.*?)</script>", RegexOptions.Singleline))
        {
            // Skip external scripts (they load from 'self', not by hash) and empty bodies.
            if (m.Groups[1].Value.Contains("src", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            string body = m.Groups[2].Value;
            if (string.IsNullOrWhiteSpace(body))
            {
                continue;
            }

            byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(body));
            hashes.Add("sha256-" + Convert.ToBase64String(digest));
        }

        return hashes;
    }

    private static string SpaIndexSourcePath([CallerFilePath] string callerFilePath = "")
    {
        // callerFilePath = <repo>/ErsatzTV.Tests/Middleware/SecurityHeadersMiddlewareTests.cs
        string repoRoot = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(callerFilePath)!, "..", ".."));

        // Prefer the built index.html — it is the exact bytes the browser hashes, so a local build
        // catches any Vite transform of the inline script, not just a source edit. It is gitignored,
        // though, so in a fresh CI checkout it is absent and we fall back to the committed source
        // (Vite copies the inline script verbatim, so the two agree today).
        string built = Path.Combine(repoRoot, "ErsatzTV", "wwwroot", "app", "index.html");
        return File.Exists(built) ? built : Path.Combine(repoRoot, "web", "index.html");
    }
}
