import { and, desc, eq, sql, asc } from "drizzle-orm";
import { cache } from "react";
import fs from "fs";
import path from "path";
import { env } from "@/env";
// import { alias } from "drizzle-orm/mysql-core";
import { db } from "@/server/db";
import {
  entries,
  searchByTitles,
  searchByAliases,
  searchByOrigins,
  labels,
  labeltypes,
  authors,
  publishers,
  languages,
  machinetypes,
  genretypes,
  categories,
  files,
  filetypes,
  releases,
  downloads,
  scraps,
  schemetypes,
  sourcetypes,
  casetypes,
  availabletypes,
  currencies,
  roletypes,
  aliases,
  relatedlicenses,
  licenses,
  licensetypes,
  licensors,
  permissions,
  permissiontypes,
  origintypes,
  relations,
  relationtypes,
  tags,
  tagtypes,
  members,
  ports,
  platforms,
  remakes,
  scores,
  notes,
  notetypes,
  webrefs,
  websites,
  magazines,
  issues,
  magrefs,
  searchByMagrefs,
  referencetypes,
  countries,
  softwareHashes,
} from "@/server/schema/zxdb";

export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";

export interface SearchParams {
  q?: string;
  page?: number; // 1-based
  pageSize?: number; // default 20
  // Optional simple filters (ANDed together)
  genreId?: number;
  languageId?: string;
  machinetypeId?: number | number[];
  // Year filter
  year?: number;
  // Sorting
  sort?: "title" | "id_desc";
  // Search scope (defaults to titles only)
  scope?: EntrySearchScope;
}

export interface SearchResultItem {
  id: number;
  title: string;
  isXrated: number;
  genreId: number | null;
  genreName?: string | null;
  machinetypeId: number | null;
  machinetypeName?: string | null;
  languageId: string | null;
  languageName?: string | null;
}

export interface PagedResult<T> {
  items: T[];
  page: number;
  pageSize: number;
  total: number;
}

export interface FacetItem<T extends number | string> {
  id: T;
  name: string;
  count: number;
}

export interface EntryFacets {
  genres: FacetItem<number>[];
  languages: FacetItem<string>[];
  machinetypes: FacetItem<number>[];
  flags: {
    hasAliases: number;
    hasOrigins: number;
  };
}

/**
 * Resolves a local link for a given file link if mirroring is enabled and the file exists.
 */
function resolveLocalLink(fileLink: string): string | null {
  let localPath: string | null = null;
  let source: "zxdb" | "wos" | null = null;
  let subPath: string | null = null;

  const zxdbPrefix = env.ZXDB_FILE_PREFIX || "";
  const wosPrefix = env.WOS_FILE_PREFIX || "";

  if (fileLink.startsWith(zxdbPrefix) && env.ZXDB_LOCAL_FILEPATH) {
    subPath = fileLink.slice(zxdbPrefix.replace(/\/$/, "").length);
    localPath = path.join(env.ZXDB_LOCAL_FILEPATH, subPath);
    source = "zxdb";
  } else if (fileLink.startsWith(wosPrefix) && env.WOS_LOCAL_FILEPATH) {
    subPath = fileLink.slice(wosPrefix.replace(/\/$/, "").length);
    localPath = path.join(env.WOS_LOCAL_FILEPATH, subPath);
    source = "wos";
  }

  if (localPath && fs.existsSync(localPath) && source && subPath) {
    // Return an application-relative URL instead of the absolute filesystem path
    return `/api/zxdb/download?source=${source}&path=${encodeURIComponent(subPath)}`;
  }

  return null;
}

function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
  const parts: Array<ReturnType<typeof sql>> = [
    sql`select ${searchByTitles.entryId} as entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern}`,
  ];
  if (scope !== "title") {
    parts.push(sql`select ${searchByAliases.entryId} as entry_id from ${searchByAliases} where lower(${searchByAliases.libraryTitle}) like ${pattern}`);
  }
  if (scope === "title_aliases_origins") {
    parts.push(sql`select ${searchByOrigins.entryId} as entry_id from ${searchByOrigins} where lower(${searchByOrigins.libraryTitle}) like ${pattern}`);
  }
  return sql.join(parts, sql` union `);
}

export interface MagazineListItem {
  id: number;
  title: string;
  languageId: string;
  issueCount: number;
}

export interface MagazineDetail {
  id: number;
  title: string;
  languageId: string;
  linkSite?: string | null;
  linkMask?: string | null;
  archiveMask?: string | null;
  issues: Array<{
    id: number;
    dateYear: number | null;
    dateMonth: number | null;
    number: number | null;
    volume: number | null;
    special: string | null;
    supplement: string | null;
    linkMask?: string | null;
    archiveMask?: string | null;
  }>;
}

export interface IssueDetail {
  id: number;
  magazine: { id: number; title: string };
  dateYear: number | null;
  dateMonth: number | null;
  number: number | null;
  volume: number | null;
  special: string | null;
  supplement: string | null;
  linkMask?: string | null;
  archiveMask?: string | null;
  refs: Array<{
    id: number;
    page: number;
    typeId: number;
    typeName: string;
    entryId: number | null;
    entryTitle: string | null;
    labelId: number | null;
    labelName: string | null;
    isOriginal: number;
    scoreGroup: string;
  }>
}

export async function searchEntries(params: SearchParams): Promise<PagedResult<SearchResultItem>> {
  const q = (params.q ?? "").trim();
  const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
  const page = Math.max(1, params.page ?? 1);
  const offset = (page - 1) * pageSize;
  const sort = params.sort ?? (q ? "title" : "id_desc");
  const scope: EntrySearchScope = params.scope ?? "title";
  const hasMachineFilter = Array.isArray(params.machinetypeId)
    ? params.machinetypeId.length > 0
    : typeof params.machinetypeId === "number";
  const preferMachineOrder = hasMachineFilter
    ? null
    : sql`case
        when ${entries.machinetypeId} = 27 then 0
        when ${entries.machinetypeId} = 26 then 1
        when ${entries.machinetypeId} = 8 then 2
        when ${entries.machinetypeId} = 9 then 3
        else 4
      end`;

  if (q.length === 0) {
    // Default listing: return first page by id desc (no guaranteed ordering field; using id)
    // Apply optional filters even without q
    const whereClauses: Array<ReturnType<typeof sql>> = [];
    if (typeof params.genreId === "number") {
      whereClauses.push(eq(entries.genretypeId, params.genreId));
    }
    if (typeof params.languageId === "string") {
      whereClauses.push(eq(entries.languageId, params.languageId));
    }
    if (typeof params.machinetypeId === "number") {
      whereClauses.push(eq(entries.machinetypeId, params.machinetypeId));
    } else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
      const ids = params.machinetypeId.map((id) => sql`${id}`);
      whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
    }
    if (typeof params.year === "number") {
      whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
    }

    const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;

    const [items, countRows] = await Promise.all([
      (async () => {
        const q1 = db
          .select({
            id: entries.id,
            title: entries.title,
            isXrated: entries.isXrated,
            genreId: entries.genretypeId,
            genreName: genretypes.name,
            machinetypeId: entries.machinetypeId,
            machinetypeName: machinetypes.name,
            languageId: entries.languageId,
            languageName: languages.name,
          })
          .from(entries)
          .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
          .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
          .leftJoin(languages, eq(languages.id, entries.languageId))
          .where(whereExpr ?? sql`true`)
          .orderBy(
            ...(preferMachineOrder ? [preferMachineOrder] : []),
            sort === "id_desc" ? desc(entries.id) : entries.title
          )
          .limit(pageSize)
          .offset(offset);
        return q1;
      })(),
      db
        .select({ total: sql<number>`count(*)` })
        .from(entries)
        .where(whereExpr ?? sql`true`),
    ]);
    const total = Number(countRows?.[0]?.total ?? 0);
    return { items, page, pageSize, total };
  }

  const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;

  if (scope !== "title") {
    try {
      const union = buildEntrySearchUnion(pattern, scope);
      const whereClauses: Array<ReturnType<typeof sql>> = [
        sql`${entries.id} in (select entry_id from (${union}) as matches)`
      ];
      if (typeof params.genreId === "number") {
        whereClauses.push(eq(entries.genretypeId, params.genreId));
      }
      if (typeof params.languageId === "string") {
        whereClauses.push(eq(entries.languageId, params.languageId));
      }
      if (typeof params.machinetypeId === "number") {
        whereClauses.push(eq(entries.machinetypeId, params.machinetypeId));
      } else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
        const ids = params.machinetypeId.map((id) => sql`${id}`);
        whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
      }
      if (typeof params.year === "number") {
        whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
      }
      const whereExpr = and(...whereClauses);

      const countRows = await db.execute(sql`
        select count(distinct id) as total
        from entries
        where ${whereExpr}
      `);
      type CountRow = { total: number | string };
      const total = Number((countRows as unknown as CountRow[])[0]?.total ?? 0);

      const items = await db
        .select({
          id: entries.id,
          title: entries.title,
          isXrated: entries.isXrated,
          genreId: entries.genretypeId,
          genreName: genretypes.name,
          machinetypeId: entries.machinetypeId,
          machinetypeName: machinetypes.name,
          languageId: entries.languageId,
          languageName: languages.name,
        })
        .from(entries)
        .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
        .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
        .leftJoin(languages, eq(languages.id, entries.languageId))
        .where(whereExpr)
        .groupBy(entries.id)
        .orderBy(
          ...(preferMachineOrder ? [preferMachineOrder] : []),
          sort === "id_desc" ? desc(entries.id) : entries.title
        )
        .limit(pageSize)
        .offset(offset);

      return { items, page, pageSize, total };
    } catch {
      // Fall through to title-only search if helper tables are unavailable.
    }
  }

  // Count matches via helper table
  const whereClauses: Array<ReturnType<typeof sql>> = [
    sql`lower(${searchByTitles.entryTitle}) like ${pattern}`
  ];
  if (typeof params.genreId === "number") {
    whereClauses.push(eq(entries.genretypeId, params.genreId));
  }
  if (typeof params.languageId === "string") {
    whereClauses.push(eq(entries.languageId, params.languageId));
  }
  if (typeof params.machinetypeId === "number") {
    whereClauses.push(eq(entries.machinetypeId, params.machinetypeId));
  } else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
    const ids = params.machinetypeId.map((id) => sql`${id}`);
    whereClauses.push(sql`${entries.machinetypeId} in (${sql.join(ids, sql`, `)})`);
  }
  if (typeof params.year === "number") {
    whereClauses.push(sql`${entries.id} in (select entry_id from releases where release_year = ${params.year})`);
  }
  const whereExpr = and(...whereClauses);

  const countRows = await db
    .select({ total: sql<number>`count(distinct ${searchByTitles.entryId})` })
    .from(searchByTitles)
    .innerJoin(entries, eq(entries.id, searchByTitles.entryId))
    .where(whereExpr);

  const total = Number(countRows[0]?.total ?? 0);

  // Items using join to entries, distinct entry ids
  const items = await db
    .select({
      id: entries.id,
      title: entries.title,
      isXrated: entries.isXrated,
      genreId: entries.genretypeId,
      genreName: genretypes.name,
      machinetypeId: entries.machinetypeId,
      machinetypeName: machinetypes.name,
      languageId: entries.languageId,
      languageName: languages.name,
    })
    .from(searchByTitles)
    .innerJoin(entries, eq(entries.id, searchByTitles.entryId))
    .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
    .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
    .leftJoin(languages, eq(languages.id, entries.languageId))
    .where(whereExpr)
    .groupBy(entries.id)
    .orderBy(
      ...(preferMachineOrder ? [preferMachineOrder] : []),
      sort === "id_desc" ? desc(entries.id) : entries.title
    )
    .limit(pageSize)
    .offset(offset);

  return { items, page, pageSize, total };
}

