import { SystemSettings } from '@/types/settings';
import { FileSystem, AppPlatform, BaseDir, OsPlatform } from '@/types/system';
import {
  Book,
  BookConfig,
  BookContent,
  BookFormat,
  BookLookupIndex,
  BookNote,
  FIXED_LAYOUT_FORMATS,
  ImportBookOptions,
} from '@/types/book';
import {
  getDir,
  getLocalBookFilename,
  getCoverFilename,
  getConfigFilename,
  getBookNavFilename,
  INIT_BOOK_CONFIG,
  formatTitle,
  formatAuthors,
  getPrimaryLanguage,
  getMetadataHash,
} from '@/utils/book';
import type { BookNav } from '@/services/nav';
import { partialMD5, md5 } from '@/utils/md5';
import { getBaseFilename, getFilename } from '@/utils/path';
import { BookDoc, DocumentLoader } from '@/libs/document';
import { tryNativeParseEpub } from '@/utils/tauriEpubBridge';
import { tryNativeParseMobi } from '@/utils/tauriMobiBridge';
import { isPseStreamFileName, openPseStreamBook, parsePseStreamFileName } from './opds/pseStream';
import { DEFAULT_BOOK_SEARCH_CONFIG, DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS } from './constants';
import { isContentURI, isValidURL, makeSafeFilename } from '@/utils/misc';
import { deserializeConfig, serializeConfig, serializeRawConfig } from '@/utils/serializer';
import { ClosableFile } from '@/utils/file';
import { TxtToEpubConverter } from '@/utils/txt';
import { svg2png } from '@/utils/svg';
import { normalizeMetadataIsbn } from '@/utils/isbn';
import { BookFileNotFoundError } from './errors';
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import {
  isBookFileContentSource,
  resolveBookContentSource,
  type BookFileContentSource,
} from './bookContent';

export function buildBookLookupIndex(books: Book[], osPlatform?: OsPlatform): BookLookupIndex {
  const byHash = new Map<string, Book>();
  const byMetaKey = new Map<string, Book[]>();
  const byFilePath = new Map<string, Book>();
  for (const book of books) {
    byHash.set(book.hash, book);
    if (book.metaHash && !book.deletedAt) {
      const key = `${book.metaHash}:${book.format}`;
      const list = byMetaKey.get(key);
      if (list) list.push(book);
      else byMetaKey.set(key, [book]);
    }
    // In-place books carry the absolute source path on `filePath` (set by
    // importBook below). Indexing them here lets a re-import of the exact
    // same file short-circuit before touching disk or computing partialMD5.
    // Skip URL-backed entries (remote books) and tombstoned ones.
    if (book.filePath && !isValidURL(book.filePath) && !book.deletedAt) {
      const key = normalizeFilePathForIndex(book.filePath, osPlatform);
      if (key) byFilePath.set(key, book);
    }
  }
  return { byHash, byMetaKey, byFilePath };
}

/**
 * Normalize an absolute file path into a stable map key for `byFilePath`.
 *
 * Mirrors the same rules `ingestService.shouldImportInPlace` uses to compare
 * paths against the user's in-place roots so both sides agree on whether a
 * given source file matches a previously-indexed book:
 *   - Backslashes are normalized to `/`.
 *   - Trailing slashes are stripped.
 *   - On case-insensitive filesystems (macOS / iOS / Windows) the key is
 *     lowercased. Linux / Android keep the original casing.
 *
 * Returns an empty string for non-string / falsy input so callers can do a
 * `if (key) map.set(key, …)` guard without an extra null check.
 */
export function normalizeFilePathForIndex(path: string, osPlatform?: OsPlatform): string {
  if (!path) return '';
  const caseInsensitive =
    osPlatform === 'macos' || osPlatform === 'ios' || osPlatform === 'windows';
  const n = path.replace(/\\/g, '/').replace(/\/+$/, '');
  return caseInsensitive ? n.toLowerCase() : n;
}

export interface ScannedFileEntry {
  /** Absolute path, already joined with the folder root. */
  fullPath: string;
  /** File size in bytes. */
  size: number;
}

