namespace ErsatzTV.Mcp;

/// <summary>
/// The explicit, narrow set of tools this MCP server exposes over the frozen ErsatzTV
/// <c>/api/v1</c> surface. Read tools are always available; write tools execute only when the
/// operator sets <c>ERSATZTV_ALLOW_WRITES=true</c> (enforced at runtime in <see cref="ErsatzTvApiClient"/>).
///
/// Deliberately deferred from this v0.1 (documented in docs/mcp.md): the ~40-field schedule-item
/// and playout alternate-schedule/template replace-list writes, and the redesign-aware workflow
/// tools (#63–#68).
/// </summary>
public static class ToolCatalog
{
    public static IReadOnlyList<ToolDefinition> All { get; } =
    [
        // ---- Reads ----
        Get("ersatztv_list_channels", "List channels.", "/api/v1/channels"),
        Get("ersatztv_get_channel", "Get a channel by id.", "/api/v1/channels/{id}", IdPath("Channel id.")),
        Get("ersatztv_list_collections", "List collections.", "/api/v1/collections"),
        Get("ersatztv_get_collection", "Get a collection by id.", "/api/v1/collections/{id}", IdPath("Collection id.")),
        Get(
            "ersatztv_get_collection_items",
            "Get the items in a manual collection (paged). Emits the collection ETag for optimistic-concurrency reorder.",
            "/api/v1/collections/{id}/items",
            [IdPath("Collection id.")],
            Page()),
        Get("ersatztv_list_smart_collections", "List smart collections.", "/api/v1/smart-collections"),
        Get("ersatztv_get_smart_collection", "Get a smart collection by id.", "/api/v1/smart-collections/{id}", IdPath("Smart collection id.")),
        Get("ersatztv_list_schedules", "List schedules.", "/api/v1/schedules"),
        Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
        Get("ersatztv_get_schedule_items", "Get a schedule's items. Emits the schedule ETag.", "/api/v1/schedules/{id}/items", IdPath("Schedule id.")),
        Get("ersatztv_list_playouts", "List playouts.", "/api/v1/playouts"),
        Get("ersatztv_get_playout", "Get a playout by id.", "/api/v1/playouts/{id}", IdPath("Playout id.")),
        Get("ersatztv_get_playout_items", "Get upcoming items (and unscheduled gaps) for a playout.", "/api/v1/playouts/{id}/items", IdPath("Playout id.")),
        Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/v1/ffmpeg/profiles"),
        Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/v1/ffmpeg/profiles/{id}", IdPath("FFmpeg profile id.")),
        Get(
            "ersatztv_get_resolution_by_name",
            "Get an FFmpeg resolution by name.",
            "/api/v1/ffmpeg/resolution/by-name/{name}",
            Str("name", "Resolution name.", required: true, arg: In.Path)),
        Get("ersatztv_list_sessions", "List active HLS sessions.", "/api/v1/sessions"),
        Get("ersatztv_get_version", "Get API and app version.", "/api/v1/version"),
        Get("ersatztv_list_media_sources", "Get all media sources with their libraries.", "/api/v1/media-sources"),
        Get("ersatztv_get_jellyfin_libraries", "Get a Jellyfin source's libraries.", "/api/v1/media-sources/jellyfin/{id}/libraries", IdPath("Jellyfin media source id.")),
        Get("ersatztv_list_local_libraries", "Get all local libraries.", "/api/v1/libraries/local"),
        Get("ersatztv_get_library_scan_status", "Get active library scan status.", "/api/v1/libraries/scan-status"),
        Get(
            "ersatztv_search",
            "Search library items across all media kinds (paged, hydrated results).",
            "/api/v1/search",
            [],
            [Str("query", "Lucene query string.", arg: In.Query), .. Page()]),
        Get(
            "ersatztv_search_all_items",
            "Search library items across all media kinds and return raw id lists — use to discover media ids to add to a collection.",
            "/api/v1/search/all-items",
            [],
            [Str("query", "Lucene query string.", arg: In.Query), .. Page()]),
        Get(
            "ersatztv_search_artists",
            "Search artists by name; returns matching artist ids.",
            "/api/v1/search/artists",
            [],
            [Str("query", "Artist name query.", arg: In.Query)]),

        // ---- Writes (require ERSATZTV_ALLOW_WRITES=true) ----
        Post(
            "ersatztv_create_collection",
            "Create an empty manual collection.",
            "/api/v1/collections",
            Str("name", "Collection name.", required: true)),
        Put(
            "ersatztv_update_collection",
            "Rename a collection and/or toggle its custom playback order.",
            "/api/v1/collections/{id}",
            IdPath("Collection id."),
            Str("name", "Collection name.", required: true),
            Bool("useCustomPlaybackOrder", "Whether the collection uses a custom playback order.")),
        Delete("ersatztv_delete_collection", "Delete a collection.", "/api/v1/collections/{id}", IdPath("Collection id.")),
        Post(
            "ersatztv_add_collection_items",
            "Add media items to a manual collection. Send only the buckets you need; re-adding an already-present item is an idempotent no-op. All referenced ids must exist or the whole batch is rejected (422).",
            "/api/v1/collections/{id}/items",
            IdPath("Collection id."),
            IntArray("movieIds", "Movie ids to add."),
            IntArray("showIds", "Show ids to add."),
            IntArray("seasonIds", "Season ids to add."),
            IntArray("episodeIds", "Episode ids to add."),
            IntArray("artistIds", "Artist ids to add."),
            IntArray("musicVideoIds", "Music video ids to add."),
            IntArray("otherVideoIds", "Other-video ids to add."),
            IntArray("songIds", "Song ids to add."),
            IntArray("imageIds", "Image ids to add."),
            IntArray("remoteStreamIds", "Remote-stream ids to add.")),
        Delete(
            "ersatztv_remove_collection_item",
            "Remove a single media item from a collection.",
            "/api/v1/collections/{id}/items/{mediaItemId}",
            IdPath("Collection id."),
            Int("mediaItemId", "Media item id to remove.", required: true, arg: In.Path)),
        Put(
            "ersatztv_update_collection_custom_order",
            "Replace a collection's custom playback order with the given media-item id order. Honors If-Match (read the ETag from ersatztv_get_collection_items first); omit ifMatch to force-write.",
            "/api/v1/collections/{id}/custom-order",
            IdPath("Collection id."),
            IntArray("mediaItemIds", "Media item ids in the desired playback order.", required: true),
            IfMatch),
        Post(
            "ersatztv_create_smart_collection",
            "Create a smart collection backed by a search query.",
            "/api/v1/smart-collections",
            Str("name", "Smart collection name.", required: true),
            Str("query", "Lucene query defining membership.", required: true)),
        Put(
            "ersatztv_update_smart_collection",
            "Update a smart collection's name and query.",
            "/api/v1/smart-collections/{id}",
            IdPath("Smart collection id."),
            Str("name", "Smart collection name.", required: true),
            Str("query", "Lucene query defining membership.", required: true)),
        Delete("ersatztv_delete_smart_collection", "Delete a smart collection.", "/api/v1/smart-collections/{id}", IdPath("Smart collection id.")),
        Post(
            "ersatztv_create_schedule",
            "Create a program schedule.",
            "/api/v1/schedules",
            [Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
        Put(
            "ersatztv_update_schedule",
            "Update a program schedule's settings.",
            "/api/v1/schedules/{id}",
            [IdPath("Schedule id."), Str("name", "Schedule name.", required: true), .. ScheduleFlags()]),
        Delete("ersatztv_delete_schedule", "Delete a program schedule.", "/api/v1/schedules/{id}", IdPath("Schedule id.")),
        Post(
            "ersatztv_create_playout",
            "Create a playout for a channel. Classic requires programScheduleId; Sequential/Scripted/ExternalJson require scheduleFile.",
            "/api/v1/playouts",
            Int("channelId", "Channel id.", required: true),
            Str("scheduleKind", "One of: Classic, Block, Sequential, Scripted, ExternalJson.", required: true),
            Int("programScheduleId", "Program schedule id (Classic only)."),
            Str("scheduleFile", "Schedule file path (Sequential/Scripted/ExternalJson only).")),
        Put(
            "ersatztv_update_playout",
            "Update a playout's daily rebuild time and/or schedule file. dailyRebuildTime is always applied (null clears the daily reset).",
            "/api/v1/playouts/{id}",
            IdPath("Playout id."),
            Str("dailyRebuildTime", "Daily rebuild time as an ISO 8601 duration/timespan (e.g. \"04:00:00\"); null clears it."),
            Str("scheduleFile", "Schedule file path (Sequential/Scripted/ExternalJson only); omit to leave unchanged.")),
        Delete("ersatztv_delete_playout", "Delete a playout.", "/api/v1/playouts/{id}", IdPath("Playout id.")),
        Post(
            "ersatztv_create_channel",
            "Create a channel. Enum fields take the enum name; GET an existing channel (ersatztv_get_channel) to see valid values and sensible defaults before creating.",
            "/api/v1/channels",
            ChannelFields()),
        Put(
            "ersatztv_update_channel",
            "Update a channel. Send the full desired state; enum fields take the enum name (GET the channel first to copy current values).",
            "/api/v1/channels/{id}",
            [IdPath("Channel id."), .. ChannelFields()]),
        Post(
            "ersatztv_reset_channel_playout",
            "Queue a rebuild of a channel's playout (202 Accepted; 409 if a build is already running).",
            "/api/v1/channels/{id}/playout/reset",
            [IdPath("Channel id.")],
            [Str("mode", "Optional playout build mode; omit for the default. GET a playout to see valid values.", arg: In.Query)]),
        Delete("ersatztv_delete_channel", "Delete a channel.", "/api/v1/channels/{id}", IdPath("Channel id.")),
        Put(
            "ersatztv_enable_jellyfin_library_sync",
            "Replace a Jellyfin source's library sync preferences. The body must be the COMPLETE set of the source's libraries; enabling shouldSyncItems also enqueues a scan. A row absent from the request is left untouched.",
            "/api/v1/media-sources/jellyfin/{id}/libraries",
            IdPath("Jellyfin media source id."),
            ObjArray("libraries", "Complete set of the source's libraries; each element { id: number, shouldSyncItems: boolean }.", required: true)),
        Post(
            "ersatztv_refresh_jellyfin_libraries",
            "Refresh the list of a Jellyfin source's libraries from the server (202 Accepted).",
            "/api/v1/media-sources/jellyfin/{id}/refresh-libraries",
            IdPath("Jellyfin media source id.")),
        Post(
            "ersatztv_scan_jellyfin_collections",
            "Scan a Jellyfin source's collections (202 Accepted; 409 if already scanning).",
            "/api/v1/media-sources/jellyfin/{id}/scan-collections",
            [IdPath("Jellyfin media source id.")],
            [Bool("deep", "Whether to perform a deep scan.", arg: In.Query)]),
        Post(
            "ersatztv_scan_library",
            "Trigger a scan of a library (202 queued; 404 not found; 409 already scanning; 422 sync disabled).",
            "/api/v1/libraries/{id}/scan",
            [IdPath("Library id.")],
            [Bool("deep", "Whether to perform a deep scan.", arg: In.Query)])
    ];

    public static ToolDefinition? Find(string name) =>
        All.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.Ordinal));