export interface LabelSummary {
  id: number;
  name: string;
  labeltypeId: string | null;
}

export interface EntryDetail {
  id: number;
  title: string;
  isXrated: number;
  machinetype: { id: number | null; name: string | null };
  language: { id: string | null; name: string | null };
  genre: { id: number | null; name: string | null };
  authors: LabelSummary[];
  publishers: LabelSummary[];
  licenses?: {
    id: number;
    name: string;
    type: { id: string; name: string | null };
    isOfficial: boolean;
    linkWikipedia?: string | null;
    linkSite?: string | null;
    comments?: string | null;
  }[];
  relations?: {
    direction: "from" | "to";
    type: { id: string; name: string | null };
    entry: { id: number; title: string | null };
  }[];
  tags?: {
    id: number;
    name: string;
    type: { id: string; name: string | null };
    category: { id: number | null; name: string | null };
    memberSeq: number | null;
    link: string | null;
    comments: string | null;
  }[];
  ports?: {
    id: number;
    title: string | null;
    platform: { id: number; name: string | null };
    isOfficial: boolean;
    linkSystem: string | null;
  }[];
  remakes?: {
    id: number;
    title: string;
    fileLink: string;
    fileDate: string | null;
    fileSize: number | null;
    authors: string | null;
    platforms: string | null;
    remakeYears: string | null;
    remakeStatus: string | null;
  }[];
  scores?: {
    website: { id: number; name: string | null };
    score: number;
    votes: number;
  }[];
  notes?: {
    id: number;
    type: { id: string; name: string | null };
    text: string;
  }[];
  origins?: {
    type: { id: string; name: string | null };
    libraryTitle: string;
    publication: string | null;
    containerId: number | null;
    issueId: number | null;
    issue: { id: number; magazineId: number | null; magazineTitle: string | null } | null;
    date: { year: number | null; month: number | null; day: number | null };
  }[];
  // Additional entry fields for richer details
  maxPlayers?: number;
  availabletypeId?: string | null;
  withoutLoadScreen?: number;
  withoutInlay?: number;
  issueId?: number | null;
  files?: {
    id: number;
    link: string;
    size: number | null;
    md5: string | null;
    comments: string | null;
    type: { id: number; name: string };
  }[];
  // Flat downloads by entry_id (no dependency on releases)
  downloadsFlat?: {
    id: number;
    link: string;
    size: number | null;
    md5: string | null;
    comments: string | null;
    isDemo: boolean;
    type: { id: number; name: string };
    language: { id: string | null; name: string | null };
    machinetype: { id: number | null; name: string | null };
    scheme: { id: string | null; name: string | null };
    source: { id: string | null; name: string | null };
    case: { id: string | null; name: string | null };
    year: number | null;
    releaseSeq: number;
    localLink?: string | null;
  }[];
  releases?: {
    releaseSeq: number;
    type: { id: string | null; name: string | null };
    language: { id: string | null; name: string | null };
    machinetype: { id: number | null; name: string | null };
    year: number | null;
    comments: string | null;
    downloads: {
      id: number;
      link: string;
      size: number | null;
      md5: string | null;
      comments: string | null;
      isDemo: boolean;
      type: { id: number; name: string };
      language: { id: string | null; name: string | null };
      machinetype: { id: number | null; name: string | null };
      scheme: { id: string | null; name: string | null };
      source: { id: string | null; name: string | null };
      case: { id: string | null; name: string | null };
      year: number | null;
      localLink?: string | null;
    }[];
  }[];
  // Additional relationships surfaced on the entry detail page
  aliases?: { releaseSeq: number; languageId: string; title: string }[];
  webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
  magazineRefs?: {
    id: number;
    issueId: number;
    magazineId: number | null;
    magazineName: string | null;
    referencetypeId: number;
    referencetypeName: string | null;
    page: number;
    isOriginal: number;
    scoreGroup: string;
    issue: {
      dateYear: number | null;
      dateMonth: number | null;
      dateDay: number | null;
      volume: number | null;
      number: number | null;
      special: string | null;
      supplement: string | null;
    };
  }[];
}