/**
 * From a folder scan, keep only entries that (a) match one of `extensions`
 * (lowercased, no leading dot), (b) are at least `minSizeBytes`, and (c) are
 * NOT already in the library. Membership is tested against `existingPaths`,
 * which the caller builds from `buildBookLookupIndex(...).byFilePath.keys()`
 * (those keys are already normalized by `normalizeFilePathForIndex`, so we
 * normalize each scanned path the same way before comparing). Pure — no I/O.
 */
export function selectNewImportableFiles(
  entries: ScannedFileEntry[],
  opts: {
    extensions: string[];
    minSizeBytes: number;
    existingPaths: Set<string>;
    osPlatform?: OsPlatform;
  },
): ScannedFileEntry[] {
  const exts = new Set(opts.extensions.map((e) => e.toLowerCase()));
  return entries.filter((entry) => {
    const ext = entry.fullPath.split('.').pop()?.toLowerCase() ?? '';
    if (!exts.has(ext)) return false;
    if (opts.minSizeBytes > 0 && entry.size < opts.minSizeBytes) return false;
    const key = normalizeFilePathForIndex(entry.fullPath, opts.osPlatform);
    return !!key && !opts.existingPaths.has(key);
  });
}

/**
 * Collect all known local source paths from the library into a normalized set.
 *
 * Unlike `buildBookLookupIndex(...).byFilePath`, this includes soft-deleted
 * books (`deletedAt` set) so that auto-import does not resurrect a book the
 * user intentionally removed from their library.
 *
 * URL-backed entries (remote books) are excluded — only on-disk paths matter.
 */
export function collectKnownSourcePaths(books: Book[], osPlatform?: OsPlatform): Set<string> {
  const paths = new Set<string>();
  for (const book of books) {
    if (book.filePath && !isValidURL(book.filePath)) {
      const key = normalizeFilePathForIndex(book.filePath, osPlatform);
      if (key) paths.add(key);
    }
  }
  return paths;
}

export interface CoverContext {
  fs: FileSystem;
  appPlatform: AppPlatform;
  localBooksDir: string;
}

export function getCoverImageUrl(ctx: CoverContext, book: Book): string {
  return ctx.fs.getURL(`${ctx.localBooksDir}/${getCoverFilename(book)}`);
}

export async function getCoverImageBlobUrl(ctx: CoverContext, book: Book): Promise<string> {
  return ctx.fs.getBlobURL(`${ctx.localBooksDir}/${getCoverFilename(book)}`, 'None');
}

export async function getCachedImageUrl(ctx: CoverContext, pathOrUrl: string): Promise<string> {
  const cachedKey = `img_${md5(pathOrUrl)}`;
  const cachePrefix = await ctx.fs.getPrefix('Cache');
  const cachedPath = `${cachePrefix}/${cachedKey}`;
  if (await ctx.fs.exists(cachedPath, 'None')) {
    return await ctx.fs.getImageURL(cachedPath);
  } else {
    const file = await ctx.fs.openFile(pathOrUrl, 'None');
    await ctx.fs.writeFile(cachedKey, 'Cache', await file.arrayBuffer());
    return await ctx.fs.getImageURL(cachedPath);
  }
}

export async function generateCoverImageUrl(ctx: CoverContext, book: Book): Promise<string> {
  return ctx.appPlatform === 'web'
    ? await getCoverImageBlobUrl(ctx, book)
    : getCoverImageUrl(ctx, book);
}

function imageToArrayBuffer(
  ctx: CoverContext,
  imageUrl?: string,
  imageFile?: string,
): Promise<ArrayBuffer> {
  return new Promise((resolve, reject) => {
    if (!imageUrl && !imageFile) {
      reject(new Error('No image URL or file provided'));
      return;
    }
    if (ctx.appPlatform === 'web' && imageUrl && imageUrl.startsWith('blob:')) {
      fetch(imageUrl)
        .then((response) => response.arrayBuffer())
        .then(resolve)
        .catch(reject);
    } else if (ctx.appPlatform === 'tauri' && imageFile) {
      ctx.fs
        .openFile(imageFile, 'None')
        .then((file) => file.arrayBuffer())
        .then(resolve)
        .catch(reject);
    } else if (ctx.appPlatform === 'tauri' && imageUrl) {
      tauriFetch(imageUrl, { method: 'GET' })
        .then((response) => response.arrayBuffer())
        .then(resolve)
        .catch(reject);
    } else {
      reject(new Error('Unsupported platform or missing image data'));
    }
  });
}

