// Search scoping and result-group ordering.
//
// Two independent axes govern how search results are presented:
//   - *scope* narrows which item types are requested from the repository,
//   - *group order* decides the sequence the surviving groups render in.
// Neither one rewrites the other: narrowing to Music and widening back to All
// restores the user's saved arrangement untouched.
//
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067

// Sourced from Rust via the generated bindings — the backend owns what a scope
// *means* (which Jellyfin item types it covers). Naming an opaque variant is
// presentation; knowing its expansion is domain vocabulary and stays in Rust.
export type { SearchScope } from "$lib/api/bindings";
import type { SearchScope } from "$lib/api/bindings";

export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"];

export const SCOPE_LABELS: Record<SearchScope, string> = {
  all: "All",
  music: "Music",
  movies: "Movies",
  tv: "TV",
};

// NOTE: the scope → Jellyfin item-type mapping deliberately does NOT live here.
// It is domain vocabulary and lives in Rust (`SearchScope::item_types()` in
// repository/types.rs); the frontend sends the opaque scope and the backend
// expands it. Re-introducing a `{ music: ["MusicAlbum", …] }` table in this file
// is the boundary leak documented in docs/specs/scoped-search-boundary.md.

/**
 * Resolve the scope a search started from a given route should default to.
 * Pure — takes a pathname, touches no DOM, so it unit-tests directly.
 *
 * TRACES: UR-049 | DR-063
 */
export function resolveSearchScope(pathname: string): SearchScope {
  // Tolerate query strings, hashes and trailing slashes.
  const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";

  if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
  if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
  if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
  // `/library/shows/*` is a legacy TV route that now redirects into
  // `/library/tv?view=genres` (DR-105). Kept so a search typed on the URL
  // before the redirect lands still scopes to TV.
  if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";

  return "all";
}

/**
 * The URL of the single search surface for a query + scope.
 *
 * `/search` is the *only* route that renders results, so every other search
 * affordance (the desktop header bar) is a navigator to this URL rather than a
 * second result renderer. The `all` scope is the page's own default, so it is
 * omitted to keep shared/back-navigated URLs clean.
 *
 * TRACES: UR-049 | DR-063
 */
export function searchRouteUrl(query: string, scope: SearchScope): string {
  const trimmed = query.trim();
  if (!trimmed) return "/search";

  const params = new URLSearchParams({ q: trimmed });
  if (scope !== "all") params.set("scope", scope);
  // URLSearchParams renders spaces as "+", valid in a query but noisier to
  // read; %20 is equally valid and matches how the app builds other links.
  return `/search?${params.toString().replace(/\+/g, "%20")}`;
}

/** Normalise a pathname for comparison: drop query, hash and trailing slashes. */
function normalizePath(pathname: string): string {
  return pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
}

/** Whether `pathname` is the search surface itself. */
export function isSearchRoute(pathname: string): boolean {
  return normalizePath(pathname) === "/search";
}

/**
 * Whether a search typed on `pathname` must navigate to `/search` to be seen.
 *
 * True for every route except `/search` itself: no other page renders
 * `searchResults`, so a search performed there is invisible. Guarding on
 * `/search` keeps typing from pushing a history entry per keystroke.
 *
 * TRACES: UR-049 | DR-063
 */
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
  if (!query.trim()) return false;
  return !isSearchRoute(pathname);
}

/** Read a `?scope=` value, falling back when it is absent or unrecognised. */
export function parseSearchScope(
  raw: string | null | undefined,
  fallback: SearchScope = "all"
): SearchScope {
  return SEARCH_SCOPES.includes(raw as SearchScope) ? (raw as SearchScope) : fallback;
}

/** The query + scope a `/search` URL asks the page to show. */
export interface SearchSeed {
  query: string;
  scope: SearchScope;
}

/**
 * What a `/search` URL should seed the page with, or `null` if it asks for
 * nothing new.
 *
 * The URL is *consumed once per value*, not continuously reconciled against the
 * live input. `applied` is the seed the caller last took from the URL: while it
 * still matches, the user's own typing and chip picks govern, and only a real
 * navigation (the header search sending a new query) re-seeds the page.
 *
 * Reconciling instead of consuming was the bug this replaced — the old effect
 * compared the URL against `library.searchQuery`, so every keystroke's search
 * re-ran it and snapped the input back to the query the header had sent.
 *
 * TRACES: UR-049 | DR-063, DR-064
 */
export function seedFromSearchUrl(
  params: URLSearchParams,
  applied: SearchSeed | null
): SearchSeed | null {
  const seed: SearchSeed = {
    query: params.get("q") ?? "",
    scope: parseSearchScope(params.get("scope")),
  };
  if (applied && applied.query === seed.query && applied.scope === seed.scope) return null;
  return seed;
}

// ---------------------------------------------------------------------------
// Result groups
// ---------------------------------------------------------------------------

export type SearchGroupId =
  | "shows"
  | "episodes"
  | "movies"
  | "songs"
  | "albums"
  | "artists"
  | "people";