export async function getEntryById(id: number): Promise<EntryDetail | null> {

  // Run base row + contributors in parallel to reduce latency
  const [rows, authorRows, publisherRows] = await Promise.all([
    db
      .select({
        id: entries.id,
        title: entries.title,
        isXrated: entries.isXrated,
        machinetypeId: entries.machinetypeId,
        machinetypeName: machinetypes.name,
        languageId: entries.languageId,
        languageName: languages.name,
        genreId: entries.genretypeId,
        genreName: genretypes.name,
        maxPlayers: entries.maxPlayers,
        availabletypeId: entries.availabletypeId,
        withoutLoadScreen: entries.withoutLoadScreen,
        withoutInlay: entries.withoutInlay,
        issueId: entries.issueId,
      })
      .from(entries)
      .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
      .leftJoin(languages, eq(languages.id, entries.languageId))
      .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
      .where(eq(entries.id, id)),
    db
      .select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
      .from(authors)
      .innerJoin(labels, eq(labels.id, authors.labelId))
      .where(eq(authors.entryId, id))
      .groupBy(labels.id),
    db
      .select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
      .from(publishers)
      .innerJoin(labels, eq(labels.id, publishers.labelId))
      .where(eq(publishers.entryId, id))
      .groupBy(labels.id),
  ]);

  const base = rows[0];
  if (!base) return null;

  // Fetch related files if the entry is associated with an issue
  let fileRows: {
    id: number;
    link: string;
    size: number | null;
    md5: string | null;
    comments: string | null;
    typeId: number;
    typeName: string;
  }[] = [];

  if (base.issueId != null) {
    fileRows = (await db
      .select({
        id: files.id,
        link: files.fileLink,
        size: files.fileSize,
        md5: files.fileMd5,
        comments: files.comments,
        typeId: filetypes.id,
        typeName: filetypes.name,
      })
      .from(files)
      .innerJoin(filetypes, eq(filetypes.id, files.filetypeId))
      .where(eq(files.issueId, base.issueId)));
  }

  type ReleaseRow = { releaseSeq: number | string; year: number | string | null };
  type DownloadRow = {
    id: number | string;
    releaseSeq: number | string;
    link: string;
    size: number | string | null;
    md5: string | null;
    comments: string | null;
    isDemo: number | boolean | null;
    filetypeId: number | string;
    filetypeName: string;
    dlLangId: string | null;
    dlLangName: string | null;
    dlMachineId: number | string | null;
    dlMachineName: string | null;
    schemeId: string | null;
    schemeName: string | null;
    sourceId: string | null;
    sourceName: string | null;
    caseId: string | null;
    caseName: string | null;
    year: number | string | null;
  };
  let releaseRows: ReleaseRow[] = [];
  let downloadRows: DownloadRow[] = [];
  let downloadFlatRows: DownloadRow[] = [];

  // Fetch releases for this entry (optional; ignore if table missing)
  try {
    releaseRows = (await db
      .select({
        releaseSeq: releases.releaseSeq,
        year: releases.releaseYear,
      })
      .from(releases)
      .where(eq(releases.entryId, id)));
  } catch {
    releaseRows = [];
  }

  // Fetch downloads for this entry, join lookups (do not gate behind schema checks)
  try {
    downloadRows = (await db
      .select({
        id: downloads.id,
        releaseSeq: downloads.releaseSeq,
        link: downloads.fileLink,
        size: downloads.fileSize,
        md5: downloads.fileMd5,
        comments: downloads.comments,
        isDemo: downloads.isDemo,
        filetypeId: filetypes.id,
        filetypeName: filetypes.name,
        dlLangId: downloads.languageId,
        dlLangName: languages.name,
        dlMachineId: downloads.machinetypeId,
        dlMachineName: machinetypes.name,
        schemeId: schemetypes.id,
        schemeName: schemetypes.name,
        sourceId: sourcetypes.id,
        sourceName: sourcetypes.name,
        caseId: casetypes.id,
        caseName: casetypes.name,
        year: downloads.releaseYear,
      })
      .from(downloads)
      .innerJoin(filetypes, eq(filetypes.id, downloads.filetypeId))
      .leftJoin(languages, eq(languages.id, downloads.languageId))
      .leftJoin(machinetypes, eq(machinetypes.id, downloads.machinetypeId))
      .leftJoin(schemetypes, eq(schemetypes.id, downloads.schemetypeId))
      .leftJoin(sourcetypes, eq(sourcetypes.id, downloads.sourcetypeId))
      .leftJoin(casetypes, eq(casetypes.id, downloads.casetypeId))
      .where(eq(downloads.entryId, id)));
  } catch {
    downloadRows = [];
  }

  // Flat list: same rows mapped, independent of releases
  downloadFlatRows = downloadRows;

  const downloadsBySeq = new Map<number, DownloadRow[]>();
  for (const row of downloadRows) {
    const key = Number(row.releaseSeq);
    const arr = downloadsBySeq.get(key) ?? [];
    arr.push(row);
    downloadsBySeq.set(key, arr);
  }

  // Build a map of downloads grouped by release_seq
  // Then ensure we create "synthetic" release groups for any release_seq
  // that appears in downloads but has no corresponding releases row.
  const releasesData = releaseRows.map((r) => ({
    releaseSeq: Number(r.releaseSeq),
    type: { id: null, name: null },
    language: { id: null, name: null },
    machinetype: { id: null, name: null },
    year: r.year != null ? Number(r.year) : null,
    comments: null,
    downloads: (downloadsBySeq.get(Number(r.releaseSeq)) ?? []).map((d) => ({
      id: Number(d.id),
      link: d.link,
      size: d.size != null ? Number(d.size) : null,
      md5: d.md5 ?? null,
      comments: d.comments ?? null,
      isDemo: !!d.isDemo,
      type: { id: Number(d.filetypeId), name: d.filetypeName },
      language: { id: (d.dlLangId) ?? null, name: (d.dlLangName) ?? null },
      machinetype: { id: d.dlMachineId != null ? Number(d.dlMachineId) : null, name: (d.dlMachineName) ?? null },
      scheme: { id: (d.schemeId) ?? null, name: (d.schemeName) ?? null },
      source: { id: (d.sourceId) ?? null, name: (d.sourceName) ?? null },
      case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
      year: d.year != null ? Number(d.year) : null,
      localLink: resolveLocalLink(d.link),
    })),
  }));

  // No synthetic release groups: only real releases are returned

  // Sort releases by sequence for stable UI order
  releasesData.sort((a, b) => a.releaseSeq - b.releaseSeq);

  // Fetch extra relationships in parallel
  let aliasRows: { releaseSeq: number | string; languageId: string; title: string }[] = [];
  let webrefRows: { link: string; languageId: string; websiteId: number | string; websiteName: string; websiteLink: string | null }[] = [];
  let licenseRows: {
    id: number | string;
    name: string;
    licensetypeId: string;
    licensetypeName: string | null;
    isOfficial: number | boolean;
    linkWikipedia: string | null;
    linkSite: string | null;
    comments: string | null;
  }[] = [];
  let originRows: {
    libraryTitle: string;
    origintypeId: string;
    origintypeName: string | null;
    containerId: number | string | null;
    issueId: number | string | null;
    magazineId: number | string | null;
    magazineTitle: string | null;
    dateYear: number | string | null;
    dateMonth: number | string | null;
    dateDay: number | string | null;
    publication: string | null;
  }[] = [];
  let relationOutRows: {
    relationtypeId: string;
    relationtypeName: string | null;
    relatedId: number | string;
    relatedTitle: string | null;
  }[] = [];
  let relationInRows: {
    relationtypeId: string;
    relationtypeName: string | null;
    relationtypeReciprocal: string | null;
    relatedId: number | string;
    relatedTitle: string | null;
  }[] = [];
  let tagRows: {
    id: number | string;
    name: string;
    tagtypeId: string;
    tagtypeName: string | null;
    categoryId: number | string | null;
    categoryName: string | null;
    memberSeq: number | string | null;
    link: string | null;
    comments: string | null;
  }[] = [];
  let portRows: {
    id: number | string;
    title: string | null;
    platformId: number | string;
    platformName: string | null;
    isOfficial: number | boolean;
    linkSystem: string | null;
  }[] = [];
  let remakeRows: {
    id: number | string;
    title: string;
    fileLink: string;
    fileDate: string | null;
    fileSize: number | string | null;
    authors: string | null;
    platforms: string | null;
    remakeYears: string | null;
    remakeStatus: string | null;
  }[] = [];
  let scoreRows: {
    websiteId: number | string;
    websiteName: string | null;
    score: number | string;
    votes: number | string;
  }[] = [];
  let noteRows: {
    id: number | string;
    notetypeId: string;
    notetypeName: string | null;
    text: string;
  }[] = [];
  let magazineRefRows: {
    id: number;
    issueId: number;
    magazineId: number | null;
    magazineName: string | null;
    referencetypeId: number;
    referencetypeName: string | null;
    page: number;
    isOriginal: number;
    scoreGroup: string;
    issueDateYear: number | null;
    issueDateMonth: number | null;
    issueDateDay: number | null;
    issueVolume: number | null;
    issueNumber: number | null;
    issueSpecial: string | null;
    issueSupplement: string | null;
  }[] = [];
  try {
    aliasRows = await db
      .select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title })
      .from(aliases)
      .where(eq(aliases.entryId, id));
  } catch {}
  try {
    const rows = await db
      .select({ link: webrefs.link, languageId: webrefs.languageId, websiteId: websites.id, websiteName: websites.name, websiteLink: websites.link })
      .from(webrefs)
      .innerJoin(websites, eq(websites.id, webrefs.websiteId))
      .where(eq(webrefs.entryId, id));
    webrefRows = rows as typeof webrefRows;
  } catch {}
  try {
    const rows = await db
      .select({
        id: licenses.id,
        name: licenses.name,
        licensetypeId: licenses.licensetypeId,
        licensetypeName: licensetypes.name,
        isOfficial: relatedlicenses.isOfficial,
        linkWikipedia: licenses.linkWikipedia,
        linkSite: licenses.linkSite,
        comments: licenses.comments,
      })
      .from(relatedlicenses)
      .innerJoin(licenses, eq(licenses.id, relatedlicenses.licenseId))
      .leftJoin(licensetypes, eq(licensetypes.id, licenses.licensetypeId))
      .where(eq(relatedlicenses.entryId, id));
    licenseRows = rows as typeof licenseRows;
  } catch {}
  try {
    const rows = await db
      .select({
        libraryTitle: searchByOrigins.libraryTitle,
        origintypeId: searchByOrigins.origintypeId,
        origintypeName: origintypes.name,
        containerId: searchByOrigins.containerId,
        issueId: searchByOrigins.issueId,
        magazineId: magazines.id,
        magazineTitle: magazines.name,
        dateYear: searchByOrigins.dateYear,
        dateMonth: searchByOrigins.dateMonth,
        dateDay: searchByOrigins.dateDay,
        publication: searchByOrigins.publication,
      })
      .from(searchByOrigins)
      .leftJoin(issues, eq(issues.id, searchByOrigins.issueId))
      .leftJoin(magazines, eq(magazines.id, issues.magazineId))
      .leftJoin(origintypes, eq(origintypes.id, searchByOrigins.origintypeId))
      .where(eq(searchByOrigins.entryId, id))
      .orderBy(
        asc(searchByOrigins.origintypeId),
        desc(searchByOrigins.dateYear),
        desc(searchByOrigins.dateMonth),
        desc(searchByOrigins.dateDay),
        asc(searchByOrigins.libraryTitle)
      );
    originRows = rows as typeof originRows;
  } catch {}
  try {
    const rows = await db
      .select({
        relationtypeId: relations.relationtypeId,
        relationtypeName: relationtypes.name,
        relatedId: relations.originalId,
        relatedTitle: entries.title,
      })
      .from(relations)
      .innerJoin(relationtypes, eq(relationtypes.id, relations.relationtypeId))
      .leftJoin(entries, eq(entries.id, relations.originalId))
      .where(eq(relations.entryId, id));
    relationOutRows = rows as typeof relationOutRows;
  } catch {}
  try {
    const rows = await db
      .select({
        relationtypeId: relations.relationtypeId,
        relationtypeName: relationtypes.name,
        relationtypeReciprocal: relationtypes.reciprocal,
        relatedId: relations.entryId,
        relatedTitle: entries.title,
      })
      .from(relations)
      .innerJoin(relationtypes, eq(relationtypes.id, relations.relationtypeId))
      .leftJoin(entries, eq(entries.id, relations.entryId))
      .where(eq(relations.originalId, id));
    relationInRows = rows as typeof relationInRows;
  } catch {}
  try {
    const rows = await db
      .select({
        id: tags.id,
        name: tags.name,
        tagtypeId: tags.tagtypeId,
        tagtypeName: tagtypes.name,
        categoryId: members.categoryId,
        categoryName: categories.name,
        memberSeq: members.memberSeq,
        link: tags.link,
        comments: tags.comments,
      })
      .from(members)
      .innerJoin(tags, eq(tags.id, members.tagId))
      .leftJoin(tagtypes, eq(tagtypes.id, tags.tagtypeId))
      .leftJoin(categories, eq(categories.id, members.categoryId))
      .where(eq(members.entryId, id))
      .orderBy(
        asc(tags.name),
        asc(members.memberSeq)
      );
    tagRows = rows as typeof tagRows;
  } catch {}
  try {
    const rows = await db
      .select({
        id: ports.id,
        title: ports.title,
        platformId: ports.platformId,
        platformName: platforms.name,
        isOfficial: ports.isOfficial,
        linkSystem: ports.linkSystem,
      })
      .from(ports)
      .leftJoin(platforms, eq(platforms.id, ports.platformId))
      .where(eq(ports.entryId, id))
      .orderBy(asc(ports.title));
    portRows = rows as typeof portRows;
  } catch {}
  try {
    const rows = await db
      .select({
        id: remakes.id,
        title: remakes.title,
        fileLink: remakes.fileLink,
        fileDate: remakes.fileDate,
        fileSize: remakes.fileSize,
        authors: remakes.authors,
        platforms: remakes.platforms,
        remakeYears: remakes.remakeYears,
        remakeStatus: remakes.remakeStatus,
      })
      .from(remakes)
      .where(eq(remakes.entryId, id))
      .orderBy(asc(remakes.title));
    remakeRows = rows as typeof remakeRows;
  } catch {}
  try {
    const rows = await db
      .select({
        websiteId: websites.id,
        websiteName: websites.name,
        score: scores.score,
        votes: scores.votes,
      })
      .from(scores)
      .innerJoin(websites, eq(websites.id, scores.websiteId))
      .where(eq(scores.entryId, id));
    scoreRows = rows as typeof scoreRows;
  } catch {}
  try {
    const rows = await db
      .select({
        id: notes.id,
        notetypeId: notes.notetypeId,
        notetypeName: notetypes.name,
        text: notes.text,
      })
      .from(notes)
      .leftJoin(notetypes, eq(notetypes.id, notes.notetypeId))
      .where(eq(notes.entryId, id))
      .orderBy(asc(notes.id));
    noteRows = rows as typeof noteRows;
  } catch {}

  try {
    const rows = await db
      .select({
        id: magrefs.id,
        issueId: magrefs.issueId,
        magazineId: magazines.id,
        magazineName: magazines.name,
        referencetypeId: magrefs.referencetypeId,
        referencetypeName: referencetypes.name,
        page: magrefs.page,
        isOriginal: magrefs.isOriginal,
        scoreGroup: magrefs.scoreGroup,
        issueDateYear: issues.dateYear,
        issueDateMonth: issues.dateMonth,
        issueDateDay: issues.dateDay,
        issueVolume: issues.volume,
        issueNumber: issues.number,
        issueSpecial: issues.special,
        issueSupplement: issues.supplement,
      })
      .from(searchByMagrefs)
      .innerJoin(magrefs, eq(magrefs.id, searchByMagrefs.magrefId))
      .leftJoin(issues, eq(issues.id, magrefs.issueId))
      .leftJoin(magazines, eq(magazines.id, issues.magazineId))
      .leftJoin(referencetypes, eq(referencetypes.id, magrefs.referencetypeId))
      .where(eq(searchByMagrefs.entryId, id))
      .orderBy(
        asc(magazines.name),
        asc(issues.dateYear),
        asc(issues.dateMonth),
        asc(issues.id),
        asc(magrefs.page),
        asc(magrefs.id)
      );
    magazineRefRows = rows as typeof magazineRefRows;
  } catch {}

  return {
    id: base.id,
    title: base.title,
    isXrated: base.isXrated,
    machinetype: { id: (base.machinetypeId) ?? null, name: (base.machinetypeName) ?? null },
    language: { id: (base.languageId) ?? null, name: (base.languageName) ?? null },
    genre: { id: (base.genreId) ?? null, name: (base.genreName) ?? null },
    authors: authorRows,
    publishers: publisherRows,
    licenses: licenseRows.map((l) => ({
      id: Number(l.id),
      name: l.name,
      type: { id: l.licensetypeId, name: l.licensetypeName ?? null },
      isOfficial: !!l.isOfficial,
      linkWikipedia: l.linkWikipedia ?? null,
      linkSite: l.linkSite ?? null,
      comments: l.comments ?? null,
    })),
    relations: [
      ...relationOutRows.map((r) => ({
        direction: "from" as const,
        type: { id: r.relationtypeId, name: r.relationtypeName ?? null },
        entry: { id: Number(r.relatedId), title: r.relatedTitle ?? null },
      })),
      ...relationInRows.map((r) => ({
        direction: "to" as const,
        type: { id: r.relationtypeId, name: r.relationtypeReciprocal ?? r.relationtypeName ?? null },
        entry: { id: Number(r.relatedId), title: r.relatedTitle ?? null },
      })),
    ],
    tags: tagRows.map((t) => ({
      id: Number(t.id),
      name: t.name,
      type: { id: t.tagtypeId, name: t.tagtypeName ?? null },
      category: { id: t.categoryId != null ? Number(t.categoryId) : null, name: t.categoryName ?? null },
      memberSeq: t.memberSeq != null ? Number(t.memberSeq) : null,
      link: t.link ?? null,
      comments: t.comments ?? null,
    })),
    ports: portRows.map((p) => ({
      id: Number(p.id),
      title: p.title ?? null,
      platform: { id: Number(p.platformId), name: p.platformName ?? null },
      isOfficial: !!p.isOfficial,
      linkSystem: p.linkSystem ?? null,
    })),
    remakes: remakeRows.map((r) => ({
      id: Number(r.id),
      title: r.title,
      fileLink: r.fileLink,
      fileDate: r.fileDate ?? null,
      fileSize: r.fileSize != null ? Number(r.fileSize) : null,
      authors: r.authors ?? null,
      platforms: r.platforms ?? null,
      remakeYears: r.remakeYears ?? null,
      remakeStatus: r.remakeStatus ?? null,
    })),
    scores: scoreRows.map((s) => ({
      website: { id: Number(s.websiteId), name: s.websiteName ?? null },
      score: Number(s.score),
      votes: Number(s.votes),
    })),
    notes: noteRows.map((n) => ({
      id: Number(n.id),
      type: { id: n.notetypeId, name: n.notetypeName ?? null },
      text: n.text,
    })),
    origins: originRows.map((o) => ({
      type: { id: o.origintypeId, name: o.origintypeName ?? null },
      libraryTitle: o.libraryTitle,
      publication: o.publication ?? null,
      containerId: o.containerId != null ? Number(o.containerId) : null,
      issueId: o.issueId != null ? Number(o.issueId) : null,
      issue: o.issueId != null
        ? {
            id: Number(o.issueId),
            magazineId: o.magazineId != null ? Number(o.magazineId) : null,
            magazineTitle: o.magazineTitle ?? null,
          }
        : null,
      date: {
        year: o.dateYear != null ? Number(o.dateYear) : null,
        month: o.dateMonth != null ? Number(o.dateMonth) : null,
        day: o.dateDay != null ? Number(o.dateDay) : null,
      },
    })),
    maxPlayers: (base.maxPlayers) ?? undefined,
    availabletypeId: (base.availabletypeId) ?? undefined,
    withoutLoadScreen: (base.withoutLoadScreen) ?? undefined,
    withoutInlay: (base.withoutInlay) ?? undefined,
    issueId: (base.issueId) ?? undefined,
    files:
      fileRows.length > 0
        ? fileRows.map((f) => ({
            id: f.id,
            link: f.link,
            size: f.size ?? null,
            md5: f.md5 ?? null,
            comments: f.comments ?? null,
            type: { id: f.typeId, name: f.typeName },
          }))
        : [],
    releases: releasesData,
    downloadsFlat: downloadFlatRows.map((d) => ({
      id: Number(d.id),
      link: d.link,
      size: d.size != null ? Number(d.size) : null,
      md5: d.md5 ?? null,
      comments: d.comments ?? null,
      isDemo: !!d.isDemo,
      type: { id: Number(d.filetypeId), name: d.filetypeName },
      language: { id: (d.dlLangId) ?? null, name: (d.dlLangName) ?? null },
      machinetype: { id: d.dlMachineId != null ? Number(d.dlMachineId) : null, name: (d.dlMachineName) ?? null },
      scheme: { id: (d.schemeId) ?? null, name: (d.schemeName) ?? null },
      source: { id: (d.sourceId) ?? null, name: (d.sourceName) ?? null },
      case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
      year: d.year != null ? Number(d.year) : null,
      releaseSeq: Number(d.releaseSeq),
      localLink: resolveLocalLink(d.link),
    })),
    aliases: aliasRows.map((a) => ({ releaseSeq: Number(a.releaseSeq), languageId: a.languageId, title: a.title })),
    webrefs: webrefRows.map((w) => ({ link: w.link, languageId: w.languageId, website: { id: Number(w.websiteId), name: w.websiteName, link: w.websiteLink } })),
    magazineRefs: magazineRefRows.map((m) => ({
      id: m.id,
      issueId: Number(m.issueId),
      magazineId: m.magazineId != null ? Number(m.magazineId) : null,
      magazineName: m.magazineName ?? null,
      referencetypeId: Number(m.referencetypeId),
      referencetypeName: m.referencetypeName ?? null,
      page: Number(m.page),
      isOriginal: Number(m.isOriginal),
      scoreGroup: m.scoreGroup ?? "",
      issue: {
        dateYear: m.issueDateYear != null ? Number(m.issueDateYear) : null,
        dateMonth: m.issueDateMonth != null ? Number(m.issueDateMonth) : null,
        dateDay: m.issueDateDay != null ? Number(m.issueDateDay) : null,
        volume: m.issueVolume != null ? Number(m.issueVolume) : null,
        number: m.issueNumber != null ? Number(m.issueNumber) : null,
        special: m.issueSpecial ?? null,
        supplement: m.issueSupplement ?? null,
      },
    })),
  };
}