    // ---- Argument location + builders ----

    private enum In
    {
        Path,
        Query,
        Body,
        Header
    }

    private sealed record Arg(string Name, string Type, string Description, bool Required, In Location, string? ItemType = null);

    // A property (not a static field): the `All` initializer runs before a static field declared
    // later would be assigned, which would pass a null Arg here. A property is evaluated on access.
    private static Arg IfMatch => new(
        ErsatzTvApiClient.IfMatchArgument,
        "string",
        "Optional RFC 7232 ETag from a prior read (e.g. \"3\") for optimistic concurrency; omit to force-write.",
        Required: false,
        In.Header);

    private static Arg IdPath(string description) => new("id", "integer", description, Required: true, In.Path);

    private static Arg Str(string name, string description, bool required = false, In arg = In.Body) =>
        new(name, "string", description, required, arg);

    private static Arg Int(string name, string description, bool required = false, In arg = In.Body) =>
        new(name, "integer", description, required, arg);

    private static Arg Bool(string name, string description, In arg = In.Body) =>
        new(name, "boolean", description, Required: false, arg);

    private static Arg IntArray(string name, string description, bool required = false) =>
        new(name, "array", description, required, In.Body, ItemType: "integer");

    private static Arg ObjArray(string name, string description, bool required = false) =>
        new(name, "array", description, required, In.Body, ItemType: "object");

