using System.Globalization;
using Microsoft.AspNetCore.Http;

namespace ErsatzTV;

// Blazor -> ChicoryTV SPA cutover (ersatztv#91).
//
// The React SPA (served under /app) is the default UI: the legacy Blazor routes
// below 302-redirect to their SPA equivalents. As of phase (b) the Blazor pages
// themselves are DELETED (ersatztv#91). A legacy route that is not in this map
// (nor a live controller/artwork/docs route) is caught by the Startup catch-all
// and 302s to /app, so nothing hard-404s — but a catch-all can only reach the SPA
// root, so this map stays the source of truth for the precise legacy-route ->
// SPA-screen mapping.
//
// The one Blazor route intentionally never redirected was /system/health (the old
// Blazor home / escape hatch); it is deleted with Blazor in phase (b) and now
// falls through to the catch-all like any other retired route.
//
// Matching is two-tier (see TryGetRedirect):
//   Tier 1 = the exact-path dictionary Map below (parameterless routes, plus
//            the (E-base) browse roots whose *targets* carry "?kind=...").
//   Tier 2 = the ordered Patterns list of segment-template rules, consulted
//            only when Tier 1 misses (parameterized routes with one variable
//            segment: an {id} or an {any} kind).
//
// GUARD INVARIANT (see also the collision-freedom note above Patterns): no
// Tier-1 key and no Tier-2 template may begin with /api, /artwork, /docs,
// /openapi, /iptv, or /app. Rules are always full, specific templates — never
// prefix wildcards (a bare "/media/{any}" style rule is forbidden). The legacy
// branch (and its catch-all fallback) does not prefix-guard /api|/artwork|/docs|
// /openapi, so the matcher's specificity is part of their protection.
// (/media/sources IS redirected as of #202/#91b — its Blazor pages were replaced
// by the /app/libraries SPA screens, so the #204-era prefix ban is lifted.)
public static class LegacyUiRedirects
{
    // Blazor route -> SPA route. EXACT paths only (no prefix matching); a single
    // trailing slash on the request is normalized away before lookup.
    public static readonly IReadOnlyDictionary<string, string> Map =
        new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            ["/"] = "/app",
            ["/channels"] = "/app/channels",
            ["/channels/add"] = "/app/new-channel",
            ["/schedules"] = "/app/schedules",
            ["/playouts"] = "/app/playouts",
            ["/media/libraries"] = "/app/libraries",
            ["/settings/ffmpeg"] = "/app/settings/streaming",
            ["/settings/hdhr"] = "/app/settings/system",
            ["/settings/logging"] = "/app/settings/logging",
            ["/settings/playout"] = "/app/settings/playout",
            ["/settings/scanner"] = "/app/settings/scanner",
            ["/settings/ui"] = "/app/settings/general",
            ["/settings/xmltv"] = "/app/settings/xmltv",

            // (A) parameterless routes migrated in ersatztv#204
            ["/channels/numbers"] = "/app/channels",
            ["/search"] = "/app/search",
            ["/system/logs"] = "/app/logs",
            ["/system/troubleshooting"] = "/app/troubleshooting",
            ["/system/troubleshooting/block-playout"] = "/app/troubleshooting/blocks",
            ["/system/troubleshooting/sequential-schedule"] = "/app/troubleshooting/yaml",
            ["/system/troubleshooting/playback"] = "/app/troubleshooting/playback",
            ["/blocks"] = "/app/blocks",
            ["/templates"] = "/app/templates",
            ["/decos"] = "/app/decos",
            ["/deco-templates"] = "/app/deco-templates",
            ["/media/multi-collections"] = "/app/multi-collections",
            ["/media/rerun-collections"] = "/app/rerun-collections",
            ["/media/filler/presets"] = "/app/filler-presets",
            ["/media/collections"] = "/app/collections",
            ["/media/trash"] = "/app/trash",
            ["/media/playlists"] = "/app/playlists",
            ["/media/trakt/lists"] = "/app/trakt-lists",
            ["/ffmpeg"] = "/app/ffmpeg-profiles",
            ["/watermarks"] = "/app/watermarks",
            ["/media/browser/images"] = "/app/media/images/browser",
            ["/schedules/add"] = "/app/schedules",

            // (B) ".../add" sub-actions (ersatztv#204)
            ["/playouts/add"] = "/app/playouts",
            ["/media/multi-collections/add"] = "/app/multi-collections",
            ["/media/rerun-collections/add"] = "/app/rerun-collections",
            ["/media/filler/presets/add"] = "/app/filler-presets",
            ["/media/collections/add"] = "/app/collections",
            ["/ffmpeg/add"] = "/app/ffmpeg-profiles",
            ["/watermarks/add"] = "/app/watermarks",

