using System.Reflection;
using System.Text.RegularExpressions;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Middleware;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NUnit.Framework;
using Shouldly;

namespace ErsatzTV.Tests.Controllers;

/// <summary>
///     Enforces the #286 route convention: every API-controller action's effective route is
///     versioned and absolute (<c>^/api/v{n}/</c>). This is the standing net for the leading-slash +
///     versioning standardization — a new controller that drifts (a relative or unversioned route)
///     fails CI here, the "fix it while you're in the file" gate the format rules use.
/// </summary>
[TestFixture]
public class ApiRouteVersioningTests
{
    private static readonly Regex VersionedAbsolute = new(@"^/api/v\d+/", RegexOptions.Compiled);

    // Routes on an API controller that deliberately live OUTSIDE /api because they are browser-navigation
    // endpoints, not part of the JSON API surface (api-conventions §9). This is an explicit allowlist, NOT
    // a blanket skip: an accidental non-/api absolute route (e.g. a stray [HttpPost("/channels")]) must fail
    // here, because it would also escape ApiAuthorizationFilter's /api-scoped gate → an unauthenticated
    // mutation. The only intentional entry today is AuthController's OIDC challenge.
    private static readonly string[] KnownNonApiRoutes = ["/auth/oidc/login"];

    [Test]
    public void Every_Api_Controller_Action_Route_Should_Be_Versioned_And_Absolute()
    {
        // Same reflective scan as ApiControllerSecurityTests: [ApiController] concrete classes in the
        // Controllers.Api namespace, so a new controller is covered automatically (no hand list).
        Type[] apiControllers = typeof(CollectionController)
            .Assembly
            .GetTypes()
            .Where(t => t.Namespace == typeof(CollectionController).Namespace)
            .Where(t => t is { IsClass: true, IsAbstract: false })
            .Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
            .ToArray();

        apiControllers.Length.ShouldBeGreaterThanOrEqualTo(20);

        var assertedRoutes = 0;
        foreach (Type controllerType in apiControllers)
        {
            // A class-level [Route] prefix is allowed (the shared-{id} pattern used by Scanner/Scripted);
            // the effective route is what must be versioned + absolute.
            string controllerTemplate = controllerType
                .GetCustomAttributes<RouteAttribute>(inherit: true)
                .Select(r => r.Template)
                .FirstOrDefault();

            foreach (MethodInfo action in controllerType
                         .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
            {
                // IRouteTemplateProvider covers BOTH [HttpGet("...")] (HttpMethodAttribute) and a bare
                // action-level [Route("...")] — so a template-less [HttpGet] paired with [Route("...")]
                // can't slip an unversioned route past this net (cold-review nit, #326).
                foreach (IRouteTemplateProvider routeProvider in action
                             .GetCustomAttributes(inherit: true)
                             .OfType<IRouteTemplateProvider>())
                {
                    string? effective = CombineRoute(controllerTemplate, routeProvider.Template);
                    if (effective is null)
                    {
                        // No route on the controller or the action → not a routable API endpoint; skip.
                        continue;
                    }

                    // A route outside /api must be a KNOWN, intentional browser-nav endpoint — never a
                    // silent skip (see KnownNonApiRoutes above for why: it would also escape the /api-scoped
                    // auth filter). A new one fails here until it's explicitly allowlisted or versioned.
                    if (!effective.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) &&
                        !effective.Equals("/api", StringComparison.OrdinalIgnoreCase))
                    {
                        KnownNonApiRoutes.ShouldContain(
                            effective,
                            $"{controllerType.Name}.{action.Name} route '{effective}' is neither versioned " +
                            "(/api/v1/…) nor a known non-/api browser-nav endpoint — version it, or add it to " +
                            "KnownNonApiRoutes if it is intentionally outside the JSON API surface.");
                        continue;
                    }

                    VersionedAbsolute.IsMatch(effective).ShouldBeTrue(
                        $"{controllerType.Name}.{action.Name} route '{effective}' must be versioned + absolute (^/api/v{{n}}/)");
                    assertedRoutes++;
                }
            }
        }

        // Non-vacuous guard: the whole surface is ~250 routes, so a scan that asserted almost nothing
        // (a reflection regression) would otherwise pass green.
        assertedRoutes.ShouldBeGreaterThanOrEqualTo(150);
    }

    // Mirror ASP.NET Core's attribute-route combination: an action template starting with '/' or '~/'
    // is absolute (ignores the controller prefix); otherwise it is appended to the controller prefix.
    private static string? CombineRoute(string? controllerTemplate, string? actionTemplate)
    {
        if (!string.IsNullOrEmpty(actionTemplate) &&
            (actionTemplate.StartsWith('/') || actionTemplate.StartsWith("~/")))
        {
            return actionTemplate.TrimStart('~');
        }

        if (!string.IsNullOrEmpty(controllerTemplate))
        {
            string prefix = "/" + controllerTemplate.Trim('/');
            return string.IsNullOrEmpty(actionTemplate) ? prefix : prefix + "/" + actionTemplate.TrimStart('/');
        }

        if (string.IsNullOrEmpty(actionTemplate))
        {
            return null;
        }

        return "/" + actionTemplate.TrimStart('/');
    }
}

[TestFixture]
public class ApiVersionRewriteMiddlewareTests
{
    [TestCase("/api/channels", "/api/v1/channels")]
    [TestCase("/api/channels/5", "/api/v1/channels/5")]
    [TestCase("/api/scan/00000000-0000-0000-0000-000000000000/progress",
        "/api/v1/scan/00000000-0000-0000-0000-000000000000/progress")]
    [TestCase("/api/version", "/api/v1/version")] // 'version' must not be mistaken for a version token
    [TestCase("/API/Channels", "/api/v1/Channels")] // case-insensitive on the /api segment
    public void Should_Rewrite_Legacy_Unversioned_Api_Path(string input, string expected)
    {
        ApiVersionRewriteMiddleware.TryRewriteLegacyApiPath(new PathString(input), out PathString rewritten)
            .ShouldBeTrue();
        rewritten.Value.ShouldBe(expected);
    }

    [TestCase("/api/v1/channels")] // already versioned
    [TestCase("/api/v2/channels")] // a future version is passed through, never forced back to v1
    [TestCase("/iptv/channels.m3u")] // non-/api
    [TestCase("/app/")]
    [TestCase("/artwork/posters/1.jpg")]
    [TestCase("/api")] // no trailing segment
    [TestCase("")]
    public void Should_Not_Rewrite(string input)
    {
        ApiVersionRewriteMiddleware.TryRewriteLegacyApiPath(new PathString(input), out PathString rewritten)
            .ShouldBeFalse();
        rewritten.Value.ShouldBe(new PathString(input).Value);
    }
}