    private static Arg[] Page() =>
    [
        Int("pageNum", "1-based page number (optional).", arg: In.Query),
        Int("pageSize", "Page size (optional).", arg: In.Query)
    ];

    // The channel create/update body (CreateChannelRequest / UpdateChannelRequest — the id comes from
    // the route on update). Only name/number/ffmpegProfileId are marked required; the rest have
    // server-side defaults. Enum fields are typed "string" (the enum name) — the API validates them.
    private static Arg[] ChannelFields() =>
    [
        Str("name", "Channel name.", required: true),
        Str("number", "Channel number (e.g. \"5\" or \"5.1\").", required: true),
        Str("group", "Channel group."),
        Str("categories", "Comma-separated categories."),
        Int("ffmpegProfileId", "FFmpeg profile id.", required: true),
        new Arg("slugSeconds", "number", "Optional slug/pad seconds.", Required: false, In.Body),
        new Arg("logo", "object", "Channel logo as an ArtworkContentTypeModel object.", Required: false, In.Body),
        Str("streamSelectorMode", "ChannelStreamSelectorMode enum name."),
        Str("streamSelector", "Stream selector value (when streamSelectorMode uses one)."),
        Str("preferredAudioLanguageCode", "Preferred audio language code."),
        Str("preferredAudioTitle", "Preferred audio title."),
        Str("playoutSource", "ChannelPlayoutSource enum name."),
        Str("playoutMode", "ChannelPlayoutMode enum name."),
        Int("mirrorSourceChannelId", "Channel id to mirror (when playoutSource is a mirror)."),
        Str("playoutOffset", "Playout offset as a timespan (e.g. \"01:00:00\")."),
        Str("streamingMode", "StreamingMode enum name."),
        Int("watermarkId", "Watermark id."),
        Int("fallbackFillerId", "Fallback filler id."),
        Str("preferredSubtitleLanguageCode", "Preferred subtitle language code."),
        Str("subtitleMode", "ChannelSubtitleMode enum name."),
        Str("musicVideoCreditsMode", "ChannelMusicVideoCreditsMode enum name."),
        Str("musicVideoCreditsTemplate", "Music video credits template name."),
        Str("songVideoMode", "ChannelSongVideoMode enum name."),
        Str("transcodeMode", "ChannelTranscodeMode enum name."),
        Str("idleBehavior", "ChannelIdleBehavior enum name."),
        Bool("isEnabled", "Whether the channel is enabled."),
        Bool("showInEpg", "Whether the channel appears in the EPG/guide.")
    ];