export async function updateCoverImage(
  ctx: CoverContext,
  book: Book,
  imageUrl?: string,
  imageFile?: string,
): Promise<void> {
  if (imageUrl === '_blank') {
    await ctx.fs.removeFile(getCoverFilename(book), 'Books');
  } else if (imageUrl || imageFile) {
    const arrayBuffer = await imageToArrayBuffer(ctx, imageUrl, imageFile);
    await ctx.fs.writeFile(getCoverFilename(book), 'Books', arrayBuffer);
  }
}

/**
 * Partial MD5 of the local cover.png, or null when the cover is absent. This is
 * the content-addressed cover-change signal for cross-device sync (issue
 * #4544): keeping `book.coverHash === computeCoverHash(book)` lets a peer
 * re-download the cover iff the synced hash differs from the local one. An
 * identical (re-extracted / re-imported) cover yields the same hash, so there
 * is no re-sync churn.
 */
export async function computeCoverHash(fs: FileSystem, book: Book): Promise<string | null> {
  if (!(await fs.exists(getCoverFilename(book), 'Books'))) return null;
  const coverFile = await fs.openFile(getCoverFilename(book), 'Books');
  return partialMD5(coverFile);
}

// --- Book Merge ---

/**
 * Merge duplicate book entries that share the same metaHash and format as `book`.
 * Finds all other matching books in the array, selects the base config with the
 * largest reading progress page number, merges booknotes from all configs
 * (deduplicating by id, latest updatedAt wins), soft-deletes duplicates
 * (sets deletedAt), and cleans up their directories.
 *
 * @returns The merged config as a JSON string, or undefined if no duplicates were found.
 */
export async function mergeBooks(
  fs: FileSystem,
  books: Book[],
  book: Book,
  lookupIndex?: BookLookupIndex,
): Promise<string | undefined> {
  if (!book.metaHash) return undefined;

  const metaKey = `${book.metaHash}:${book.format}`;
  const duplicates = lookupIndex
    ? (lookupIndex.byMetaKey.get(metaKey) ?? []).filter((b) => !b.deletedAt && b !== book)
    : books.filter(
        (b) =>
          b.metaHash === book.metaHash && b.format === book.format && !b.deletedAt && b !== book,
      );
  if (duplicates.length === 0) return undefined;

  const allCandidates = [book, ...duplicates];
  const configs: Partial<BookConfig>[] = [];
  for (const candidate of allCandidates) {
    const configPath = getConfigFilename(candidate);
    if (await fs.exists(configPath, 'Books')) {
      try {
        const str = (await fs.readFile(configPath, 'Books', 'text')) as string;
        configs.push(JSON.parse(str));
      } catch {
        /* ignore corrupt configs */
      }
    }
  }

  let mergedConfigData: string | undefined;
  if (configs.length > 0) {
    const base = configs.reduce((best, cfg) => {
      const bestPage = best.progress?.[0] ?? 0;
      const cfgPage = cfg.progress?.[0] ?? 0;
      return cfgPage > bestPage ? cfg : best;
    });

    const noteMap = new Map<string, BookNote>();
    for (const cfg of configs) {
      for (const note of cfg.booknotes ?? []) {
        const existing = noteMap.get(note.id);
        if (!existing || (note.updatedAt || 0) > (existing.updatedAt || 0)) {
          noteMap.set(note.id, note);
        }
      }
    }
    base.booknotes = [...noteMap.values()];

    mergedConfigData = serializeRawConfig(base);
  }

  for (const dup of duplicates) {
    dup.deletedAt = Date.now();
    const dupDir = getDir(dup);
    if (await fs.exists(dupDir, 'Books')) {
      await fs.removeDir(dupDir, 'Books', true);
    }
  }

  return mergedConfigData;
}

// --- Book Import ---

/**
 * Options consumed by bookService.importBook. Extends the user-facing
 * ImportBookOptions with the required AppService callbacks that are bound by
 * the AppService wrapper.
 */