/**
 * Shipped default order.
 *
 * TRACES: UR-060 | DR-091
 *
 * Containers lead the kinds they contain — a show above its episodes, an album
 * above nothing (songs are ranked separately) — which matches how people search:
 * you look for the show, not an arbitrary episode of it. `people` sits last as
 * a peripheral match; it exists so searching an actor's name reaches their bio
 * page rather than silently dropping the result.
 */
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
  "shows",
  "episodes",
  "movies",
  "songs",
  "albums",
  "artists",
  "people",
];

export const GROUP_LABELS: Record<SearchGroupId, string> = {
  shows: "TV Shows",
  episodes: "Episodes",
  movies: "Movies",
  songs: "Songs",
  albums: "Albums",
  artists: "Artists",
  people: "People",
};

/**
 * Which scopes each group belongs to (`all` always includes everything).
 *
 * `people` maps to no narrow scope: cast/crew cut across music, film and TV, so
 * it surfaces only under All rather than being forced into one of them.
 */
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all"> | null> = {
  shows: "tv",
  episodes: "tv",
  movies: "movies",
  songs: "music",
  albums: "music",
  artists: "music",
  people: null,
};

/** Item types that fall into each group. */
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
  shows: ["Series"],
  episodes: ["Episode"],
  movies: ["Movie"],
  songs: ["Audio"],
  albums: ["MusicAlbum"],
  artists: ["MusicArtist"],
  people: ["Person"],
};

export function groupItemTypes(group: SearchGroupId): string[] {
  return [...GROUP_ITEM_TYPES[group]];
}

/**
 * Stored group ids that no longer exist, mapped to the ids that replaced them.
 *
 * `tvShows` was one group holding both Series and Episode; it split so a show
 * can outrank its own episodes. Expanding in place preserves the position the
 * user chose for it.
 *
 * TRACES: UR-060 | DR-091
 */
const RETIRED_GROUP_IDS: Record<string, SearchGroupId[]> = {
  tvShows: ["shows", "episodes"],
};

/** Resolve a stored id to the live id(s) it corresponds to, or none if unknown. */
function migrateGroupId(id: string, known: Set<string>): SearchGroupId[] {
  if (known.has(id)) return [id as SearchGroupId];
  return RETIRED_GROUP_IDS[id] ?? [];
}

/**
 * Normalise a stored order into a usable one.
 *
 * The stored array is a *hint*, not a contract: ids that no longer exist are
 * dropped, and groups it never mentions (a user upgrading from a build with
 * fewer groups) are appended in default order rather than lost.
 *
 * TRACES: UR-050 | DR-066
 */
export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
  const known = new Set<string>(DEFAULT_GROUP_ORDER);
  const seen = new Set<SearchGroupId>();
  const order: SearchGroupId[] = [];

  if (Array.isArray(stored)) {
    for (const id of stored) {
      if (typeof id !== "string") continue;
      // Retired ids expand in place rather than being dropped, so a user who
      // dragged the old combined "TV Shows" group to the top keeps TV at the
      // top instead of having shows/episodes appended to the bottom.
      for (const groupId of migrateGroupId(id, known)) {
        if (seen.has(groupId)) continue;
        seen.add(groupId);
        order.push(groupId);
      }
    }
  }

  for (const id of DEFAULT_GROUP_ORDER) {
    if (!seen.has(id)) order.push(id);
  }

  return order;
}

/** Groups visible under a scope, in the user's configured order. */
export function groupsForScope(
  scope: SearchScope,
  order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): SearchGroupId[] {
  // A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives
  // only under `all` — the `=== scope` test already excludes it elsewhere.
  return normalizeGroupOrder(order as SearchGroupId[]).filter(
    (id) => scope === "all" || GROUP_SCOPE[id] === scope
  );
}

export interface SearchGroup<T> {
  id: SearchGroupId;
  label: string;
  items: T[];
}

/**
 * Compose scope, saved order and the results into the sections to render:
 * drop out-of-scope groups, sort by the saved order, omit empty groups.
 *
 * TRACES: UR-050 | DR-067
 */
export function composeSearchGroups<T extends { type?: string | null }>(
  results: readonly T[],
  scope: SearchScope,
  order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): SearchGroup<T>[] {
  return groupsForScope(scope, order)
    .map((id) => {
      const types = GROUP_ITEM_TYPES[id];
      return {
        id,
        label: GROUP_LABELS[id],
        items: results.filter((item) => item.type != null && types.includes(item.type)),
      };
    })
    .filter((group) => group.items.length > 0);
}

/** Move a group one slot up (-1) or down (+1); out-of-range moves are no-ops. */
export function moveGroup(
  order: readonly SearchGroupId[],
  id: SearchGroupId,
  delta: number
): SearchGroupId[] {
  const next = [...order];
  const from = next.indexOf(id);
  if (from === -1) return next;
  const to = from + delta;
  if (to < 0 || to >= next.length) return next;
  next.splice(to, 0, ...next.splice(from, 1));
  return next;
}

/** Move a group from one index to another (drag-and-drop drop handler). */
export function reorderGroups(
  order: readonly SearchGroupId[],
  from: number,
  to: number
): SearchGroupId[] {
  const next = [...order];
  if (from < 0 || from >= next.length || to < 0 || to >= next.length || from === to) return next;
  next.splice(to, 0, ...next.splice(from, 1));
  return next;
}