    private static Arg[] ScheduleFlags() =>
    [
        Bool("keepMultiPartEpisodesTogether", "Keep multi-part episodes together."),
        Bool("treatCollectionsAsShows", "Treat collections as shows."),
        Bool("shuffleScheduleItems", "Shuffle schedule items."),
        Bool("randomStartPoint", "Use a random start point."),
        Str("fixedStartTimeBehavior", "Fixed start-time behavior (enum name; GET a schedule to see valid values).")
    ];

    // ---- Tool factories ----

    private static ToolDefinition Get(string name, string description, string path, params Arg[] args) =>
        Tool(name, description, HttpMethod.Get, path, args);

    private static ToolDefinition Get(string name, string description, string path, Arg[] pathArgs, Arg[] otherArgs) =>
        Tool(name, description, HttpMethod.Get, path, [.. pathArgs, .. otherArgs]);

    private static ToolDefinition Post(string name, string description, string path, params Arg[] args) =>
        Tool(name, description, HttpMethod.Post, path, args);

    private static ToolDefinition Post(string name, string description, string path, Arg[] pathArgs, Arg[] otherArgs) =>
        Tool(name, description, HttpMethod.Post, path, [.. pathArgs, .. otherArgs]);

    private static ToolDefinition Put(string name, string description, string path, params Arg[] args) =>
        Tool(name, description, HttpMethod.Put, path, args);

    private static ToolDefinition Delete(string name, string description, string path, params Arg[] args) =>
        Tool(name, description, HttpMethod.Delete, path, args);

    private static ToolDefinition Tool(string name, string description, HttpMethod method, string path, Arg[] args)
    {
        System.Text.Json.JsonDocument schema = args.Length == 0
            ? ToolInputSchemas.Empty
            : ToolInputSchemas.Object(
                args.Select(a => new SchemaProperty(a.Name, a.Type, a.Description, a.Required, a.ItemType)).ToArray());

        var queryParameters = args
            .Where(a => a.Location == In.Query)
            .Select(a => a.Name)
            .ToHashSet(StringComparer.Ordinal);

        return new ToolDefinition(name, description, method, path, schema, queryParameters);
    }
}