export interface ImportBookInternalOptions extends ImportBookOptions {
  saveBookConfig: (book: Book, config: BookConfig) => Promise<void>;
  generateCoverImageUrl: (book: Book) => Promise<string>;
  /**
   * Used by the in-place fast path to normalize the source path the same way
   * `buildBookLookupIndex` does. Optional: when omitted the fast path falls
   * back to a case-sensitive comparison, which is still safe (it will simply
   * miss matches that differ only in casing on macOS / iOS / Windows and the
   * import will proceed down the slow path as before).
   */
  osPlatform?: OsPlatform;
}

export async function importBook(
  fs: FileSystem,
  // file might be:
  // 1.1 absolute path for local file on Desktop
  // 1.2 /private/var inbox file path on iOS
  // 2. remote url
  // 3. content provider uri
  // 4. File object from browsers
  file: string | File,
  books: Book[],
  options: ImportBookInternalOptions,
): Promise<Book | null> {
  const {
    saveBookConfig: saveBookConfigFn,
    generateCoverImageUrl: generateCoverImageUrlFn,
    saveBook = true,
    saveCover = true,
    overwrite = false,
    transient = false,
    inPlace = false,
    lookupIndex,
    osPlatform,
  } = options;
  const isPseStream = typeof file === 'string' && isPseStreamFileName(file);

  try {
    let loadedBook: BookDoc;
    let format: BookFormat;
    let filename: string;
    let fileobj: File | undefined;
    // When the Rust EPUB parser succeeds it gives us the partialMD5 for free,
    // so we can short-circuit the JS hashing pass below.
    let nativeHash: string | undefined;
    let usedNativeParser = false;

    if (transient && typeof file !== 'string') {
      throw new Error('Transient import is only supported for file paths');
    }

    try {
      if (isPseStream) {
        const data = parsePseStreamFileName(file as string);
        ({ book: loadedBook, format } = await openPseStreamBook(data));
        filename = file as string;
      } else {
        if (typeof file === 'string') {
          fileobj = await fs.openFile(file, 'None');
          filename = fileobj.name || getFilename(file);
        } else {
          fileobj = file;
          filename = file.name;
        }
        if (/\.txt$/i.test(filename)) {
          const txt2epub = new TxtToEpubConverter();
          ({ file: fileobj } = await txt2epub.convert({ file: fileobj }));
        }
        if (!fileobj || fileobj.size === 0) {
          throw new Error('Invalid or empty book file');
        }
        // Q1 fast path: when running under Tauri with a real file
        // path, let Rust contribute the mechanical parts of the
        // import work — partialMD5 over the file, the downscaled
        // cover, and (for EPUB) the raw OPF bytes. Metadata
        // extraction itself runs through foliate-js so the import
        // path produces the same `Book.metadata` shape the reader
        // path does (`refines` chains / ONIX5 / language maps / EPUB
        // `belongs-to-collection` for EPUB; PalmDB UID identifier
        // for MOBI), without any `DocumentLoader.open()` overhead —
        // the importer never reads sections / toc / fixed-layout
        // detection, so spending CPU on a zip central-directory
        // scan, nav/ncx inflate, or PDB record-table walk would be
        // pure waste here.
        //
        // Both bridges are no-ops on web / non-eligible paths, so
        // the cost when neither matches is just two cheap regex
        // tests.
        let nativeBookDoc: BookDoc | undefined;
        let nativeFormat: BookFormat | undefined;
        if (typeof file === 'string' && !/\.txt$/i.test(filename)) {
          const nativeEpub = await tryNativeParseEpub(file);
          if (nativeEpub) {
            nativeBookDoc = nativeEpub.bookDoc;
            nativeFormat = 'EPUB' as BookFormat;
            nativeHash = nativeEpub.partialMd5;
          } else {
            const nativeMobi = await tryNativeParseMobi(file, fileobj);
            if (nativeMobi) {
              nativeBookDoc = nativeMobi.bookDoc;
              nativeFormat = nativeMobi.format;
              nativeHash = nativeMobi.partialMd5;
            }
          }
        }
        if (nativeBookDoc && nativeFormat) {
          loadedBook = nativeBookDoc;
          format = nativeFormat;
          usedNativeParser = true;
        } else {
          ({ book: loadedBook, format } = await new DocumentLoader(fileobj).open());
        }
      }
      if (!loadedBook) {
        throw new Error('Unsupported or corrupted book file');
      }
      normalizeMetadataIsbn(loadedBook.metadata);
      const metadataTitle = formatTitle(loadedBook.metadata.title);
      if (!metadataTitle || !metadataTitle.trim() || metadataTitle === filename) {
        loadedBook.metadata.title = getBaseFilename(filename);
      }
    } catch (error) {
      throw new Error(`Failed to open the book file: ${(error as Error).message || error}`);
    }

    const hash = isPseStream
      ? md5(file as string)
      : usedNativeParser
        ? nativeHash!
        : await partialMD5(fileobj!);

    const metaHash = getMetadataHash(loadedBook.metadata);
    let existingBook = lookupIndex
      ? lookupIndex.byHash.get(hash)
      : books.find((b) => b.hash === hash);
    let metaHashMatch = false;
    let oldBookDir: string | undefined;
    if (existingBook) {
      if (!transient) {
        existingBook.deletedAt = null;
      }
      existingBook.createdAt = Date.now();
      existingBook.updatedAt = Date.now();
    }

    // Aggregate all books with same metaHash and format, deduplicating into one entry
    let bestConfigData: string | undefined;
    if (!transient && metaHash) {
      if (!existingBook) {
        const metaKey = `${metaHash}:${format}`;
        const firstMatch = lookupIndex
          ? (lookupIndex.byMetaKey.get(metaKey) ?? []).find((b) => !b.deletedAt)
          : books.find((b) => b.metaHash === metaHash && b.format === format && !b.deletedAt);
        if (firstMatch) {
          oldBookDir = getDir(firstMatch);
          existingBook = firstMatch;
          metaHashMatch = true;
          existingBook.createdAt = Date.now();
          existingBook.updatedAt = Date.now();
        }
      }
      if (existingBook) {
        bestConfigData = await mergeBooks(fs, books, existingBook, lookupIndex);
      }
    }

    const primaryLanguage = getPrimaryLanguage(loadedBook.metadata.language);
    const book: Book = {
      hash,
      format,
      metaHash,
      title: formatTitle(loadedBook.metadata.title),
      sourceTitle: formatTitle(loadedBook.metadata.title),
      primaryLanguage,
      author: formatAuthors(loadedBook.metadata.author, primaryLanguage),
      metadata: loadedBook.metadata,
      createdAt: existingBook ? existingBook.createdAt : Date.now(),
      uploadedAt: existingBook ? existingBook.uploadedAt : null,
      deletedAt: transient ? Date.now() : null,
      downloadedAt: Date.now(),
      updatedAt: Date.now(),
    };
    // update series info from metadata
    if (book.metadata?.belongsTo?.series) {
      const belongsTo = book.metadata.belongsTo.series;
      const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo;
      if (series) {
        book.metadata.series = formatTitle(series.name);
        book.metadata.seriesIndex = parseFloat(series.position || '0');
        if (series.total) book.metadata.seriesTotal = parseInt(series.total, 10);
      }
    }
    // update book metadata when reimporting the same book
    if (existingBook && metaHashMatch) {
      // MetaHash match (different file, same book): override metadata and hash
      existingBook.hash = hash;
      existingBook.format = book.format;
      existingBook.metaHash = metaHash;
      existingBook.title = book.title;
      existingBook.sourceTitle = book.sourceTitle;
      existingBook.author = book.author;
      existingBook.primaryLanguage = book.primaryLanguage;
      existingBook.metadata = book.metadata;
      existingBook.uploadedAt = null;
      existingBook.downloadedAt = Date.now();
    } else if (existingBook) {
      // Same file hash: preserve user edits
      existingBook.format = book.format;
      existingBook.metaHash = metaHash;
      existingBook.title = existingBook.title.trim() ? existingBook.title.trim() : book.title;
      existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle;
      existingBook.author = existingBook.author ?? book.author;
      existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage;
      existingBook.metadata = book.metadata;
      existingBook.downloadedAt = Date.now();
    }

    // Idempotent create (recursive): a plain createDir defaults to
    // non-recursive and throws if the dir already exists, and the check-then-
    // create above races two concurrent imports of the same book — on Windows
    // the loser fails with "Cannot create a file when that file already exists"
    // (Sentry READEST-H). create_dir_all is a no-op when the dir exists.
    await fs.createDir(getDir(book), 'Books', true);
    const bookFilename = getLocalBookFilename(book);
    const willWriteBookFile =
      saveBook &&
      !transient &&
      !inPlace &&
      !!fileobj &&
      (!(await fs.exists(bookFilename, 'Books')) || overwrite);
    if (willWriteBookFile && fileobj) {
      if (/\.txt$/i.test(filename)) {
        await fs.writeFile(bookFilename, 'Books', fileobj);
      } else if (typeof file === 'string' && isContentURI(file)) {
        await fs.copyFile(file, 'None', bookFilename, 'Books');
      } else if (typeof file === 'string' && !isValidURL(file)) {
        try {
          // try to copy the file directly first in case of large files to avoid memory issues
          // on desktop when reading recursively from selected directory the direct copy will fail
          // due to permission issues, then fallback to read and write files
          await fs.copyFile(file, 'None', bookFilename, 'Books');
        } catch {
          await fs.writeFile(bookFilename, 'Books', await fileobj.arrayBuffer());
        }
      } else {
        await fs.writeFile(bookFilename, 'Books', fileobj);
      }
    }
    if (saveCover && (!(await fs.exists(getCoverFilename(book), 'Books')) || overwrite)) {
      let cover = await loadedBook.getCover();
      if (cover?.type === 'image/svg+xml') {
        try {
          console.log('Converting SVG cover to PNG...');
          cover = await svg2png(cover);
        } catch {}
      }
      if (cover) {
        const coverBytes = await cover.arrayBuffer();
        await fs.writeFile(getCoverFilename(book), 'Books', coverBytes);
      }
    }
    // Maintain coverHash === partialMD5(cover.png) so cross-device cover sync
    // can detect changes (issue #4544). Read from disk regardless of whether we
    // just wrote it — a hash-match reimport may reuse an existing cover.
    const coverHash = await computeCoverHash(fs, book);
    book.coverHash = coverHash;
    if (existingBook) existingBook.coverHash = coverHash;
    // Never overwrite the config file only when it's not existed
    if (!existingBook) {
      await saveBookConfigFn(book, INIT_BOOK_CONFIG);
      books.push(book);
      if (lookupIndex) {
        lookupIndex.byHash.set(book.hash, book);
        if (book.metaHash) {
          const key = `${book.metaHash}:${book.format}`;
          const list = lookupIndex.byMetaKey.get(key);
          if (list) list.push(book);
          else lookupIndex.byMetaKey.set(key, [book]);
        }
      }
    } else if (metaHashMatch && oldBookDir && oldBookDir !== getDir(book)) {
      // Migrate config from old directory to new directory, updating bookHash and metaHash
      // Use aggregated best config when available from deduplication
      if (bestConfigData) {
        const config: Partial<BookConfig> = JSON.parse(bestConfigData);
        config.bookHash = hash;
        config.metaHash = metaHash;
        await fs.writeFile(getConfigFilename(book), 'Books', serializeRawConfig(config));
      } else {
        const oldConfigPath = `${oldBookDir}/config.json`;
        if (await fs.exists(oldConfigPath, 'Books')) {
          const configData = (await fs.readFile(oldConfigPath, 'Books', 'text')) as string;
          const config: Partial<BookConfig> = JSON.parse(configData);
          config.bookHash = hash;
          config.metaHash = metaHash;
          await fs.writeFile(getConfigFilename(book), 'Books', serializeRawConfig(config));
        } else {
          await saveBookConfigFn(book, INIT_BOOK_CONFIG);
        }
      }
      // Clean up old directory
      if (await fs.exists(oldBookDir, 'Books')) {
        await fs.removeDir(oldBookDir, 'Books', true);
      }
    } else if (bestConfigData) {
      // Exact hash match with duplicates removed — adopt the best config
      const config: Partial<BookConfig> = JSON.parse(bestConfigData);
      config.bookHash = hash;
      config.metaHash = metaHash;
      await fs.writeFile(getConfigFilename(book), 'Books', serializeRawConfig(config));
    }

    // update file links with url or path or content uri
    if (isPseStream) {
      book.url = file as string;
      if (existingBook) existingBook.url = file as string;
    } else if (typeof file === 'string') {
      if (isValidURL(file)) {
        book.url = file;
        if (existingBook) existingBook.url = file;
      } else if (transient || inPlace) {
        // transient: source file is loaded directly, never persisted in Books/.
        // inPlace: source file is inside the user's library root and we read it
        // there directly instead of duplicating it under Books/<hash>/.
        book.filePath = file;
        if (existingBook) existingBook.filePath = file;
      }
    }
    // Now that `filePath` is set, keep the path index in sync so later files
    // in the same batch (and the next call into importBook) can hit the
    // in-place fast path. Only persistent in-place imports go into the
    // index — transient previews are short-lived and never persisted to
    // the library so indexing them would just leak references.
    if (lookupIndex && inPlace && !transient && typeof file === 'string') {
      const indexedBook = existingBook || book;
      if (indexedBook.filePath) {
        const key = normalizeFilePathForIndex(indexedBook.filePath, osPlatform);
        if (key) lookupIndex.byFilePath.set(key, indexedBook);
      }
    }
    book.coverImageUrl = await generateCoverImageUrlFn(book);
    const f = file as ClosableFile;
    if (f && f.close) {
      await f.close();
    }

    return existingBook || book;
  } catch (error) {
    console.error('Error importing book:', error);
    throw error;
  }
}