// ----- Labels -----

export interface LabelDetail extends LabelSummary {
  labeltypeName: string | null;
  countryId: string | null;
  countryName: string | null;
  country2Id: string | null;
  country2Name: string | null;
  linkWikipedia: string | null;
  linkSite: string | null;
  permissions: {
    website: { id: number; name: string; link?: string | null };
    type: { id: string; name: string | null };
    text: string | null;
  }[];
  licenses: {
    id: number;
    name: string;
    type: { id: string; name: string | null };
    linkWikipedia?: string | null;
    linkSite?: string | null;
    comments?: string | null;
  }[];
}

export interface LabelSearchParams {
  q?: string;
  page?: number;
  pageSize?: number;
}

export async function searchLabels(params: LabelSearchParams): Promise<PagedResult<LabelSummary>> {
  const q = (params.q ?? "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "");
  const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
  const page = Math.max(1, params.page ?? 1);
  const offset = (page - 1) * pageSize;

  if (!q) {
    const [items, countRows] = await Promise.all([
      db.select().from(labels).orderBy(labels.name).limit(pageSize).offset(offset),
      db
        .select({ total: sql<number>`count(*)` })
        .from(labels) as unknown as Promise<{ total: number }[]>,
    ]);
    const total = Number(countRows?.[0]?.total ?? 0);
    return { items: items, page, pageSize, total };
  }

  // Using helper search_by_names for efficiency via subselect to avoid raw identifier typing
  const pattern = `%${q}%`;
  const countRows = await db
    .select({ total: sql<number>`count(*)` })
    .from(labels)
    .where(sql`${labels.id} in (select distinct label_id from search_by_names where label_name like ${pattern})`);
  const total = Number(countRows[0]?.total ?? 0);

  const items = await db
    .select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
    .from(labels)
    .where(sql`${labels.id} in (select distinct label_id from search_by_names where label_name like ${pattern})`)
    .orderBy(labels.name)
    .limit(pageSize)
    .offset(offset);

  return { items: items, page, pageSize, total };
}

export async function getLabelById(id: number): Promise<LabelDetail | null> {
  const rows = await db
    .select({
      id: labels.id,
      name: labels.name,
      labeltypeId: labels.labeltypeId,
      labeltypeName: labeltypes.name,
      countryId: labels.countryId,
      countryName: sql<string>`c1.text`,
      country2Id: labels.country2Id,
      country2Name: sql<string>`c2.text`,
      linkWikipedia: labels.linkWikipedia,
      linkSite: labels.linkSite,
    })
    .from(labels)
    .leftJoin(labeltypes, eq(labeltypes.id, labels.labeltypeId))
    .leftJoin(sql`${countries} c1`, eq(sql`c1.id`, labels.countryId))
    .leftJoin(sql`${countries} c2`, eq(sql`c2.id`, labels.country2Id))
    .where(eq(labels.id, id))
    .limit(1);
  const base = rows[0];
  if (!base) return null;

  let permissionRows: {
    websiteId: number | string;
    websiteName: string;
    websiteLink: string | null;
    permissiontypeId: string;
    permissiontypeName: string | null;
    text: string | null;
  }[] = [];
  let licenseRows: {
    id: number | string;
    name: string;
    licensetypeId: string;
    licensetypeName: string | null;
    linkWikipedia: string | null;
    linkSite: string | null;
    comments: string | null;
  }[] = [];

  try {
    const rowsPerm = await db
      .select({
        websiteId: websites.id,
        websiteName: websites.name,
        websiteLink: websites.link,
        permissiontypeId: permissiontypes.id,
        permissiontypeName: permissiontypes.name,
        text: permissions.text,
      })
      .from(permissions)
      .innerJoin(websites, eq(websites.id, permissions.websiteId))
      .leftJoin(permissiontypes, eq(permissiontypes.id, permissions.permissiontypeId))
      .where(eq(permissions.labelId, id));
    permissionRows = rowsPerm as typeof permissionRows;
  } catch {}

  try {
    const rowsLic = await db
      .select({
        id: licenses.id,
        name: licenses.name,
        licensetypeId: licenses.licensetypeId,
        licensetypeName: licensetypes.name,
        linkWikipedia: licenses.linkWikipedia,
        linkSite: licenses.linkSite,
        comments: licenses.comments,
      })
      .from(licensors)
      .innerJoin(licenses, eq(licenses.id, licensors.licenseId))
      .leftJoin(licensetypes, eq(licensetypes.id, licenses.licensetypeId))
      .where(eq(licensors.labelId, id));
    licenseRows = rowsLic as typeof licenseRows;
  } catch {}

  return {
    id: base.id,
    name: base.name,
    labeltypeId: base.labeltypeId,
    labeltypeName: base.labeltypeName ?? null,
    countryId: base.countryId ?? null,
    countryName: base.countryName ?? null,
    country2Id: base.country2Id ?? null,
    country2Name: base.country2Name ?? null,
    linkWikipedia: base.linkWikipedia ?? null,
    linkSite: base.linkSite ?? null,
    permissions: permissionRows.map((p) => ({
      website: { id: Number(p.websiteId), name: p.websiteName, link: p.websiteLink ?? null },
      type: { id: p.permissiontypeId, name: p.permissiontypeName ?? null },
      text: p.text ?? null,
    })),
    licenses: licenseRows.map((l) => ({
      id: Number(l.id),
      name: l.name,
      type: { id: l.licensetypeId, name: l.licensetypeName ?? null },
      linkWikipedia: l.linkWikipedia ?? null,
      linkSite: l.linkSite ?? null,
      comments: l.comments ?? null,
    })),
  };
}

export interface LabelContribsParams {
  page?: number;
  pageSize?: number;
  q?: string; // optional title filter
}

export async function getLabelAuthoredEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
  const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
  const page = Math.max(1, params.page ?? 1);
  const offset = (page - 1) * pageSize;
  const hasQ = !!(params.q && params.q.trim());

  if (!hasQ) {
    const countRows = await db
      .select({ total: sql<number>`count(distinct ${authors.entryId})` })
      .from(authors)
      .where(eq(authors.labelId, labelId));
    const total = Number(countRows[0]?.total ?? 0);

    const items = await db
      .select({
        id: entries.id,
        title: entries.title,
        isXrated: entries.isXrated,
        genreId: entries.genretypeId,
        genreName: genretypes.name,
        machinetypeId: entries.machinetypeId,
        machinetypeName: machinetypes.name,
        languageId: entries.languageId,
        languageName: languages.name,
      })
      .from(authors)
      .innerJoin(entries, eq(entries.id, authors.entryId))
      .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
      .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
      .leftJoin(languages, eq(languages.id, entries.languageId))
      .where(eq(authors.labelId, labelId))
      .groupBy(entries.id)
      .orderBy(entries.title)
      .limit(pageSize)
      .offset(offset);

    return { items: items, page, pageSize, total };
  }

  const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
  const countRows = await db
    .select({ total: sql<number>`count(distinct ${entries.id})` })
    .from(authors)
    .innerJoin(entries, eq(entries.id, authors.entryId))
    .where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`));
  const total = Number((countRows)[0]?.total ?? 0);
  const items = await db
    .select({
      id: entries.id,
      title: entries.title,
      isXrated: entries.isXrated,
      genreId: entries.genretypeId,
      genreName: genretypes.name,
      machinetypeId: entries.machinetypeId,
      machinetypeName: machinetypes.name,
      languageId: entries.languageId,
      languageName: languages.name,
    })
    .from(authors)
    .innerJoin(entries, eq(entries.id, authors.entryId))
    .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
    .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
    .leftJoin(languages, eq(languages.id, entries.languageId))
    .where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`))
    .groupBy(entries.id)
    .orderBy(entries.title)
    .limit(pageSize)
    .offset(offset);

  return { items: items, page, pageSize, total };
}