            // (E-base) browse roots — targets carry "?kind=" (ersatztv#204)
            ["/media/movies"] = "/app/media?kind=movies",
            ["/media/tv/shows"] = "/app/media?kind=shows",
            ["/media/tv/seasons"] = "/app/media?kind=seasons",
            ["/media/tv/episodes"] = "/app/media?kind=episodes",
            ["/media/music/artists"] = "/app/media?kind=artists",
            ["/media/music/videos"] = "/app/media?kind=music-videos",
            ["/media/music/songs"] = "/app/media?kind=songs",
            ["/media/other/videos"] = "/app/media?kind=other-videos",
            ["/media/remote/streams"] = "/app/media?kind=remote-streams",
            ["/media/images"] = "/app/media?kind=images",

            // (F) media sources — SPA screens landed in #202; Blazor pages deleted in #91b.
            // The parameterized ({id}) source routes are Tier-2 patterns below.
            ["/media/sources/local"] = "/app/libraries",
            ["/media/sources/local/add"] = "/app/libraries/local/new",
            ["/media/sources/plex"] = "/app/libraries/plex",
            ["/media/sources/jellyfin"] = "/app/libraries/jellyfin",
            ["/media/sources/jellyfin/edit"] = "/app/libraries/jellyfin/connection",
            ["/media/sources/emby"] = "/app/libraries/emby",
            ["/media/sources/emby/edit"] = "/app/libraries/emby/connection"
        };

    // Tier-2 segment-template rules, consulted only after the exact Map misses.
    // Declaration order IS match order (first match wins) — keep it exactly as
    // grouped below: (C) -> (C2) -> (D) -> (E-page).
    //
    // The rule set is COLLISION-FREE BY CONSTRUCTION — exact-before-pattern plus
    // strict positive-integer {id} validation means no two tiers/rules can match
    // the same path:
    //   - /channels/add, /channels/numbers, /schedules/add, /playouts/add,
    //     /media/collections/add, /media/browser/images are Tier-1 exact hits
    //     (checked first) and their trailing segments are non-numeric anyway, so
    //     they could never match an {id} rule.
    //   - /playouts/classic/{id} vs /playouts/{id}/templates: both 3 segments,
    //     but "classic" fails {id} and "templates" != the {id}-position literal.
    //   - /playouts/add/{any} vs /playouts/{id}/...: "add" fails {id} — disjoint.
    //   - /media/movies/{id} (3 seg) vs /media/movies/page/{id} (4 seg): disjoint
    //     by count; /media/movies/page (3 seg, "page" non-numeric) falls through.
    private static readonly IReadOnlyList<PatternRule> Patterns = new List<PatternRule>
    {
        // (C) id-substituting
        new("/channels/{id}", "/app/edit-channel/{id}"),
        new("/blocks/{id}", "/app/blocks/{id}"),
        new("/templates/{id}", "/app/templates/{id}"),
        new("/decos/{id}", "/app/decos/{id}"),
        new("/deco-templates/{id}", "/app/deco-templates/{id}"),
        new("/media/trakt/lists/{id}", "/app/trakt-lists/{id}"),
        new("/media/movies/{id}", "/app/media/movies/{id}"),
        new("/media/tv/shows/{id}", "/app/media/shows/{id}"),
        new("/media/tv/seasons/{id}", "/app/media/seasons/{id}"),
        new("/media/music/artists/{id}", "/app/media/artists/{id}"),

        // (C2) id-substituting mid-path
        new("/playouts/{id}/alternate-schedules", "/app/playouts/{id}/alternate-schedules"),
        new("/playouts/{id}/templates", "/app/playouts/{id}/templates"),

        // (D) id-dropping
        new("/schedules/{id}", "/app/schedules"),
        new("/schedules/{id}/items", "/app/schedules"),
        new("/playouts/classic/{id}", "/app/playouts"),
        new("/playouts/block/{id}", "/app/playouts"),
        new("/playouts/scripted/{id}", "/app/playouts"),
        new("/playouts/sequential/{id}", "/app/playouts"),
        new("/playouts/add/{any}", "/app/playouts"),
        new("/media/multi-collections/{id}/edit", "/app/multi-collections"),
        new("/media/rerun-collections/{id}/edit", "/app/rerun-collections"),
        new("/media/filler/presets/{id}/edit", "/app/filler-presets"),
        new("/media/collections/{id}/edit", "/app/collections"),
        new("/media/collections/{id}", "/app/collections"),
        new("/ffmpeg/{id}", "/app/ffmpeg-profiles"),
        new("/watermarks/{id}", "/app/watermarks"),

        // (E-page) paging -> "?kind=" ({id} token reused for the page number, value dropped)
        new("/media/movies/page/{id}", "/app/media?kind=movies"),
        new("/media/tv/shows/page/{id}", "/app/media?kind=shows"),
        new("/media/tv/seasons/page/{id}", "/app/media?kind=seasons"),
        new("/media/tv/episodes/page/{id}", "/app/media?kind=episodes"),
        new("/media/music/artists/page/{id}", "/app/media?kind=artists"),
        new("/media/music/videos/page/{id}", "/app/media?kind=music-videos"),
        new("/media/music/songs/page/{id}", "/app/media?kind=songs"),
        new("/media/other/videos/page/{id}", "/app/media?kind=other-videos"),
        new("/media/remote/streams/page/{id}", "/app/media?kind=remote-streams"),
        new("/media/images/page/{id}", "/app/media?kind=images"),

        // (F) media sources — id-carrying source sub-routes (#202 SPA; #91b redirect).
        new("/media/sources/local/{id}/edit", "/app/libraries/local/{id}"),
        new("/media/sources/plex/{id}/libraries", "/app/libraries/plex/{id}/sync"),
        new("/media/sources/plex/{id}/paths", "/app/libraries/plex/{id}/path-replacements"),
        new("/media/sources/jellyfin/{id}/libraries", "/app/libraries/jellyfin/{id}/sync"),
        new("/media/sources/jellyfin/{id}/paths", "/app/libraries/jellyfin/{id}/path-replacements"),
        new("/media/sources/emby/{id}/libraries", "/app/libraries/emby/{id}/sync"),
        new("/media/sources/emby/{id}/paths", "/app/libraries/emby/{id}/path-replacements")
    };

    public static bool TryGetRedirect(PathString path, out string target)
    {
        target = string.Empty;

        string value = path.Value;
        if (string.IsNullOrEmpty(value))
        {
            return false;
        }

        // Normalize a single trailing slash so "/channels/" matches "/channels"
        // (but keep root "/" intact). Guard against a path of all slashes (e.g.
        // "//") collapsing down to "/" and falsely matching the root entry.
        if (value.Length > 1 && value.EndsWith('/'))
        {
            string trimmed = value[..^1];
            if (trimmed == "/")
            {
                return false;
            }

            value = trimmed;
        }

        // Tier 1 — exact map (O(1); covers real traffic).
        if (Map.TryGetValue(value, out string mapped))
        {
            target = mapped;
            return true;
        }

        // Tier 2 — ordered segment-template rules. Split with
        // StringSplitOptions.None (NOT RemoveEmptyEntries) and reject empty
        // segments so "/channels//5" cannot match "/channels/{id}".
        if (value.Length == 0 || value[0] != '/')
        {
            return false;
        }

        string[] segments = value[1..].Split('/', StringSplitOptions.None);
        foreach (string segment in segments)
        {
            if (segment.Length == 0)
            {
                return false;
            }
        }

        foreach (PatternRule rule in Patterns)
        {
            if (rule.Segments.Length != segments.Length)
            {
                continue;
            }

            var matched = true;
            string capturedId = string.Empty;
            for (var i = 0; i < rule.Segments.Length; i++)
            {
                string template = rule.Segments[i];
                string segment = segments[i];

                if (template == "{id}")
                {
                    if (!IsPositiveInteger(segment))
                    {
                        matched = false;
                        break;
                    }

                    capturedId = segment;
                }
                else if (template == "{any}")
                {
                    // any non-empty segment (already guaranteed non-empty above)
                }
                else if (!string.Equals(template, segment, StringComparison.OrdinalIgnoreCase))
                {
                    matched = false;
                    break;
                }
            }

            if (matched)
            {
                target = capturedId.Length > 0
                    ? rule.Target.Replace("{id}", capturedId)
                    : rule.Target;
                return true;
            }
        }

        return false;
    }

    // Appends the incoming request query string to a redirect target that may
    // already carry its own query (e.g. "/app/media?kind=movies"). Merges with
    // '&' instead of producing a malformed double '?'. Targets WITHOUT a '?'
    // behave byte-for-byte as before (verbatim append). A duplicated key after a
    // merge (e.g. incoming "?kind=shows" onto "?kind=movies") is first-wins in
    // the SPA (URLSearchParams.get returns the first value) — acceptable.
    public static string AppendQueryString(string target, QueryString query)
    {
        if (!query.HasValue || query.Value is null || query.Value.Length <= 1)
        {
            return target;
        }

        return target.Contains('?')
            ? target + "&" + query.Value[1..]
            : target + query.Value;
    }

    // A {id} segment must be a positive integer. NumberStyles.None rejects signs,
    // whitespace and separators; TryParse failure also rejects overflow garbage
    // like "999999999999". The raw matched text (e.g. "007") is substituted — the
    // SPA's parseInt handles leading zeros, so do not re-format.
    private static bool IsPositiveInteger(string segment) =>
        int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int id) && id > 0;

    // Exposed for the guard-invariant meta-test (InternalsVisibleTo ErsatzTV.Tests):
    // the source-side template of every Tier-2 rule, so the test can assert no
    // template — not just no Tier-1 Map key — begins with a forbidden prefix.
    internal static IEnumerable<string> PatternTemplates => Patterns.Select(rule => rule.Template);

    private sealed class PatternRule
    {
        public PatternRule(string template, string target)
        {
            Template = template;
            Segments = template.Split('/', StringSplitOptions.RemoveEmptyEntries);
            Target = target;
        }

        // The raw legacy route template, e.g. "/channels/{id}" (source side).
        public string Template { get; }

        // e.g. ["channels", "{id}"]
        public string[] Segments { get; }

        // e.g. "/app/edit-channel/{id}"
        public string Target { get; }
    }
}