// --- Book Content & Config ---

export async function isBookAvailable(fs: FileSystem, book: Book): Promise<boolean> {
  return (await resolveBookContentSource(fs, book)).kind !== 'missing';
}

export async function getBookFileSize(fs: FileSystem, book: Book): Promise<number | null> {
  const source = await resolveBookContentSource(fs, book);
  if (source.kind !== 'managed' && source.kind !== 'external') {
    return null;
  }
  const file = await fs.openFile(source.path, source.base);
  const size = file.size;
  const f = file as ClosableFile;
  if (f && f.close) {
    await f.close();
  }
  return size;
}

async function openBookFileContent(
  fs: FileSystem,
  book: Book,
): Promise<{
  source: BookFileContentSource;
  file: File;
}> {
  const source = await resolveBookContentSource(fs, book);
  if (!isBookFileContentSource(source)) {
    throw new BookFileNotFoundError();
  }
  return { source, file: await fs.openFile(source.path, source.base) };
}

export async function loadBookContent(fs: FileSystem, book: Book): Promise<BookContent> {
  const { file } = await openBookFileContent(fs, book);
  return { book, file };
}

/**
 * Best-effort resolution of an absolute, on-disk filesystem path for a book.
 *
 * Returns null when the book is not stored on disk (e.g. in-memory blob,
 * remote URL) or the path cannot be resolved. The returned path is
 * suitable for handing to native (Rust) commands that read the file
 * directly via std::fs.
 */