export async function getLabelPublishedEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
  const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
  const page = Math.max(1, params.page ?? 1);
  const offset = (page - 1) * pageSize;
  const hasQ = !!(params.q && params.q.trim());

  if (!hasQ) {
    const countRows = await db
      .select({ total: sql<number>`count(distinct ${publishers.entryId})` })
      .from(publishers)
      .where(eq(publishers.labelId, labelId));
    const total = Number(countRows[0]?.total ?? 0);

    const items = await db
      .select({
        id: entries.id,
        title: entries.title,
        isXrated: entries.isXrated,
        genreId: entries.genretypeId,
        genreName: genretypes.name,
        machinetypeId: entries.machinetypeId,
        machinetypeName: machinetypes.name,
        languageId: entries.languageId,
        languageName: languages.name,
      })
      .from(publishers)
      .innerJoin(entries, eq(entries.id, publishers.entryId))
      .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
      .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
      .leftJoin(languages, eq(languages.id, entries.languageId))
      .where(eq(publishers.labelId, labelId))
      .groupBy(entries.id)
      .orderBy(entries.title)
      .limit(pageSize)
      .offset(offset);

    return { items: items, page, pageSize, total };
  }

  const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
  const countRows = await db
    .select({ total: sql<number>`count(distinct ${entries.id})` })
    .from(publishers)
    .innerJoin(entries, eq(entries.id, publishers.entryId))
    .where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`));
  const total = Number((countRows)[0]?.total ?? 0);
  const items = await db
    .select({
      id: entries.id,
      title: entries.title,
      isXrated: entries.isXrated,
      genreId: entries.genretypeId,
      genreName: genretypes.name,
      machinetypeId: entries.machinetypeId,
      machinetypeName: machinetypes.name,
      languageId: entries.languageId,
      languageName: languages.name,
    })
    .from(publishers)
    .innerJoin(entries, eq(entries.id, publishers.entryId))
    .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
    .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
    .leftJoin(languages, eq(languages.id, entries.languageId))
    .where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`))
    .groupBy(entries.id)
    .orderBy(entries.title)
    .limit(pageSize)
    .offset(offset);

  return { items: items, page, pageSize, total };
}

// ----- Lookups lists and category browsing -----

export const listGenres = cache(async () => db.select().from(genretypes).orderBy(genretypes.name));
export const listLanguages = cache(async () => db.select().from(languages).orderBy(languages.name));
export const listMachinetypes = cache(async () => db.select().from(machinetypes).orderBy(machinetypes.name));

// Note: ZXDB structure in this project does not include a `releasetypes` table.
// Do not attempt to query it here.

// Search with pagination for lookups
export interface SimpleSearchParams {
  q?: string;
  page?: number;
  pageSize?: number;
}

export async function searchLanguages(params: SimpleSearchParams) {
  const q = (params.q ?? "").trim();
  const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
  const page = Math.max(1, params.page ?? 1);
  const offset = (page - 1) * pageSize;

  if (!q) {
    const [items, countRows] = await Promise.all([
      db.select().from(languages).orderBy(languages.name).limit(pageSize).offset(offset),
      db.select({ total: sql<number>`count(*)` }).from(languages),
    ]);
    const total = Number(countRows?.[0]?.total ?? 0);
    return { items, page, pageSize, total };
  }

  const pattern = `%${q.toLowerCase()}%`;
  const [items, countRows] = await Promise.all([
    db
      .select()
      .from(languages)
      .where(sql`lower(${languages.name}) like ${pattern}`)
      .orderBy(languages.name)
      .limit(pageSize)
      .offset(offset),
    db.select({ total: sql<number>`count(*)` }).from(languages).where(sql`lower(${languages.name}) like ${pattern}`),
  ]);
  const total = Number(countRows?.[0]?.total ?? 0);
  return { items, page, pageSize, total };
}

export async function searchGenres(params: SimpleSearchParams) {
  const q = (params.q ?? "").trim();
  const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
  const page = Math.max(1, params.page ?? 1);
  const offset = (page - 1) * pageSize;

  if (!q) {
    const [items, countRows] = await Promise.all([
      db.select().from(genretypes).orderBy(genretypes.name).limit(pageSize).offset(offset),
      db.select({ total: sql<number>`count(*)` }).from(genretypes),
    ]);
    const total = Number(countRows?.[0]?.total ?? 0);
    return { items, page, pageSize, total };
  }

  const pattern = `%${q.toLowerCase()}%`;
  const [items, countRows] = await Promise.all([
    db
      .select()
      .from(genretypes)
      .where(sql`lower(${genretypes.name}) like ${pattern}`)
      .orderBy(genretypes.name)
      .limit(pageSize)
      .offset(offset),
    db.select({ total: sql<number>`count(*)` }).from(genretypes).where(sql`lower(${genretypes.name}) like ${pattern}`),
  ]);
  const total = Number(countRows?.[0]?.total ?? 0);
  return { items, page, pageSize, total };
}

export async function searchMachinetypes(params: SimpleSearchParams) {
  const q = (params.q ?? "").trim();
  const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
  const page = Math.max(1, params.page ?? 1);
  const offset = (page - 1) * pageSize;

  if (!q) {
    const [items, countRows] = await Promise.all([
      db.select().from(machinetypes).orderBy(machinetypes.name).limit(pageSize).offset(offset),
      db.select({ total: sql<number>`count(*)` }).from(machinetypes),
    ]);
    const total = Number(countRows?.[0]?.total ?? 0);
    return { items, page, pageSize, total };
  }

  const pattern = `%${q.toLowerCase()}%`;
  const [items, countRows] = await Promise.all([
    db
      .select()
      .from(machinetypes)
      .where(sql`lower(${machinetypes.name}) like ${pattern}`)
      .orderBy(machinetypes.name)
      .limit(pageSize)
      .offset(offset),
    db.select({ total: sql<number>`count(*)` }).from(machinetypes).where(sql`lower(${machinetypes.name}) like ${pattern}`),
  ]);
  const total = Number((countRows as { total: number }[])[0]?.total ?? 0);
  return { items, page, pageSize, total };
}