export async function resolveNativeBookFilePath(
  fs: FileSystem,
  resolveFilePath: (path: string, base: BaseDir) => Promise<string>,
  book: Book,
): Promise<string | null> {
  try {
    const source = await resolveBookContentSource(fs, book);
    if (source.kind !== 'managed' && source.kind !== 'external') return null;
    const fp = await resolveFilePath(source.path, source.base);
    if (!fp) return null;
    return fp.startsWith('file://') ? decodeURI(fp.slice('file://'.length)) : fp;
  } catch {
    return null;
  }
}

export async function loadBookConfig(
  fs: FileSystem,
  book: Book,
  settings: SystemSettings,
): Promise<BookConfig> {
  const globalViewSettings = {
    ...settings.globalViewSettings,
    ...(FIXED_LAYOUT_FORMATS.has(book.format) ? DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS : {}),
  };
  try {
    let str = '{}';
    if (await fs.exists(getConfigFilename(book), 'Books')) {
      str = (await fs.readFile(getConfigFilename(book), 'Books', 'text')) as string;
    }
    return deserializeConfig(str, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
  } catch {
    return deserializeConfig('{}', globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
  }
}

export async function saveBookConfig(
  fs: FileSystem,
  book: Book,
  config: BookConfig,
  settings?: SystemSettings,
): Promise<void> {
  let serializedConfig: string;
  if (settings) {
    const globalViewSettings = {
      ...settings.globalViewSettings,
      ...(FIXED_LAYOUT_FORMATS.has(book.format) ? DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS : {}),
    };
    serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG);
  } else {
    serializedConfig = serializeRawConfig(config);
  }
  await fs.writeFile(getConfigFilename(book), 'Books', serializedConfig);
}

export async function loadBookNav(fs: FileSystem, book: Book): Promise<BookNav | null> {
  try {
    const path = getBookNavFilename(book);
    if (!(await fs.exists(path, 'Books'))) return null;
    const str = (await fs.readFile(path, 'Books', 'text')) as string;
    const parsed = JSON.parse(str) as BookNav;
    if (!parsed || typeof parsed.version !== 'number') return null;
    return parsed;
  } catch {
    return null;
  }
}

export async function saveBookNav(fs: FileSystem, book: Book, nav: BookNav): Promise<void> {
  await fs.writeFile(getBookNavFilename(book), 'Books', JSON.stringify(nav));
}

export async function fetchBookDetails(
  fs: FileSystem,
  book: Book,
  downloadBookFn: (book: Book) => Promise<void>,
): Promise<BookDoc['metadata']> {
  const fp = getLocalBookFilename(book);
  if (!(await fs.exists(fp, 'Books')) && book.uploadedAt) {
    await downloadBookFn(book);
  }
  const { file } = await loadBookContent(fs, book);
  const bookDoc = (await new DocumentLoader(file).open()).book;
  const f = file as ClosableFile;
  if (f && f.close) {
    await f.close();
  }
  return bookDoc.metadata;
}

/**
 * Refresh metadata for a single book by re-opening and re-parsing its file.
 * Updates series info, language, and other metadata fields without modifying
 * user-edited titles or reading progress.
 * Returns true if the metadata was successfully refreshed.
 */
export async function refreshBookMetadata(fs: FileSystem, book: Book): Promise<boolean> {
  const { file } = await loadBookContent(fs, book);
  const { book: bookDoc } = await new DocumentLoader(file).open();
  if (!bookDoc) return false;

  book.metadata = bookDoc.metadata;
  book.metaHash = getMetadataHash(bookDoc.metadata);
  const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language);
  if (primaryLanguage) {
    book.primaryLanguage = primaryLanguage;
  }

  // Update series info from metadata
  if (book.metadata?.belongsTo?.series) {
    const belongsTo = book.metadata.belongsTo.series;
    const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo;
    if (series) {
      book.metadata.series = formatTitle(series.name);
      book.metadata.seriesIndex = parseFloat(series.position || '0');
      if (series.total) book.metadata.seriesTotal = parseInt(series.total, 10);
    }
  }

  return true;
}

export async function exportBook(
  fs: FileSystem,
  book: Book,
  resolveFilePath: (path: string, base: BaseDir) => Promise<string>,
  copyFile: (srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir) => Promise<void>,
  saveFile: (
    filename: string,
    content: ArrayBuffer,
    options?: { filePath?: string; mimeType?: string },
  ) => Promise<boolean>,
): Promise<boolean> {
  const { source, file } = await openBookFileContent(fs, book);
  const content = await file.arrayBuffer();
  const filename = `${makeSafeFilename(book.title)}.${book.format.toLowerCase()}`;
  const mimeType = file.type || 'application/octet-stream';
  if (source.kind === 'url') {
    return await saveFile(filename, content, { mimeType });
  }
  let filePath = await resolveFilePath(source.path, source.base);
  if (getFilename(filePath) !== filename) {
    await copyFile(source.path, source.base, filename, 'Temp');
    filePath = await resolveFilePath(filename, 'Temp');
  }
  return await saveFile(filename, content, { filePath, mimeType });
}