export async function entriesByGenre(
  genreId: number,
  page: number,
  pageSize: number,
  q?: string
): Promise<PagedResult<SearchResultItem>> {
  const offset = (page - 1) * pageSize;
  const hasQ = !!(q && q.trim());

  if (!hasQ) {
    const countRows = await db
      .select({ total: sql<number>`count(*)` })
      .from(entries)
      .where(eq(entries.genretypeId, genreId));
    const items = await db
      .select({
        id: entries.id,
        title: entries.title,
        isXrated: entries.isXrated,
        genreId: entries.genretypeId,
        genreName: genretypes.name,
        machinetypeId: entries.machinetypeId,
        machinetypeName: machinetypes.name,
        languageId: entries.languageId,
        languageName: languages.name,
      })
      .from(entries)
      .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
      .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
      .leftJoin(languages, eq(languages.id, entries.languageId))
      .where(eq(entries.genretypeId, genreId))
      .orderBy(entries.title)
      .limit(pageSize)
      .offset(offset);
    return { items, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
  }

  const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
  const countRows = await db
    .select({ total: sql<number>`count(distinct ${entries.id})` })
    .from(entries)
    .where(and(eq(entries.genretypeId, genreId), sql`${entries.id} in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`));
  const total = Number(countRows[0]?.total ?? 0);
  const items = await db
    .select({
      id: entries.id,
      title: entries.title,
      isXrated: entries.isXrated,
      genreId: entries.genretypeId,
      genreName: genretypes.name,
      machinetypeId: entries.machinetypeId,
      machinetypeName: machinetypes.name,
      languageId: entries.languageId,
      languageName: languages.name,
    })
    .from(entries)
    .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
    .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
    .leftJoin(languages, eq(languages.id, entries.languageId))
    .where(and(eq(entries.genretypeId, genreId), sql`${entries.id} in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`))
    .groupBy(entries.id)
    .orderBy(entries.title)
    .limit(pageSize)
    .offset(offset);
  return { items, page, pageSize, total };
}

export async function entriesByLanguage(
  langId: string,
  page: number,
  pageSize: number,
  q?: string
): Promise<PagedResult<SearchResultItem>> {
  const offset = (page - 1) * pageSize;
  const hasQ = !!(q && q.trim());

  if (!hasQ) {
    const countRows = await db
      .select({ total: sql<number>`count(*)` })
      .from(entries)
      .where(eq(entries.languageId, langId));
    const items = await db
      .select({
        id: entries.id,
        title: entries.title,
        isXrated: entries.isXrated,
        genreId: entries.genretypeId,
        genreName: genretypes.name,
        machinetypeId: entries.machinetypeId,
        machinetypeName: machinetypes.name,
        languageId: entries.languageId,
        languageName: languages.name,
      })
      .from(entries)
      .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
      .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
      .leftJoin(languages, eq(languages.id, entries.languageId))
      .where(eq(entries.languageId, langId))
      .orderBy(entries.title)
      .limit(pageSize)
      .offset(offset);
    return { items, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
  }

  const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
  const countRows = await db
    .select({ total: sql<number>`count(distinct ${entries.id})` })
    .from(entries)
    .where(and(eq(entries.languageId, langId), sql`${entries.id} in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`));
  const total = Number(countRows[0]?.total ?? 0);
  const items = await db
    .select({
      id: entries.id,
      title: entries.title,
      isXrated: entries.isXrated,
      genreId: entries.genretypeId,
      genreName: genretypes.name,
      machinetypeId: entries.machinetypeId,
      machinetypeName: machinetypes.name,
      languageId: entries.languageId,
      languageName: languages.name,
    })
    .from(entries)
    .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
    .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
    .leftJoin(languages, eq(languages.id, entries.languageId))
    .where(and(eq(entries.languageId, langId), sql`${entries.id} in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`))
    .groupBy(entries.id)
    .orderBy(entries.title)
    .limit(pageSize)
    .offset(offset);
  return { items, page, pageSize, total };
}

export async function entriesByMachinetype(
  mtId: number,
  page: number,
  pageSize: number,
  q?: string
): Promise<PagedResult<SearchResultItem>> {
  const offset = (page - 1) * pageSize;
  const hasQ = !!(q && q.trim());

  if (!hasQ) {
    const countRows = await db
      .select({ total: sql<number>`count(*)` })
      .from(entries)
      .where(eq(entries.machinetypeId, mtId));
    const items = await db
      .select({
        id: entries.id,
        title: entries.title,
        isXrated: entries.isXrated,
        genreId: entries.genretypeId,
        genreName: genretypes.name,
        machinetypeId: entries.machinetypeId,
        machinetypeName: machinetypes.name,
        languageId: entries.languageId,
        languageName: languages.name,
      })
      .from(entries)
      .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
      .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
      .leftJoin(languages, eq(languages.id, entries.languageId))
      .where(eq(entries.machinetypeId, mtId))
      .orderBy(entries.title)
      .limit(pageSize)
      .offset(offset);
    return { items, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
  }

  const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
  const countRows = await db
    .select({ total: sql<number>`count(distinct ${entries.id})` })
    .from(entries)
    .where(and(eq(entries.machinetypeId, mtId), sql`${entries.id} in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`));
  const total = Number(countRows[0]?.total ?? 0);
  const items = await db
    .select({
      id: entries.id,
      title: entries.title,
      isXrated: entries.isXrated,
      genreId: entries.genretypeId,
      genreName: genretypes.name,
      machinetypeId: entries.machinetypeId,
      machinetypeName: machinetypes.name,
      languageId: entries.languageId,
      languageName: languages.name,
    })
    .from(entries)
    .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
    .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
    .leftJoin(languages, eq(languages.id, entries.languageId))
    .where(and(eq(entries.machinetypeId, mtId), sql`${entries.id} in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`))
    .groupBy(entries.id)
    .orderBy(entries.title)
    .limit(pageSize)
    .offset(offset);
  return { items, page, pageSize, total };
}

// ----- Facets for search -----

export async function getEntryFacets(params: SearchParams): Promise<EntryFacets> {
  const q = (params.q ?? "").trim();
  const pattern = q ? `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%` : null;
  const scope: EntrySearchScope = params.scope ?? "title";

  // Build base WHERE SQL snippet considering q + filters
  const whereParts: Array<ReturnType<typeof sql>> = [];
  if (pattern) {
    if (scope !== "title") {
      try {
        const union = buildEntrySearchUnion(pattern, scope);
        whereParts.push(sql`e.id in (select entry_id from (${union}) as matches)`);
      } catch {
        whereParts.push(sql`e.id in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`);
      }
    } else {
      whereParts.push(sql`e.id in (select entry_id from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`);
    }
  }
  if (params.genreId) whereParts.push(sql`e.genretype_id = ${params.genreId}`);
  if (params.languageId) whereParts.push(sql`e.language_id = ${params.languageId}`);
  if (typeof params.machinetypeId === "number") {
    whereParts.push(sql`e.machinetype_id = ${params.machinetypeId}`);
  } else if (Array.isArray(params.machinetypeId) && params.machinetypeId.length > 0) {
    const ids = params.machinetypeId.map((id) => sql`${id}`);
    whereParts.push(sql`e.machinetype_id in (${sql.join(ids, sql`, `)})`);
  }

  const whereSql = whereParts.length ? sql.join([sql`where `, sql.join(whereParts, sql` and `)], sql``) : sql``;

  // Genres facet
  const genresRows = await db.execute(sql`
    select e.genretype_id as id, gt.text as name, count(*) as count
    from ${entries} as e
    left join ${genretypes} as gt on gt.id = e.genretype_id
    ${whereSql}
    group by e.genretype_id, gt.text
    order by count desc, name asc
  `);

  // Languages facet
  const langRows = await db.execute(sql`
    select e.language_id as id, l.text as name, count(*) as count
    from ${entries} as e
    left join ${languages} as l on l.id = e.language_id
    ${whereSql}
    group by e.language_id, l.text
    order by count desc, name asc
  `);

  // Machinetypes facet
  const mtRows = await db.execute(sql`
    select e.machinetype_id as id, m.text as name, count(*) as count
    from ${entries} as e
    left join ${machinetypes} as m on m.id = e.machinetype_id
    ${whereSql}
    group by e.machinetype_id, m.text
    order by count desc, name asc
  `);

  let hasAliases = 0;
  let hasOrigins = 0;
  try {
    const rows = await db.execute(sql`
      select
        sum(e.id in (select ${searchByAliases.entryId} from ${searchByAliases})) as hasAliases,
        sum(e.id in (select ${searchByOrigins.entryId} from ${searchByOrigins})) as hasOrigins
      from ${entries} as e
      ${whereSql}
    `);
    type FlagRow = { hasAliases: number | string | null; hasOrigins: number | string | null };
    const row = (rows as unknown as FlagRow[])[0];
    hasAliases = Number(row?.hasAliases ?? 0);
    hasOrigins = Number(row?.hasOrigins ?? 0);
  } catch {}

  type FacetRow = { id: number | string | null; name: string | null; count: number | string };
  return {
    genres: (genresRows as unknown as FacetRow[])
      .map((r) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) }))
      .filter((r) => !!r.id),
    languages: (langRows as unknown as FacetRow[])
      .map((r) => ({ id: String(r.id), name: r.name ?? "(none)", count: Number(r.count) }))
      .filter((r) => !!r.id),
    machinetypes: (mtRows as unknown as FacetRow[])
      .map((r) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) }))
      .filter((r) => !!r.id),
    flags: {
      hasAliases,
      hasOrigins,
    },
  };
}

// ----- Releases search (browser) -----

export interface ReleaseSearchParams {
  q?: string; // match entry title via helper search
  page?: number;
  pageSize?: number;
  year?: number;
  sort?: "year_desc" | "year_asc" | "title" | "entry_id_desc";
  // Optional download-based filters (matched via EXISTS on downloads)
  dLanguageId?: string; // downloads.language_id
  dMachinetypeId?: number | number[]; // downloads.machinetype_id
  filetypeId?: number; // downloads.filetype_id
  schemetypeId?: string; // downloads.schemetype_id
  sourcetypeId?: string; // downloads.sourcetype_id
  casetypeId?: string; // downloads.casetype_id
  isDemo?: boolean; // downloads.is_demo
}

export interface ReleaseListItem {
  entryId: number;
  releaseSeq: number;
  entryTitle: string;
  year: number | null;
  magrefCount: number;
}

export async function searchReleases(params: ReleaseSearchParams): Promise<PagedResult<ReleaseListItem>> {
  const q = (params.q ?? "").trim();
  const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
  const page = Math.max(1, params.page ?? 1);
  const offset = (page - 1) * pageSize;
  const hasMachineFilter = Array.isArray(params.dMachinetypeId)
    ? params.dMachinetypeId.length > 0
    : params.dMachinetypeId != null;
  const preferMachineOrder = hasMachineFilter
    ? null
    : sql`case
        when ${entries.machinetypeId} = 27 then 0
        when ${entries.machinetypeId} = 26 then 1
        when ${entries.machinetypeId} = 8 then 2
        when ${entries.machinetypeId} = 9 then 3
        else 4
      end`;

  // Build WHERE conditions in Drizzle QB
  const wherePartsQB: Array<ReturnType<typeof sql>> = [];
  if (q) {
    const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
    wherePartsQB.push(sql`${releases.entryId} in (select ${searchByTitles.entryId} from ${searchByTitles} where lower(${searchByTitles.entryTitle}) like ${pattern})`);
  }
  if (params.year != null) {
    wherePartsQB.push(eq(releases.releaseYear, params.year));
  }

  // Optional filters via downloads table: use EXISTS for performance and correctness
  // IMPORTANT: when hand-writing SQL with an aliased table, we must render
  // "from downloads as d" explicitly; using only the alias identifier ("d")
  // would produce "from `d`" which MySQL interprets as a literal table.
  const dlConds: Array<ReturnType<typeof sql>> = [];
  if (params.dLanguageId) dlConds.push(sql`d.language_id = ${params.dLanguageId}`);
  if (typeof params.dMachinetypeId === "number") {
    dlConds.push(sql`d.machinetype_id = ${params.dMachinetypeId}`);
  } else if (Array.isArray(params.dMachinetypeId) && params.dMachinetypeId.length > 0) {
    const ids = params.dMachinetypeId.map((id) => sql`${id}`);
    dlConds.push(sql`d.machinetype_id in (${sql.join(ids, sql`, `)})`);
  }
  if (params.filetypeId != null) dlConds.push(sql`d.filetype_id = ${params.filetypeId}`);
  if (params.schemetypeId) dlConds.push(sql`d.schemetype_id = ${params.schemetypeId}`);
  if (params.sourcetypeId) dlConds.push(sql`d.sourcetype_id = ${params.sourcetypeId}`);
  if (params.casetypeId) dlConds.push(sql`d.casetype_id = ${params.casetypeId}`);
  if (params.isDemo != null) dlConds.push(sql`d.is_demo = ${params.isDemo ? 1 : 0}`);

  if (dlConds.length) {
    const baseConds = [
      sql`d.entry_id = ${releases.entryId}`,
      sql`d.release_seq = ${releases.releaseSeq}`,
      ...dlConds,
    ];
    wherePartsQB.push(sql`exists (select 1 from ${downloads} as d where ${sql.join(baseConds, sql` and `)})`);
  }
  const whereExpr = wherePartsQB.length ? and(...wherePartsQB) : undefined;

  // Count total
  const countRows = await db
    .select({ total: sql<number>`count(*)` })
    .from(releases)
    .where(whereExpr ?? sql`true`);
  const total = Number(countRows?.[0]?.total ?? 0);

  // Rows via Drizzle QB to avoid tuple/field leakage
  let orderBy1;
  let orderBy2;
  let orderBy3;
  switch (params.sort) {
    case "year_asc":
      orderBy1 = asc(releases.releaseYear);
      orderBy2 = asc(releases.entryId);
      orderBy3 = asc(releases.releaseSeq);
      break;
    case "title":
      orderBy1 = asc(entries.title);
      orderBy2 = desc(releases.releaseYear);
      orderBy3 = asc(releases.releaseSeq);
      break;
    case "entry_id_desc":
      orderBy1 = desc(releases.entryId);
      orderBy2 = desc(releases.releaseSeq);
      break;
    case "year_desc":
    default:
      orderBy1 = desc(releases.releaseYear);
      orderBy2 = desc(releases.entryId);
      orderBy3 = desc(releases.releaseSeq);
      break;
  }

  const rowsQB = await db
    .select({
      entryId: releases.entryId,
      releaseSeq: releases.releaseSeq,
      entryTitle: entries.title,
      year: releases.releaseYear,
    })
    .from(releases)
    .leftJoin(entries, eq(entries.id, releases.entryId))
    .where(whereExpr ?? sql`true`)
    .orderBy(
      ...(preferMachineOrder ? [preferMachineOrder] : []),
      orderBy1!,
      ...(orderBy2 ? [orderBy2] : []),
      ...(orderBy3 ? [orderBy3] : [])
    )
    .limit(pageSize)
    .offset(offset);

  const entryIds = Array.from(new Set(rowsQB.map((r) => Number(r.entryId)).filter((id) => Number.isFinite(id))));
  const magrefCounts = new Map<number, number>();
  if (entryIds.length > 0) {
    try {
      const values = entryIds.map((id) => sql`${id}`);
      const rows = await db.execute(sql`
        select ${searchByMagrefs.entryId} as entryId, count(*) as count
        from ${searchByMagrefs}
        where ${searchByMagrefs.entryId} in (${sql.join(values, sql`, `)})
        group by ${searchByMagrefs.entryId}
      `);
      type CountRow = { entryId: number | string; count: number | string };
      for (const row of rows as unknown as CountRow[]) {
        magrefCounts.set(Number(row.entryId), Number(row.count));
      }
    } catch {
      // Helper table might be missing; default to 0 counts.
    }
  }

  // Ensure plain primitives
  const items: ReleaseListItem[] = rowsQB.map((r) => ({
    entryId: Number(r.entryId),
    releaseSeq: Number(r.releaseSeq),
    entryTitle: r.entryTitle ?? "",
    year: r.year != null ? Number(r.year) : null,
    magrefCount: magrefCounts.get(Number(r.entryId)) ?? 0,
  }));

  return { items, page, pageSize, total };
}

export interface ReleaseDetail {
  entry: {
    id: number;
    title: string;
    issueId: number | null;
  };
  entryReleases: Array<{
    releaseSeq: number;
    year: number | null;
  }>;
  release: {
    entryId: number;
    releaseSeq: number;
    year: number | null;
    month: number | null;
    day: number | null;
    currency: { id: string | null; name: string | null; symbol: string | null; prefix: number | null };
    prices: {
      release: number | null;
      budget: number | null;
      microdrive: number | null;
      disk: number | null;
      cartridge: number | null;
    };
    book: { isbn: string | null; pages: number | null };
  };
  downloads: Array<{
    id: number;
    link: string;
    size: number | null;
    md5: string | null;
    comments: string | null;
    isDemo: boolean;
    type: { id: number; name: string };
    language: { id: string | null; name: string | null };
    machinetype: { id: number | null; name: string | null };
    scheme: { id: string | null; name: string | null };
    source: { id: string | null; name: string | null };
    case: { id: string | null; name: string | null };
    year: number | null;
    localLink?: string | null;
  }>;
  scraps: Array<{
    id: number;
    link: string | null;
    size: number | null;
    comments: string | null;
    rationale: string;
    isDemo: boolean;
    type: { id: number; name: string };
    language: { id: string | null; name: string | null };
    machinetype: { id: number | null; name: string | null };
    scheme: { id: string | null; name: string | null };
    source: { id: string | null; name: string | null };
    case: { id: string | null; name: string | null };
    year: number | null;
    localLink?: string | null;
  }>;
  files: Array<{
    id: number;
    link: string;
    size: number | null;
    md5: string | null;
    comments: string | null;
    type: { id: number; name: string };
  }>;
  magazineRefs: Array<{
    id: number;
    issueId: number;
    magazineId: number | null;
    magazineName: string | null;
    referencetypeId: number;
    referencetypeName: string | null;
    page: number;
    isOriginal: number;
    scoreGroup: string;
    issue: {
      dateYear: number | null;
      dateMonth: number | null;
      dateDay: number | null;
      volume: number | null;
      number: number | null;
      special: string | null;
      supplement: string | null;
    };
  }>;
}

export async function getReleaseDetail(entryId: number, releaseSeq: number): Promise<ReleaseDetail | null> {
  const rows = await db
    .select({
      entryId: releases.entryId,
      releaseSeq: releases.releaseSeq,
      year: releases.releaseYear,
      month: releases.releaseMonth,
      day: releases.releaseDay,
      currencyId: releases.currencyId,
      currencyName: currencies.name,
      currencySymbol: currencies.symbol,
      currencyPrefix: currencies.prefix,
      releasePrice: releases.releasePrice,
      budgetPrice: releases.budgetPrice,
      microdrivePrice: releases.microdrivePrice,
      diskPrice: releases.diskPrice,
      cartridgePrice: releases.cartridgePrice,
      bookIsbn: releases.bookIsbn,
      bookPages: releases.bookPages,
      entryTitle: entries.title,
      issueId: entries.issueId,
    })
    .from(releases)
    .leftJoin(entries, eq(entries.id, releases.entryId))
    .leftJoin(currencies, eq(currencies.id, releases.currencyId))
    .where(and(eq(releases.entryId, entryId), eq(releases.releaseSeq, releaseSeq)))
    .limit(1);

  const base = rows[0];
  if (!base) return null;

  const entryReleaseRows = await db
    .select({
      releaseSeq: releases.releaseSeq,
      year: releases.releaseYear,
    })
    .from(releases)
    .where(eq(releases.entryId, entryId))
    .orderBy(asc(releases.releaseSeq));

  type DownloadRow = {
    id: number | string;
    link: string;
    size: number | string | null;
    md5: string | null;
    comments: string | null;
    isDemo: number | boolean | null;
    filetypeId: number | string;
    filetypeName: string;
    dlLangId: string | null;
    dlLangName: string | null;
    dlMachineId: number | string | null;
    dlMachineName: string | null;
    schemeId: string | null;
    schemeName: string | null;
    sourceId: string | null;
    sourceName: string | null;
    caseId: string | null;
    caseName: string | null;
    year: number | string | null;
  };
  type ScrapRow = DownloadRow & { rationale: string };

  const downloadRows = await db
    .select({
      id: downloads.id,
      link: downloads.fileLink,
      size: downloads.fileSize,
      md5: downloads.fileMd5,
      comments: downloads.comments,
      isDemo: downloads.isDemo,
      filetypeId: filetypes.id,
      filetypeName: filetypes.name,
      dlLangId: downloads.languageId,
      dlLangName: languages.name,
      dlMachineId: downloads.machinetypeId,
      dlMachineName: machinetypes.name,
      schemeId: schemetypes.id,
      schemeName: schemetypes.name,
      sourceId: sourcetypes.id,
      sourceName: sourcetypes.name,
      caseId: casetypes.id,
      caseName: casetypes.name,
      year: downloads.releaseYear,
    })
    .from(downloads)
    .innerJoin(filetypes, eq(filetypes.id, downloads.filetypeId))
    .leftJoin(languages, eq(languages.id, downloads.languageId))
    .leftJoin(machinetypes, eq(machinetypes.id, downloads.machinetypeId))
    .leftJoin(schemetypes, eq(schemetypes.id, downloads.schemetypeId))
    .leftJoin(sourcetypes, eq(sourcetypes.id, downloads.sourcetypeId))
    .leftJoin(casetypes, eq(casetypes.id, downloads.casetypeId))
    .where(and(eq(downloads.entryId, entryId), eq(downloads.releaseSeq, releaseSeq)));

  const scrapRows = await db
    .select({
      id: scraps.id,
      link: scraps.fileLink,
      size: scraps.fileSize,
      comments: scraps.comments,
      rationale: scraps.rationale,
      isDemo: scraps.isDemo,
      filetypeId: filetypes.id,
      filetypeName: filetypes.name,
      dlLangId: scraps.languageId,
      dlLangName: languages.name,
      dlMachineId: scraps.machinetypeId,
      dlMachineName: machinetypes.name,
      schemeId: schemetypes.id,
      schemeName: schemetypes.name,
      sourceId: sourcetypes.id,
      sourceName: sourcetypes.name,
      caseId: casetypes.id,
      caseName: casetypes.name,
      year: scraps.releaseYear,
    })
    .from(scraps)
    .innerJoin(filetypes, eq(filetypes.id, scraps.filetypeId))
    .leftJoin(languages, eq(languages.id, scraps.languageId))
    .leftJoin(machinetypes, eq(machinetypes.id, scraps.machinetypeId))
    .leftJoin(schemetypes, eq(schemetypes.id, scraps.schemetypeId))
    .leftJoin(sourcetypes, eq(sourcetypes.id, scraps.sourcetypeId))
    .leftJoin(casetypes, eq(casetypes.id, scraps.casetypeId))
    .where(and(eq(scraps.entryId, entryId), eq(scraps.releaseSeq, releaseSeq)));

  const fileRows = base.issueId != null ? await db
    .select({
      id: files.id,
      link: files.fileLink,
      size: files.fileSize,
      md5: files.fileMd5,
      comments: files.comments,
      typeId: filetypes.id,
      typeName: filetypes.name,
    })
    .from(files)
    .innerJoin(filetypes, eq(filetypes.id, files.filetypeId))
    .where(eq(files.issueId, base.issueId)) : [];

  const magazineRefs = await db
    .select({
      id: magrefs.id,
      issueId: magrefs.issueId,
      magazineId: magazines.id,
      magazineName: magazines.name,
      referencetypeId: magrefs.referencetypeId,
      referencetypeName: referencetypes.name,
      page: magrefs.page,
      isOriginal: magrefs.isOriginal,
      scoreGroup: magrefs.scoreGroup,
      issueDateYear: issues.dateYear,
      issueDateMonth: issues.dateMonth,
      issueDateDay: issues.dateDay,
      issueVolume: issues.volume,
      issueNumber: issues.number,
      issueSpecial: issues.special,
      issueSupplement: issues.supplement,
    })
    .from(searchByMagrefs)
    .innerJoin(magrefs, eq(magrefs.id, searchByMagrefs.magrefId))
    .leftJoin(issues, eq(issues.id, magrefs.issueId))
    .leftJoin(magazines, eq(magazines.id, issues.magazineId))
    .leftJoin(referencetypes, eq(referencetypes.id, magrefs.referencetypeId))
    .where(eq(searchByMagrefs.entryId, entryId))
    .orderBy(
      asc(magazines.name),
      asc(issues.dateYear),
      asc(issues.dateMonth),
      asc(issues.id),
      asc(magrefs.page),
      asc(magrefs.id),
    );

  return {
    entry: {
      id: Number(base.entryId),
      title: base.entryTitle ?? "",
      issueId: base.issueId ?? null,
    },
    entryReleases: entryReleaseRows.map((r) => ({
      releaseSeq: Number(r.releaseSeq),
      year: r.year != null ? Number(r.year) : null,
    })),
    release: {
      entryId: Number(base.entryId),
      releaseSeq: Number(base.releaseSeq),
      year: base.year != null ? Number(base.year) : null,
      month: base.month != null ? Number(base.month) : null,
      day: base.day != null ? Number(base.day) : null,
      currency: {
        id: base.currencyId ?? null,
        name: base.currencyName ?? null,
        symbol: base.currencySymbol ?? null,
        prefix: base.currencyPrefix != null ? Number(base.currencyPrefix) : null,
      },
      prices: {
        release: base.releasePrice != null ? Number(base.releasePrice) : null,
        budget: base.budgetPrice != null ? Number(base.budgetPrice) : null,
        microdrive: base.microdrivePrice != null ? Number(base.microdrivePrice) : null,
        disk: base.diskPrice != null ? Number(base.diskPrice) : null,
        cartridge: base.cartridgePrice != null ? Number(base.cartridgePrice) : null,
      },
      book: {
        isbn: base.bookIsbn ?? null,
        pages: base.bookPages != null ? Number(base.bookPages) : null,
      },
    },
    downloads: (downloadRows as DownloadRow[]).map((d) => ({
      id: Number(d.id),
      link: d.link,
      size: d.size != null ? Number(d.size) : null,
      md5: d.md5 ?? null,
      comments: d.comments ?? null,
      isDemo: !!d.isDemo,
      type: { id: Number(d.filetypeId), name: d.filetypeName },
      language: { id: d.dlLangId ?? null, name: d.dlLangName ?? null },
      machinetype: { id: d.dlMachineId != null ? Number(d.dlMachineId) : null, name: d.dlMachineName ?? null },
      scheme: { id: d.schemeId ?? null, name: d.schemeName ?? null },
      source: { id: d.sourceId ?? null, name: d.sourceName ?? null },
      case: { id: d.caseId ?? null, name: d.caseName ?? null },
      year: d.year != null ? Number(d.year) : null,
      localLink: resolveLocalLink(d.link),
    })),
    scraps: (scrapRows as ScrapRow[]).map((s) => ({
      id: Number(s.id),
      link: s.link ?? null,
      size: s.size != null ? Number(s.size) : null,
      comments: s.comments ?? null,
      rationale: s.rationale ?? "",
      isDemo: !!s.isDemo,
      type: { id: Number(s.filetypeId), name: s.filetypeName },
      language: { id: s.dlLangId ?? null, name: s.dlLangName ?? null },
      machinetype: { id: s.dlMachineId != null ? Number(s.dlMachineId) : null, name: s.dlMachineName ?? null },
      scheme: { id: s.schemeId ?? null, name: s.schemeName ?? null },
      source: { id: s.sourceId ?? null, name: s.sourceName ?? null },
      case: { id: s.caseId ?? null, name: s.caseName ?? null },
      year: s.year != null ? Number(s.year) : null,
      localLink: s.link ? resolveLocalLink(s.link) : null,
    })),
    files: fileRows.map((f) => ({
      id: f.id,
      link: f.link,
      size: f.size ?? null,
      md5: f.md5 ?? null,
      comments: f.comments ?? null,
      type: { id: f.typeId, name: f.typeName },
    })),
    magazineRefs: magazineRefs.map((m) => ({
      id: m.id,
      issueId: Number(m.issueId),
      magazineId: m.magazineId != null ? Number(m.magazineId) : null,
      magazineName: m.magazineName ?? null,
      referencetypeId: Number(m.referencetypeId),
      referencetypeName: m.referencetypeName ?? null,
      page: Number(m.page),
      isOriginal: Number(m.isOriginal),
      scoreGroup: m.scoreGroup ?? "",
      issue: {
        dateYear: m.issueDateYear != null ? Number(m.issueDateYear) : null,
        dateMonth: m.issueDateMonth != null ? Number(m.issueDateMonth) : null,
        dateDay: m.issueDateDay != null ? Number(m.issueDateDay) : null,
        volume: m.issueVolume != null ? Number(m.issueVolume) : null,
        number: m.issueNumber != null ? Number(m.issueNumber) : null,
        special: m.issueSpecial ?? null,
        supplement: m.issueSupplement ?? null,
      },
    })),
  };
}

// ----- Download/lookups simple lists -----
export const listFiletypes = cache(async () => db.select().from(filetypes).orderBy(filetypes.name));
export const listSchemetypes = cache(async () => db.select().from(schemetypes).orderBy(schemetypes.name));
export const listSourcetypes = cache(async () => db.select().from(sourcetypes).orderBy(sourcetypes.name));
export const listCasetypes = cache(async () => db.select().from(casetypes).orderBy(casetypes.name));

// Newly exposed lookups
export const listAvailabletypes = cache(async () => db.select().from(availabletypes).orderBy(availabletypes.name));

export const listCurrencies = cache(async () =>
  db
    .select({ id: currencies.id, name: currencies.name, symbol: currencies.symbol, prefix: currencies.prefix })
    .from(currencies)
    .orderBy(currencies.name)
);

export const listRoletypes = cache(async () => db.select().from(roletypes).orderBy(roletypes.name));

export async function listMagazines(params: { q?: string; page?: number; pageSize?: number }): Promise<PagedResult<MagazineListItem>> {
  const q = (params.q ?? "").trim();
  const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
  const page = Math.max(1, params.page ?? 1);
  const offset = (page - 1) * pageSize;

  const whereExpr = q ? sql`lower(${magazines.name}) like ${`%${q.toLowerCase()}%`}` : sql`true`;

  const [items, totalRows] = await Promise.all([
    db
      .select({
        id: magazines.id,
        // Expose as `title` to UI while DB column is `name`
        title: magazines.name,
        languageId: magazines.languageId,
        issueCount: sql<number>`count(${issues.id})`,
      })
      .from(magazines)
      .leftJoin(issues, eq(issues.magazineId, magazines.id))
      .where(whereExpr)
      .groupBy(magazines.id)
      .orderBy(asc(magazines.name))
      .limit(pageSize)
      .offset(offset),
    db
      .select({ cnt: sql<number>`count(*)` })
      .from(magazines)
      .where(whereExpr),
  ]);

  return {
    items,
    page,
    pageSize,
    total: totalRows[0]?.cnt ?? 0,
  };
}

export async function getMagazine(id: number): Promise<MagazineDetail | null> {
  const rows = await db
    .select({
      id: magazines.id,
      // Alias DB `name` as `title` for UI shape
      title: magazines.name,
      languageId: magazines.languageId,
      linkSite: magazines.linkSite,
      linkMask: magazines.linkMask,
      archiveMask: magazines.archiveMask,
    })
    .from(magazines)
    .where(eq(magazines.id, id));
  if (rows.length === 0) return null;
  const mag = rows[0];

  const iss = await db
    .select({
      id: issues.id,
      dateYear: issues.dateYear,
      dateMonth: issues.dateMonth,
      number: issues.number,
      volume: issues.volume,
      special: issues.special,
      supplement: issues.supplement,
      linkMask: issues.linkMask,
      archiveMask: issues.archiveMask,
    })
    .from(issues)
    .where(eq(issues.magazineId, id))
    .orderBy(
      asc(issues.dateYear),
      asc(issues.dateMonth),
      asc(issues.volume),
      asc(issues.number),
      asc(issues.id),
    );

  return { ...mag, issues: iss };
}

export async function getIssue(id: number): Promise<IssueDetail | null> {
  const rows = await db
    .select({
      id: issues.id,
      magazineId: issues.magazineId,
      magazineTitle: magazines.name,
      dateYear: issues.dateYear,
      dateMonth: issues.dateMonth,
      number: issues.number,
      volume: issues.volume,
      special: issues.special,
      supplement: issues.supplement,
      linkMask: issues.linkMask,
      archiveMask: issues.archiveMask,
    })
    .from(issues)
    .leftJoin(magazines, eq(magazines.id, issues.magazineId))
    .where(eq(issues.id, id));
  const base = rows[0];
  if (!base) return null;

  const refs = await db
    .select({
      id: magrefs.id,
      page: magrefs.page,
      typeId: magrefs.referencetypeId,
      typeName: referencetypes.name,
      entryId: magrefs.entryId,
      entryTitle: entries.title,
      labelId: magrefs.labelId,
      labelName: labels.name,
      isOriginal: magrefs.isOriginal,
      scoreGroup: magrefs.scoreGroup,
    })
    .from(magrefs)
    .leftJoin(referencetypes, eq(referencetypes.id, magrefs.referencetypeId))
    .leftJoin(entries, eq(entries.id, magrefs.entryId))
    .leftJoin(labels, eq(labels.id, magrefs.labelId))
    .where(eq(magrefs.issueId, id))
    .orderBy(asc(magrefs.page), asc(magrefs.id));

  return {
    id: base.id,
    magazine: { id: Number(base.magazineId), title: base.magazineTitle ?? "" },
    dateYear: base.dateYear,
    dateMonth: base.dateMonth,
    number: base.number,
    volume: base.volume,
    special: base.special,
    supplement: base.supplement,
    linkMask: base.linkMask,
    archiveMask: base.archiveMask,
    refs: refs.map((r) => ({
      id: r.id,
      page: r.page,
      typeId: Number(r.typeId),
      typeName: r.typeName ?? "",
      entryId: r.entryId ?? null,
      entryTitle: r.entryTitle ?? null,
      labelId: r.labelId ?? null,
      labelName: r.labelName ?? null,
      isOriginal: Number(r.isOriginal),
      scoreGroup: r.scoreGroup,
    })),
  };
}

// ----- Tape identification via software_hashes -----

export type TapeMatch = {
  downloadId: number;
  entryId: number;
  entryTitle: string;
  innerPath: string;
  md5: string;
  crc32: string;
  sizeBytes: number;
  machinetype: string | null;
  genre: string | null;
  releaseYear: number | null;
  authors: string[];
  downloadLink: string;
};

export async function lookupByMd5(md5: string): Promise<TapeMatch[]> {
  const rows = await db
    .select({
      downloadId: softwareHashes.downloadId,
      entryId: downloads.entryId,
      entryTitle: entries.title,
      innerPath: softwareHashes.innerPath,
      md5: softwareHashes.md5,
      crc32: softwareHashes.crc32,
      sizeBytes: softwareHashes.sizeBytes,
      machinetypeName: machinetypes.name,
      genreName: genretypes.name,
      releaseYear: releases.releaseYear,
      downloadLink: downloads.fileLink,
    })
    .from(softwareHashes)
    .innerJoin(downloads, eq(downloads.id, softwareHashes.downloadId))
    .innerJoin(entries, eq(entries.id, downloads.entryId))
    .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
    .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
    .leftJoin(
      releases,
      and(eq(releases.entryId, downloads.entryId), eq(releases.releaseSeq, downloads.releaseSeq))
    )
    .where(eq(softwareHashes.md5, md5.toLowerCase()));

  // Collect unique entry IDs to fetch authors
  const entryIds = [...new Set(rows.map((r) => Number(r.entryId)))];
  const authorMap = new Map<number, string[]>();
  if (entryIds.length > 0) {
    try {
      const authorRows = await db
        .select({ entryId: authors.entryId, name: labels.name })
        .from(authors)
        .innerJoin(labels, eq(labels.id, authors.labelId))
        .where(
          entryIds.length === 1
            ? eq(authors.entryId, entryIds[0])
            : sql`${authors.entryId} in (${sql.join(entryIds.map((id) => sql`${id}`), sql`, `)})`
        )
        .orderBy(asc(authors.authorSeq));
      for (const a of authorRows) {
        const eid = Number(a.entryId);
        const existing = authorMap.get(eid);
        if (existing) existing.push(a.name);
        else authorMap.set(eid, [a.name]);
      }
    } catch {}
  }

  return rows.map((r) => ({
    downloadId: Number(r.downloadId),
    entryId: Number(r.entryId),
    entryTitle: r.entryTitle ?? "",
    innerPath: r.innerPath,
    md5: r.md5,
    crc32: r.crc32,
    sizeBytes: Number(r.sizeBytes),
    machinetype: r.machinetypeName ?? null,
    genre: r.genreName ?? null,
    releaseYear: r.releaseYear != null ? Number(r.releaseYear) : null,
    authors: authorMap.get(Number(r.entryId)) ?? [],
    downloadLink: r.downloadLink,
  }));
}
