import fs from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import fg from 'fast-glob';
import { parse } from 'csv-parse/sync';
import {
  AppSettings,
  AudioBufferPayload,
  AudioFileSummary,
  CategoryRecord,
  ImportFailureEntry,
  ImportSkipEntry,
  LibraryImportResult,
  LibraryScanSummary,
  SplitSegmentRequest,
  TagUpdatePayload
} from '../../shared/models';
import { DatabaseService, FileRecordInput } from './DatabaseService';
import { SettingsService } from './SettingsService';
import { TagService } from './TagService';
import { SearchService } from './SearchService';
import { WaveFile } from 'wavefile';
import { OrganizationService } from './OrganizationService';

type MusicMetadataParser = (path: string, options?: { duration?: boolean }) => Promise<{
  format: { duration?: number | null; sampleRate?: number | null; bitsPerSample?: number | null };
  common: { comment?: unknown[]; genre?: unknown[]; subtitle?: unknown };
}>;

type MusicMetadataNamespace = Partial<{ parseFile: MusicMetadataParser }> & {
  default?: Partial<{ parseFile: MusicMetadataParser }>;
};

interface CsvCategoryRow {
  Category: string;
  SubCategory: string;
  CatID: string;
  CatShort: string;
  Explanations: string;
  'Synonyms - Comma Separated': string;
}

/**
 * Coordinates library scanning, categorisation, and file-level operations.
 */
export class LibraryService {
  private categoriesLoaded = false;
  private readonly metadataFailures = new Set<string>();
  private readonly checksumFailures = new Set<string>();
  private readonly organization: OrganizationService;
  private metadataSuggestionCache: { authors: Set<string> } | null = null;
  private readonly waveformPreviewCache = new Map<number, { modifiedAt: number; pointCount: number; samples: number[]; rms: number }>();
  private offlineAudioContextCtor: (new (channelCount: number, length: number, sampleRate: number) => any) | null = null;
  private musicMetadataParseFile: MusicMetadataParser | null = null;
  public constructor(
    private readonly database: DatabaseService,
    private readonly settings: SettingsService,
    private readonly tagService: TagService,
    private readonly search: SearchService
  ) {
    this.organization = new OrganizationService(database);
  }

  /**
   * Parses the UCS CSV catalog and stores the categories in the database once per session.
   */
  public async ensureCategoriesLoaded(csvAbsolutePath: string): Promise<void> {
    if (this.categoriesLoaded) {
      return;
    }
    const fileContent = await fs.readFile(csvAbsolutePath, 'utf-8');
    const rows = parse(fileContent, {
      columns: true,
      skip_empty_lines: true
    }) as CsvCategoryRow[];
    for (const row of rows) {
      const category: CategoryRecord = {
        id: row.CatID,
        category: row.Category,
        subCategory: row.SubCategory,
        shortCode: row.CatShort,
        explanation: row.Explanations,
        synonyms: row['Synonyms - Comma Separated']
          .split(',')
          .map((value: string) => value.trim())
          .filter((value: string) => value.length > 0)
      };
      this.database.upsertCategory(category);
    }
    this.categoriesLoaded = true;
  }

  /**
   * Returns the current settings snapshot (used to expose default paths to the renderer).
   */
  public getSettings(): AppSettings {
    return this.settings.getSettings();
  }

  /**
   * Triggers a library rescan and refreshes the search index.
   */
  public async scanLibrary(): Promise<LibraryScanSummary> {
    this.resetMetadataSuggestionsCache();
    this.waveformPreviewCache.clear();
    
  await this.cleanupTempFiles();
    const libraryRoot = this.settings.ensureLibraryPath();
    const existing = this.database.listFiles();
  const existingByPath = new Map(existing.map((file) => [file.absolutePath, file] as const));
  const existingByChecksum = new Map(existing.filter((file) => file.checksum).map((file) => [file.checksum!, file] as const));
  const discoveredByPath = new Map<string, AudioFileSummary>();

    const pattern = ['**/*.wav', '**/*.wave'];
    const absolutePaths = await fg(pattern, {
      cwd: libraryRoot,
      absolute: true,
      onlyFiles: true,
      suppressErrors: true,
      caseSensitiveMatch: false
    });

    let added = 0;
    let updated = 0;
    const seen = new Set<string>();

    for (const absolutePath of absolutePaths) {
      const stats = await fs.stat(absolutePath);
      const relativePath = path.relative(libraryRoot, absolutePath);
      const fileName = path.basename(absolutePath);
      const displayName = path.basename(fileName, path.extname(fileName));
      const metadata = await this.extractAudioMetadata(absolutePath);
      const createdAt = Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs;
      const checksum = await this.computeFileChecksum(absolutePath);
      
      const knownByPath = existingByPath.get(absolutePath);
      const knownByChecksum = checksum ? existingByChecksum.get(checksum) : null;
      const knownFile = knownByPath ?? knownByChecksum ?? null;
      const wasKnown = knownFile !== null;
      
      // Read embedded WAV metadata (author, copyright, rating, title, parentId)
      const embeddedMetadata = this.tagService.readMetadata(absolutePath);
      
      let parentFileId = knownFile?.parentFileId ?? null;
      
      // First try embedded metadata parentId
      if (parentFileId === null && embeddedMetadata.parentId !== undefined) {
        parentFileId = embeddedMetadata.parentId;
      }
      
      // Fall back to filename pattern matching for segments
      if (parentFileId === null) {
        const segmentMatch = fileName.match(/^(.*)_segment\d+(\.[^.]+)$/i);
        if (segmentMatch) {
          const parentFileName = `${segmentMatch[1]}${segmentMatch[2]}`;
          const parentAbsolutePath = path.join(path.dirname(absolutePath), parentFileName);
          const parentRecord = existingByPath.get(parentAbsolutePath) ?? discoveredByPath.get(parentAbsolutePath) ?? null;
          if (parentRecord) {
            parentFileId = parentRecord.id;
          }
        }
      }

      const record: FileRecordInput = {
        absolutePath,
        relativePath,
        fileName,
        displayName,
        modifiedAt: stats.mtimeMs,
        createdAt,
        size: stats.size,
        durationMs: metadata.durationMs,
        sampleRate: metadata.sampleRate,
        bitDepth: metadata.bitDepth,
        checksum,
        tags: metadata.tags.length > 0 ? metadata.tags : (knownFile?.tags ?? []),
        categories: metadata.categories.length > 0 ? metadata.categories : (knownFile?.categories ?? []),
        parentFileId
      };
      const upserted = this.database.upsertFile(record);
      
      // Update custom name from embedded title if present, otherwise keep existing
      const customName = embeddedMetadata.title?.trim() || knownFile?.customName || null;
      const finalRecord = customName !== upserted.customName
        ? this.database.updateCustomName(upserted.id, customName)
        : upserted;

      existingByPath.set(absolutePath, finalRecord);
      if (checksum) {
        existingByChecksum.set(checksum, finalRecord);
      }
      discoveredByPath.set(absolutePath, finalRecord);

      if (parentFileId !== null) {
        const embeddedParent = embeddedMetadata.parentId ?? null;
        if (embeddedParent === null || embeddedParent !== parentFileId) {
          this.tagService.writeMetadataOnly(absolutePath, {
            tags: finalRecord.tags,
            categories: finalRecord.categories,
            title: customName ?? embeddedMetadata.title ?? finalRecord.displayName,
            author: embeddedMetadata.author ?? null,
            rating: embeddedMetadata.rating,
            copyright: embeddedMetadata.copyright ?? null,
            parentId: parentFileId
          });
        }
      }
      
      if (wasKnown) {
        updated += 1;
      } else {
        added += 1;
      }
      seen.add(absolutePath);
    }

    const removed = this.database.removeFilesOutside(seen);
    this.search.rebuildIndex();
  this.resetMetadataSuggestionsCache();
    const total = this.database.listFiles().length;

    return {
      added,
      updated,
      removed,
      total
    };
  }

  /**
   * Provides the current list of library entries (already sorted by the database query).
   */
  public listFiles(): AudioFileSummary[] {
    return this.database.listFiles();
  }

  /**
   * Attempts to resolve a file summary by id. Returns null when the record no longer exists.
   */
  public getFileById(fileId: number): AudioFileSummary | null {
    try {
      return this.database.getFileById(fileId);
    } catch (error) {
      if (error instanceof Error) {
        console.warn(`Failed to resolve file by id ${fileId}:`, error.message);
      }
      return null;
    }
  }

  /**
   * Returns groups of files that have identical checksums (potential duplicates).
   */
  public listDuplicates(): { checksum: string; files: AudioFileSummary[] }[] {
    return this.database.listDuplicateGroups();
  }

  /**
   * Finds and cleans up orphaned .tmp files in the library directory.
   * Returns the number of files cleaned up.
   */
  public async cleanupTempFiles(): Promise<number> {
    const libraryRoot = this.settings.getSettings().libraryPath;
    if (!libraryRoot) {
      return 0;
    }

    const pattern = ['**/*.tmp'];
    const tempFiles = await fg(pattern, {
      cwd: libraryRoot,
      absolute: true,
      onlyFiles: true,
      suppressErrors: true
    });

    let cleanedCount = 0;

    for (const tempFile of tempFiles) {
      try {
        // Try to determine the intended final name by removing the temp suffix pattern
        const baseMatch = tempFile.match(/^(.+)\.\d+-\d+-[a-f0-9]+\.tmp$/);
        if (baseMatch) {
          const intendedPath = baseMatch[1];
          
          // Check if the intended destination exists
          try {
            await fs.access(intendedPath);
            await fs.unlink(tempFile);
            cleanedCount++;
          } catch {
            await fs.rename(tempFile, intendedPath);
            cleanedCount++;
          }
        } else {
          console.warn(`Found temp file with unknown pattern: ${tempFile}`);
        }
      } catch (error) {
        console.error(`Failed to clean up temp file ${tempFile}:`, error);
      }
    }

    return cleanedCount;
  }

  /**
   * Imports external WAV files from the provided sources, copying them into the library while
   * skipping duplicates based on the audio checksum. Imported files land under `_Imports/<date>`.
   */
  public async importExternalSources(sourcePaths: string[]): Promise<LibraryImportResult> {
    // eslint-disable-next-line no-console -- Log import start for debugging.
    console.log('Starting import from:', sourcePaths);
    
    if (!Array.isArray(sourcePaths) || sourcePaths.length === 0) {
      return { imported: [], skipped: [], failed: [] };
    }

    const libraryRoot = this.settings.ensureLibraryPath();
    const normalisedLibraryRoot = this.normalizeAbsolutePath(libraryRoot);
    const libraryRootWithSlash = normalisedLibraryRoot.endsWith('/')
      ? normalisedLibraryRoot
      : `${normalisedLibraryRoot}/`;

    const existingFiles = this.database.listFiles();
    const knownChecksums = new Set<string>();
    const knownPaths = new Set<string>();
    for (const file of existingFiles) {
      if (typeof file.checksum === 'string' && file.checksum.length > 0) {
        knownChecksums.add(file.checksum);
      }
      knownPaths.add(this.normalizeAbsolutePath(file.absolutePath));
    }

    const importFolderRelativeBase = path.join('_Imports', new Date().toISOString().slice(0, 10));
    const importFolderAbsolute = path.join(libraryRoot, importFolderRelativeBase);
    await fs.mkdir(importFolderAbsolute, { recursive: true });

    const { files: candidateFiles, failures } = await this.collectImportCandidates(sourcePaths);
    
    // eslint-disable-next-line no-console -- Log discovered files for debugging.
    console.log(`Found ${candidateFiles.length} candidate files, ${failures.length} collection failures`);
    if (failures.length > 0) {
      // eslint-disable-next-line no-console -- Log collection failures for debugging.
      console.log('Collection failures:', failures);
    }

    const imported: AudioFileSummary[] = [];
    const skipped: ImportSkipEntry[] = [];
    const failed: ImportFailureEntry[] = [...failures];
    const usedNames = new Set<string>();
    const allowedExtensions = new Set(['.wav', '.wave']);

    const folderRelativeNormalised = this.normalizeRelativePath(
      path.relative(libraryRoot, importFolderAbsolute)
    );
    const folderForJoin =
      folderRelativeNormalised === '.' || folderRelativeNormalised.length === 0
        ? ''
        : folderRelativeNormalised;

    for (const candidate of candidateFiles) {
      const extension = path.extname(candidate).toLowerCase();
      if (!allowedExtensions.has(extension)) {
        // eslint-disable-next-line no-console -- Log skip reason for debugging.
        console.log(`Skipping ${candidate}: unsupported extension ${extension}`);
        skipped.push({ path: candidate, reason: 'unsupported' });
        continue;
      }

      const normalisedCandidate = this.normalizeAbsolutePath(candidate);
      if (
        normalisedCandidate === normalisedLibraryRoot ||
        normalisedCandidate.startsWith(libraryRootWithSlash)
      ) {
        // eslint-disable-next-line no-console -- Log skip reason for debugging.
        console.log(`Skipping ${candidate}: already inside library`);
        skipped.push({ path: candidate, reason: 'inside-library' });
        continue;
      }

      if (knownPaths.has(normalisedCandidate)) {
        // eslint-disable-next-line no-console -- Log skip reason for debugging.
        console.log(`Skipping ${candidate}: duplicate path`);
        skipped.push({ path: candidate, reason: 'duplicate' });
        continue;
      }

      // eslint-disable-next-line no-console -- Log checksum computation for debugging.
      console.log(`Computing checksum for ${candidate}...`);
      const checksum = await this.computeFileChecksum(candidate);
      if (!checksum) {
        // eslint-disable-next-line no-console -- Log skip reason for debugging.
        console.log(`Skipping ${candidate}: checksum computation failed`);
        skipped.push({ path: candidate, reason: 'checksum' });
        continue;
      }

      if (knownChecksums.has(checksum)) {
        // eslint-disable-next-line no-console -- Log skip reason for debugging.
        console.log(`Skipping ${candidate}: duplicate checksum ${checksum}`);
        skipped.push({ path: candidate, reason: 'duplicate' });
        continue;
      }

      // eslint-disable-next-line no-console -- Log import attempt for debugging.
      console.log(`Attempting to import ${candidate} with checksum ${checksum}...`);
      try {
        const record = await this.copyAndRegisterImportedFile({
          sourcePath: candidate,
          checksum,
          importFolderAbsolute,
          folderForJoin,
          libraryRoot,
          usedNames
        });
        imported.push(record);
        knownChecksums.add(checksum);
        knownPaths.add(this.normalizeAbsolutePath(record.absolutePath));
      } catch (error) {
        const errorMessage = error instanceof Error ? error.message : String(error);
        // eslint-disable-next-line no-console -- Log import failures for debugging.
        console.error(`Failed to import ${candidate}:`, errorMessage);
        failed.push({
          path: candidate,
          message: errorMessage
        });
      }
    }

    if (imported.length > 0) {
      this.resetMetadataSuggestionsCache();
      this.search.rebuildIndex();
    }

    return { imported, skipped, failed };
  }

  /**
   * Renames a file while keeping it in the current directory.
   */
  public async renameFile(fileId: number, requestedName: string): Promise<AudioFileSummary> {
    const record = this.database.getFileById(fileId);
    const libraryRoot = this.settings.ensureLibraryPath();
    const cleanName = this.normaliseFileName(requestedName);
    const targetDirectory = path.dirname(record.absolutePath);
    const targetPath = path.join(targetDirectory, cleanName);
    await fs.rename(record.absolutePath, targetPath);

    const updated = this.database.updateFileLocation(
      fileId,
      targetPath,
      path.relative(libraryRoot, targetPath),
      path.basename(targetPath),
      path.basename(targetPath, path.extname(targetPath))
    );
    this.waveformPreviewCache.delete(fileId);
    this.search.rebuildIndex();
    return updated;
  }

  /**
   * Moves a file to a different subdirectory under the library root.
   */
  public async moveFile(fileId: number, targetRelativeDirectory: string): Promise<AudioFileSummary> {
    const record = this.database.getFileById(fileId);
    const libraryRoot = this.settings.ensureLibraryPath();
    const normalisedTarget = path.resolve(libraryRoot, targetRelativeDirectory);
    this.assertWithinLibrary(libraryRoot, normalisedTarget);
    await fs.mkdir(normalisedTarget, { recursive: true });

    const targetPath = path.join(normalisedTarget, record.fileName);
    await fs.rename(record.absolutePath, targetPath);

    const updated = this.database.updateFileLocation(
      fileId,
      targetPath,
      path.relative(libraryRoot, targetPath),
      path.basename(targetPath),
      path.basename(targetPath, path.extname(targetPath))
    );
    this.waveformPreviewCache.delete(fileId);
    this.search.rebuildIndex();
    return updated;
  }

  /**
   * Provides the binary payload required for renderer playback.
   */
  public async getAudioBuffer(fileId: number): Promise<AudioBufferPayload> {
    const record = this.database.getFileById(fileId);
    const buffer = await fs.readFile(record.absolutePath);
    const payload: AudioBufferPayload = {
      fileId,
      buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer,
      mimeType: 'audio/wav'
    };
    return payload;
  }

  /**
   * Generates a lightweight waveform preview suitable for list rendering.
   */
  public async getWaveformPreview(fileId: number, pointCount = 160): Promise<{ samples: number[]; rms: number }> {
    const record = this.database.getFileById(fileId);
  const effectivePoints = Math.min(Math.max(pointCount ?? 160, 32), 16384);
    const cacheHit = this.waveformPreviewCache.get(fileId);
    if (cacheHit && cacheHit.modifiedAt === record.modifiedAt && cacheHit.pointCount === effectivePoints) {
      return { samples: cacheHit.samples, rms: cacheHit.rms };
    }

    try {
  const buffer = await fs.readFile(record.absolutePath);
  const wave = new WaveFile(buffer);
  const sampleBlock = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
  const channels = (Array.isArray(sampleBlock) ? sampleBlock : [sampleBlock]) as Float64Array[];
      const firstChannel = channels[0];

      if (!firstChannel || firstChannel.length === 0) {
        const fallback = new Array(effectivePoints).fill(0);
        this.waveformPreviewCache.set(fileId, {
          modifiedAt: record.modifiedAt,
          pointCount: effectivePoints,
          samples: fallback,
          rms: 0
        });
        return { samples: fallback, rms: 0 };
      }

      const amplitudeScale = Math.max(1, this.resolveWaveformAmplitudeScale(wave));
      const blockSize = Math.max(1, Math.floor(firstChannel.length / effectivePoints));
      const peaks: number[] = [];
      let sumSquares = 0;
      let sampleTotal = 0;

      for (let index = 0; index < effectivePoints; index += 1) {
        const start = index * blockSize;
        const end = index === effectivePoints - 1 ? firstChannel.length : Math.min(firstChannel.length, start + blockSize);
        let blockPeak = 0;
        for (let cursor = start; cursor < end; cursor += 1) {
          for (let channelIndex = 0; channelIndex < channels.length; channelIndex += 1) {
            const channel = channels[channelIndex];
            const rawSample = channel?.[cursor] ?? 0;
            const normalisedSample = rawSample / amplitudeScale;
            const clampedSample = Math.max(-1, Math.min(1, normalisedSample));
            const magnitude = Math.abs(clampedSample);
            if (magnitude > blockPeak) {
              blockPeak = magnitude;
            }
            sumSquares += clampedSample * clampedSample;
            sampleTotal += 1;
          }
        }
        peaks.push(Math.min(blockPeak, 1));
      }

      const samples = this.normaliseWaveformArray(peaks);
      const rms = sampleTotal > 0 ? Math.sqrt(sumSquares / sampleTotal) : 0;
      this.waveformPreviewCache.set(fileId, {
        modifiedAt: record.modifiedAt,
        pointCount: effectivePoints,
        samples,
        rms
      });

      return { samples, rms };
    } catch (error) {
      console.warn('Failed to generate waveform preview', { fileId, error });
      const fallback = new Array(effectivePoints).fill(0);
      this.waveformPreviewCache.set(fileId, {
        modifiedAt: record.modifiedAt,
        pointCount: effectivePoints,
        samples: fallback,
        rms: 0
      });
      return { samples: fallback, rms: 0 };
    }
  }

  /**
   * Applies tag updates, optionally re-running the organize pipeline when categories are present.
   */
  public async updateTagging(payload: TagUpdatePayload): Promise<AudioFileSummary> {
    const updated = this.tagService.applyTagging(payload.fileId, payload.tags, payload.categories);
    this.waveformPreviewCache.delete(payload.fileId);

    if (updated.categories.length > 0) {
      const metadataSnapshot = this.tagService.readMetadata(updated.absolutePath);
      return this.organizeFile(payload.fileId, {
        customName: updated.customName ?? undefined,
        author: metadataSnapshot.author?.trim() || undefined,
        rating: metadataSnapshot.rating ?? undefined
      });
    }

    this.resetMetadataSuggestionsCache();
    this.search.rebuildIndex();
    return updated;
  }

  /**
   * Updates the custom name for a file without organizing it.
   */
  public updateCustomName(fileId: number, customName: string | null): AudioFileSummary {
    const updated = this.database.updateCustomName(fileId, customName);
    this.search.rebuildIndex();
    return updated;
  }

  /**
   * Finds the next available sequence number by checking both database and filesystem.
   * This prevents race conditions when multiple files are being organized simultaneously.
   */
  private async findNextAvailableNumberWithFilesystemCheck(
    folderPath: string,
    baseName: string,
    targetDirectory: string
  ): Promise<number> {
    // Start with database conflicts
    let nextIndex = this.organization.findNextAvailableNumber(folderPath, baseName);
    
    // Keep checking filesystem until we find an available number
    const maxAttempts = 100; // Prevent infinite loops
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      const candidateFileName = `${baseName}_${this.organization.formatSequenceNumber(nextIndex)}.wav`;
      const candidatePath = path.join(targetDirectory, candidateFileName);
      
      try {
        await fs.stat(candidatePath);
        // File exists, try next number
        nextIndex++;
      } catch {
        // File doesn't exist, this number is available
        return nextIndex;
      }
    }
    
    // Fallback if we somehow exhaust attempts
    return nextIndex;
  }

  /**
   * Automatically organizes a file based on its categories.
   * Moves to CATEGORY/SUBCATEGORY folder and renames to SHORT_NN.wav or SHORT_customname.wav
   */
  public async organizeFile(
    fileId: number,
    metadata: { customName?: string | null; author?: string | null; rating?: number }
  ): Promise<AudioFileSummary> {
    const record = this.database.getFileById(fileId);
    const category = this.organization.getPrimaryCategory(record);
    if (!category) {
      throw new Error('Cannot organize file without category assignment');
    }

    const libraryRoot = this.settings.ensureLibraryPath();
    const folderPath = this.organization.buildFolderPath(category);
  const requestedCustomName = this.normaliseMetadataInput(metadata.customName);
  const existingCustomName = record.customName ?? undefined;
  const effectiveCustomName = requestedCustomName !== undefined ? (requestedCustomName ?? undefined) : existingCustomName;
    const baseName = this.organization.buildBaseName(category, effectiveCustomName);
    const conflicts = this.organization
      .listConflictingFiles(folderPath, baseName)
      .filter((file) => file.id !== record.id);
    const requireNumbering = !effectiveCustomName || conflicts.length > 0;
    const targetDirectory = path.resolve(libraryRoot, folderPath);
    this.assertWithinLibrary(libraryRoot, targetDirectory);
    const currentFolder = this.normalizeRelativePath(path.dirname(record.relativePath));
    const targetFolderNormalized = this.normalizeRelativePath(folderPath);
    const needsDirectoryChange = currentFolder !== targetFolderNormalized;
    let targetFileName: string;
    if (requireNumbering) {
      const pattern = new RegExp(`^${baseName}_(\\d+)\\.wav$`, 'i');
      if (!needsDirectoryChange && pattern.test(record.fileName)) {
        targetFileName = record.fileName;
      } else {
        const nextIndex = await this.findNextAvailableNumberWithFilesystemCheck(folderPath, baseName, targetDirectory);
        targetFileName = `${baseName}_${this.organization.formatSequenceNumber(nextIndex)}.wav`;
      }
    } else {
      targetFileName = `${baseName}.wav`;
    }

    const needsRename = record.fileName !== targetFileName;
    const needsMove = needsDirectoryChange || needsRename;
    let targetRelativePath = this.toLibraryRelativePath(folderPath, targetFileName);
    let targetPath = path.join(targetDirectory, targetFileName);
    
    // Final safety check: if target exists and is not our source, force numbering
    if (needsMove) {
      const targetExists = await this.pathExists(targetPath);
      if (targetExists) {
        const targetNormalized = this.normalizeAbsolutePath(targetPath);
        const sourceNormalized = this.normalizeAbsolutePath(record.absolutePath);
        if (targetNormalized !== sourceNormalized) {
          const safeIndex = await this.findNextAvailableNumberWithFilesystemCheck(folderPath, baseName, targetDirectory);
          targetFileName = `${baseName}_${this.organization.formatSequenceNumber(safeIndex)}.wav`;
          targetPath = path.join(targetDirectory, targetFileName);
          targetRelativePath = this.toLibraryRelativePath(folderPath, targetFileName);
        }
      }
    }

    if (!needsMove) {
      const nextCustomName = effectiveCustomName ?? null;
      let updatedRecord = record;
      if (nextCustomName !== (record.customName ?? null)) {
        updatedRecord = this.database.updateCustomName(fileId, nextCustomName);
      }

      // Read existing metadata from the WAV file
      const existing = this.tagService.readMetadata(updatedRecord.absolutePath);
      
      // Merge with new metadata (only update fields that are provided)
      const requestedAuthor = this.normaliseMetadataInput(metadata.author);
      const mergedAuthor = requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null;
      const mergedRating = metadata.rating !== undefined ? metadata.rating : existing.rating;
      
      this.tagService.writeMetadataOnly(updatedRecord.absolutePath, {
        tags: updatedRecord.tags,
        categories: updatedRecord.categories,
        title: effectiveCustomName,
        author: mergedAuthor,
        rating: mergedRating,
        copyright: existing.copyright ?? null,
        parentId: updatedRecord.parentFileId ?? null
      });

      // Update suggestions cache for any metadata that was provided
      if (typeof mergedAuthor === 'string' && mergedAuthor.length > 0) {
        this.updateMetadataSuggestionsCache(mergedAuthor);
      }

      this.resetMetadataSuggestionsCache();
      this.waveformPreviewCache.delete(fileId);
      this.search.rebuildIndex();
      return updatedRecord;
    }

    if (requireNumbering && conflicts.length > 0) {
      // Filter out conflicts that already have proper numbered names in the target directory
      const pattern = new RegExp(`^${baseName}_(\\d+)\\.wav$`, 'i');
      const conflictsNeedingRenumber = conflicts.filter((conflict) => {
        const conflictFolder = this.normalizeRelativePath(path.dirname(conflict.relativePath));
        const isInTargetFolder = conflictFolder === targetFolderNormalized;
        const hasNumberedName = pattern.test(conflict.fileName);
        return !isInTargetFolder || !hasNumberedName;
      });
      
      if (conflictsNeedingRenumber.length > 0) {
        await this.renumberConflictingFiles(conflictsNeedingRenumber, folderPath, baseName, libraryRoot);
        
        const nextIndex = await this.findNextAvailableNumberWithFilesystemCheck(folderPath, baseName, targetDirectory);
        targetFileName = `${baseName}_${this.organization.formatSequenceNumber(nextIndex)}.wav`;
        targetPath = path.join(targetDirectory, targetFileName);
        targetRelativePath = this.toLibraryRelativePath(folderPath, targetFileName);
      }
    }

    await fs.mkdir(targetDirectory, { recursive: true });

    await fs.rename(record.absolutePath, targetPath);

    // Try to update database, if UNIQUE constraint fails, retry with numbering
    let updated: AudioFileSummary;
    try {
      updated = this.database.updateFileLocation(
        fileId,
        targetPath,
        targetRelativePath,
        targetFileName,
        path.basename(targetFileName, '.wav')
      );
    } catch (error: unknown) {
      // Check if this is a UNIQUE constraint error on absolute_path
      const errorMessage = error instanceof Error ? error.message : String(error);
      
      if (errorMessage.includes('UNIQUE constraint') && errorMessage.includes('absolute_path')) {
        await fs.rename(targetPath, record.absolutePath);
        
        const safeIndex = await this.findNextAvailableNumberWithFilesystemCheck(folderPath, baseName, targetDirectory);
        targetFileName = `${baseName}_${this.organization.formatSequenceNumber(safeIndex)}.wav`;
        targetPath = path.join(targetDirectory, targetFileName);
        targetRelativePath = this.toLibraryRelativePath(folderPath, targetFileName);
        
        await fs.rename(record.absolutePath, targetPath);
        
        updated = this.database.updateFileLocation(
          fileId,
          targetPath,
          targetRelativePath,
          targetFileName,
          path.basename(targetFileName, '.wav')
        );
      } else {
        console.error(`Unexpected error during updateFileLocation:`, errorMessage);
        throw error;
      }
    }
    
    const nextCustomName = effectiveCustomName ?? null;
    if (nextCustomName !== (record.customName ?? null)) {
      updated = this.database.updateCustomName(fileId, nextCustomName);
    }
    
    // Read existing metadata from the file at its new location
    const existing = this.tagService.readMetadata(targetPath);
    
    // Merge with new metadata (only update fields that are provided)
  const movedAuthor = this.normaliseMetadataInput(metadata.author);
  const mergedAuthor = movedAuthor !== undefined ? movedAuthor : existing.author ?? null;
    const mergedRating = metadata.rating !== undefined ? metadata.rating : existing.rating;
    
    // Write tags, categories, and additional metadata to the organized file
    this.tagService.writeMetadataOnly(targetPath, {
      tags: updated.tags,
      categories: updated.categories,
      title: effectiveCustomName,
      author: mergedAuthor,
      rating: mergedRating,
      copyright: existing.copyright ?? null,
      parentId: updated.parentFileId ?? null
    });

    // Update suggestions cache for any metadata that was provided
    if (typeof mergedAuthor === 'string' && mergedAuthor.length > 0) {
      this.updateMetadataSuggestionsCache(mergedAuthor);
    }

    this.resetMetadataSuggestionsCache();
    this.waveformPreviewCache.delete(fileId);
    this.search.rebuildIndex();
    return updated;
  }

  /**
   * Deletes files from disk and removes them from the database.
   */
  public async deleteFiles(fileIds: number[]): Promise<void> {
    for (const fileId of fileIds) {
      const record = this.database.getFileById(fileId);
      try {
        await fs.unlink(record.absolutePath);
      } catch (error) {
        console.error(`Failed to delete file ${record.absolutePath}:`, error);
      }
      this.database.deleteFile(fileId);
      this.waveformPreviewCache.delete(fileId);
    }
    this.resetMetadataSuggestionsCache();
    this.search.rebuildIndex();
  }

  /**
   * Splits an audio file into multiple segments, writing each segment to disk and registering it in the library.
   */
  public async splitFile(fileId: number, requestSegments: SplitSegmentRequest[]): Promise<AudioFileSummary[]> {
    if (!Array.isArray(requestSegments) || requestSegments.length === 0) {
      return [];
    }

    const record = this.database.getFileById(fileId);
    const fileBuffer = await fs.readFile(record.absolutePath);
  const wave = new WaveFile(fileBuffer);
  const format = (wave as WaveFile & { fmt?: { sampleRate?: number; bitsPerSample?: number } }).fmt;
    const rawSamples = wave.getSamples(false, Float64Array) as Float64Array | Float64Array[];
    const channels = Array.isArray(rawSamples) ? rawSamples : [rawSamples];
    if (channels.length === 0 || channels[0].length === 0) {
      throw new Error('Unable to split file: no audio data available.');
    }

  const sampleRate = format?.sampleRate ?? record.sampleRate ?? null;
    if (!sampleRate) {
      throw new Error('Unable to split file: missing sample rate information.');
    }

    const sourceSampleCount = channels[0].length;
    const totalDurationMs = Math.round((sourceSampleCount / sampleRate) * 1000);
    const bitDepthNumeric = (() => {
  const explicit = format?.bitsPerSample;
      if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit > 0) {
        return explicit;
      }
      if (typeof record.bitDepth === 'number' && Number.isFinite(record.bitDepth) && record.bitDepth > 0) {
        return record.bitDepth;
      }
      const parsedFromText = Number.parseInt(typeof wave.bitDepth === 'string' ? wave.bitDepth : '', 10);
      return Number.isFinite(parsedFromText) && parsedFromText > 0 ? parsedFromText : null;
    })();
    const bitDepthText = (() => {
      if (typeof wave.bitDepth === 'string' && wave.bitDepth.trim().length > 0) {
        return wave.bitDepth.trim();
      }
      if (typeof bitDepthNumeric === 'number') {
        return bitDepthNumeric.toString();
      }
      return '16';
    })();
    const container = (wave as WaveFile & { container?: string }).container ?? 'RIFF';

  let originalMetadata: { author?: string | null; rating?: number; title?: string | null; copyright?: string | null } = {};
    try {
      const metadata = this.tagService.readMetadata(record.absolutePath);
      originalMetadata = {
        author: metadata.author ?? null,
        rating: metadata.rating ?? undefined,
        title: metadata.title ?? null,
        copyright: metadata.copyright ?? null
      };
    } catch (error) {
      console.warn('Failed to read original metadata before splitting', error);
    }

    const normalizedSegments = requestSegments
      .map((segment) => {
        const startMs = Number.isFinite(segment.startMs) ? Math.max(0, Math.min(Math.floor(segment.startMs), totalDurationMs)) : 0;
        const endMs = Number.isFinite(segment.endMs) ? Math.max(0, Math.min(Math.floor(segment.endMs), totalDurationMs)) : startMs;
        const safeEnd = Math.max(startMs + 1, endMs);
        return {
          startMs,
          endMs: safeEnd,
          label: segment.label?.trim() ?? undefined,
          metadata: segment.metadata,
          fadeInMs: Number.isFinite(segment.fadeInMs) ? Math.max(0, Math.floor(segment.fadeInMs!)) : 0,
          fadeOutMs: Number.isFinite(segment.fadeOutMs) ? Math.max(0, Math.floor(segment.fadeOutMs!)) : 0
        };
      })
      .filter((segment) => segment.endMs - segment.startMs >= 5)
      .sort((a, b) => a.startMs - b.startMs);

    if (normalizedSegments.length === 0) {
      return [];
    }

    const libraryRoot = this.settings.ensureLibraryPath();
    const sourceDirectory = path.dirname(record.absolutePath);
    const relativeFolder = path.dirname(record.relativePath);
    const folderForJoin = relativeFolder === '.' ? '' : relativeFolder;
    const baseName = path.basename(record.fileName, path.extname(record.fileName));
    const usedNames = new Set<string>();
    let sequence = 1;
    const created: AudioFileSummary[] = [];

    for (const segment of normalizedSegments) {
      const startSample = Math.max(0, Math.min(Math.floor((segment.startMs / 1000) * sampleRate), sourceSampleCount - 1));
      const endSample = Math.max(
        startSample + 1,
        Math.min(Math.floor((segment.endMs / 1000) * sampleRate), sourceSampleCount)
      );
      if (endSample <= startSample) {
        continue;
      }

  let segmentChannels: Float64Array[] = channels.map((channel) => channel.slice(startSample, endSample));

      const fadeInMs = segment.fadeInMs ?? 0;
      const fadeOutMs = segment.fadeOutMs ?? 0;
      if (fadeInMs > 0 || fadeOutMs > 0) {
        segmentChannels = (await this.applyFadeEnvelopeToSegment(segmentChannels, sampleRate, fadeInMs, fadeOutMs)) as Float64Array[];
      }

      const nextWave = new WaveFile();
      nextWave.fromScratch(channels.length, sampleRate, bitDepthText, segmentChannels.length === 1 ? segmentChannels[0] : segmentChannels);
      (nextWave as WaveFile & { container?: string }).container = container;
      const segmentBytes = Buffer.from(nextWave.toBuffer());

      // Determine the segment name part - use label if available, otherwise use sequence
      let segmentNamePart = '';
      if (segment.label) {
        const sanitized = this.organization.sanitizeCustomName(segment.label);
        if (sanitized) {
          segmentNamePart = sanitized;
        }
      }
      
      let segmentFileName = '';
      let segmentAbsolutePath = '';
      for (let attempt = 0; attempt < 1000; attempt += 1) {
        let candidateName: string;
        if (segmentNamePart) {
          // Use label-based name, with optional suffix for duplicates
          if (attempt === 0) {
            candidateName = `${baseName}_${segmentNamePart}.wav`;
          } else {
            candidateName = `${baseName}_${segmentNamePart}_${this.organization.formatSequenceNumber(attempt)}.wav`;
          }
        } else {
          // Fallback to sequential numbering
          const suffix = this.organization.formatSequenceNumber(sequence);
          sequence += 1;
          candidateName = `${baseName}_segment${suffix}.wav`;
        }
        
        if (usedNames.has(candidateName)) {
          continue;
        }
        const candidatePath = path.join(sourceDirectory, candidateName);
        const exists = await this.pathExists(candidatePath);
        if (exists) {
          continue;
        }
        segmentFileName = candidateName;
        segmentAbsolutePath = candidatePath;
        usedNames.add(candidateName);
        break;
      }

      if (!segmentFileName || !segmentAbsolutePath) {
        throw new Error('Failed to allocate filename for split segment.');
      }

      this.assertWithinLibrary(libraryRoot, segmentAbsolutePath);
      await fs.writeFile(segmentAbsolutePath, segmentBytes);
      const stats = await fs.stat(segmentAbsolutePath);
      const checksum = createHash('md5').update(segmentBytes).digest('hex');
      const relativePath = this.toLibraryRelativePath(folderForJoin, segmentFileName);
      const durationMs = Math.round(((endSample - startSample) / sampleRate) * 1000);

      const resolvedTagsSource = segment.metadata?.tags ?? record.tags;
      const resolvedCategoriesSource = segment.metadata?.categories ?? record.categories;
      const resolvedTags = Array.isArray(resolvedTagsSource) ? resolvedTagsSource.slice() : record.tags.slice();
      const resolvedCategories = Array.isArray(resolvedCategoriesSource)
        ? resolvedCategoriesSource.slice()
        : record.categories.slice();
      const fileRecord = this.database.upsertFile({
        absolutePath: segmentAbsolutePath,
        relativePath,
        fileName: segmentFileName,
        displayName: path.basename(segmentFileName, '.wav'),
        modifiedAt: stats.mtimeMs,
        createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs,
        size: stats.size,
        durationMs,
        sampleRate,
        bitDepth: bitDepthNumeric,
        checksum,
        tags: resolvedTags,
        categories: resolvedCategories,
        parentFileId: record.id
      });

      const resolvedCustomName = segment.metadata?.customName !== undefined
        ? this.normaliseMetadataInput(segment.metadata.customName)
        : segment.label !== undefined
          ? this.normaliseMetadataInput(segment.label)
          : record.customName ?? null;
      const updatedRecord = resolvedCustomName !== fileRecord.customName
        ? this.database.updateCustomName(fileRecord.id, resolvedCustomName ?? null)
        : fileRecord;

      const resolvedAuthor = segment.metadata?.author !== undefined
        ? this.normaliseMetadataInput(segment.metadata.author)
        : this.normaliseMetadataInput(originalMetadata.author ?? null);
      const resolvedRating = segment.metadata?.rating !== undefined
        ? segment.metadata.rating ?? undefined
        : originalMetadata.rating;

      this.tagService.writeMetadataOnly(segmentAbsolutePath, {
        tags: resolvedTags,
        categories: resolvedCategories,
        title: resolvedCustomName ?? updatedRecord.displayName,
        author: resolvedAuthor ?? undefined,
        rating: resolvedRating,
        copyright: originalMetadata.copyright ?? null,
        parentId: record.id
      });

      if (typeof resolvedAuthor === 'string' && resolvedAuthor.length > 0) {
        this.updateMetadataSuggestionsCache(resolvedAuthor);
      }

      this.waveformPreviewCache.delete(updatedRecord.id);
      created.push(updatedRecord);
    }

    this.waveformPreviewCache.delete(record.id);
    this.resetMetadataSuggestionsCache();
    this.search.rebuildIndex();

    return created;
  }

  /**
   * Applies the configured fade envelope to the provided channel data. Tries to render using
   * `standardized-audio-context` for consistency with the editor preview and falls back to
   * a manual smooth-step implementation if the dependency is unavailable at runtime.
   */
  private async applyFadeEnvelopeToSegment(
    source: Float64Array[],
    sampleRate: number,
    fadeInMs: number,
    fadeOutMs: number
  ): Promise<Float64Array[]> {
    if (source.length === 0 || source[0]?.length === 0) {
      return source.map((channel) => channel.slice());
    }

    const totalSamples = source[0].length;
    const fadeInSamples = Math.min(totalSamples, Math.max(0, Math.floor((fadeInMs / 1000) * sampleRate)));
    const fadeOutSamples = Math.min(totalSamples, Math.max(0, Math.floor((fadeOutMs / 1000) * sampleRate)));

    if (fadeInSamples === 0 && fadeOutSamples === 0) {
      return source.map((channel) => channel.slice());
    }

    const applyManual = (): Float64Array[] => {
      return source.map((channel) => {
        const copy = channel.slice();
        const sampleCount = copy.length;
        const fadeInDenominator = Math.max(1, fadeInSamples - 1);
        const fadeOutDenominator = Math.max(1, fadeOutSamples - 1);
        for (let index = 0; index < sampleCount; index += 1) {
          let gain = 1;
          if (fadeInSamples > 0 && index < fadeInSamples) {
            const t = fadeInDenominator > 0 ? index / fadeInDenominator : 0;
            const eased = this.smoothStep(t);
            gain = Math.min(gain, eased);
          }
          if (fadeOutSamples > 0) {
            const fromEnd = sampleCount - 1 - index;
            if (fromEnd < fadeOutSamples) {
              const t = fadeOutDenominator > 0 ? fromEnd / fadeOutDenominator : 0;
              const eased = this.smoothStep(t);
              gain = Math.min(gain, eased);
            }
          }
          copy[index] = copy[index] * gain;
        }
        return copy;
      });
    };

    try {
      let OfflineContextCtor = this.offlineAudioContextCtor;
      if (!OfflineContextCtor) {
        const audioModule = (await import('standardized-audio-context')) as {
          OfflineAudioContext?: new (channelCount: number, length: number, sampleRate: number) => any;
          default?: { OfflineAudioContext?: new (channelCount: number, length: number, sampleRate: number) => any };
        };
        const resolvedCtor = audioModule.OfflineAudioContext ?? audioModule.default?.OfflineAudioContext ?? null;
        if (typeof resolvedCtor !== 'function') {
          return applyManual();
        }
        this.offlineAudioContextCtor = resolvedCtor;
        OfflineContextCtor = resolvedCtor;
      }

      const channelCount = source.length;
      const offlineContext = new OfflineContextCtor(channelCount, totalSamples, sampleRate);
      const buffer = offlineContext.createBuffer(channelCount, totalSamples, sampleRate);
      for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
        const channelData = buffer.getChannelData(channelIndex);
        const sourceChannel = source[channelIndex];
        for (let sampleIndex = 0; sampleIndex < totalSamples; sampleIndex += 1) {
          channelData[sampleIndex] = sourceChannel?.[sampleIndex] ?? 0;
        }
      }

      const bufferSource = offlineContext.createBufferSource();
      bufferSource.buffer = buffer;
      const gainNode = offlineContext.createGain();
      bufferSource.connect(gainNode);
      gainNode.connect(offlineContext.destination);

      const totalDurationSeconds = totalSamples / sampleRate;
      gainNode.gain.setValueAtTime(1, 0);

      if (fadeInSamples > 0) {
        const fadeInCurve = this.generateFadeCurve(Math.max(fadeInSamples, 2), 'in');
        gainNode.gain.setValueAtTime(fadeInCurve[0], 0);
        gainNode.gain.setValueCurveAtTime(fadeInCurve, 0, fadeInSamples / sampleRate);
        gainNode.gain.setValueAtTime(1, fadeInSamples / sampleRate);
      }

      if (fadeOutSamples > 0) {
        const fadeOutCurve = this.generateFadeCurve(Math.max(fadeOutSamples, 2), 'out');
        const fadeOutStart = Math.max(0, totalDurationSeconds - fadeOutSamples / sampleRate);
        gainNode.gain.setValueAtTime(1, fadeOutStart);
        gainNode.gain.setValueCurveAtTime(fadeOutCurve, fadeOutStart, fadeOutSamples / sampleRate);
        gainNode.gain.setValueAtTime(fadeOutCurve[fadeOutCurve.length - 1], totalDurationSeconds);
      }

      bufferSource.start(0);
      const renderedBuffer = await offlineContext.startRendering();
      const processed: Float64Array[] = [];
      for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
        const renderedChannel = renderedBuffer.getChannelData(channelIndex);
        const clone = new Float64Array(renderedChannel.length);
        clone.set(renderedChannel);
        processed.push(clone);
      }
      return processed;
    } catch (error) {
      console.warn('Falling back to manual fade processing', error);
      return applyManual();
    }
  }

  /**
   * Generates a smooth fade curve using a cubic smooth-step easing function.
   */
  private generateFadeCurve(sampleCount: number, direction: 'in' | 'out'): Float32Array {
    const totalSamples = Math.max(sampleCount, 2);
    const curve = new Float32Array(totalSamples);
    const lastIndex = totalSamples - 1;
    for (let index = 0; index < totalSamples; index += 1) {
      const t = lastIndex === 0 ? 1 : index / lastIndex;
      const eased = this.smoothStep(Math.min(Math.max(t, 0), 1));
      curve[index] = direction === 'in' ? eased : 1 - eased;
    }
    if (direction === 'in') {
      curve[0] = 0;
      curve[lastIndex] = 1;
    } else {
      curve[0] = 1;
      curve[lastIndex] = 0;
    }
    return curve;
  }

  /**
   * Computes the cubic smooth-step easing value for the provided normalised progress.
   */
  private smoothStep(t: number): number {
    const clamped = Math.min(Math.max(t, 0), 1);
    return clamped * clamped * (3 - 2 * clamped);
  }

  /**
   * Returns the previously parsed UCS category catalog.
   */
  public listCategories(): CategoryRecord[] {
    return this.database.listCategories();
  }

  /**
   * Opens the containing folder for a file in the system file explorer.
   */
  public async openFileFolder(fileId: number): Promise<void> {
    const record = this.database.getFileById(fileId);
    const { shell } = await import('electron');
    shell.showItemInFolder(record.absolutePath);
  }

  /**
   * Reads embedded metadata from a WAV file and returns author, title, and rating.
   */
  public async readFileMetadata(fileId: number): Promise<{ author?: string; title?: string; rating?: number }> {
    const record = this.database.getFileById(fileId);
    const metadata = this.tagService.readMetadata(record.absolutePath);
    this.updateMetadataSuggestionsCache(metadata.author);
    return metadata;
  }

  /**
   * Updates metadata (author, rating) without organizing the file.
   * Only writes to the WAV INFO chunk, doesn't move or rename the file.
   */
  public async updateFileMetadata(fileId: number, metadata: { author?: string | null; rating?: number }): Promise<void> {
    const record = this.database.getFileById(fileId);
    
    // Read existing metadata from the WAV file
    const existing = this.tagService.readMetadata(record.absolutePath);
    
    // Merge with new metadata (only update fields that are provided)
    const requestedAuthor = this.normaliseMetadataInput(metadata.author);
    const mergedMetadata = {
      tags: record.tags,
      categories: record.categories,
      title: record.customName ?? existing.title,
      author: requestedAuthor !== undefined ? requestedAuthor : existing.author ?? null,
      rating: metadata.rating !== undefined ? metadata.rating : existing.rating,
      copyright: existing.copyright ?? null,
      parentId: record.parentFileId ?? null
    };
    
    // Write merged metadata to the WAV file
    this.tagService.writeMetadataOnly(record.absolutePath, mergedMetadata);

    // Update suggestions cache
    if (typeof mergedMetadata.author === 'string' && mergedMetadata.author.length > 0) {
      this.updateMetadataSuggestionsCache(mergedMetadata.author);
    }

    this.waveformPreviewCache.delete(fileId);
    this.resetMetadataSuggestionsCache();
  }

  /**
   * Returns distinct metadata suggestions aggregated from the library.
   */
  public async listMetadataSuggestions(): Promise<{ authors: string[] }> {
    if (!this.metadataSuggestionCache) {
      const authors = new Set<string>();
      const files = this.database.listFiles();

      for (const file of files) {
        try {
          const metadata = this.tagService.readMetadata(file.absolutePath);
          if (metadata.author) {
            this.addSuggestionValue(authors, metadata.author);
          }
        } catch (error) {
          if (!this.metadataFailures.has(file.absolutePath)) {
            this.metadataFailures.add(file.absolutePath);
            // eslint-disable-next-line no-console -- Provide visibility once per file for suggestion harvesting issues.
            console.warn(`Failed to collect metadata suggestions from ${file.absolutePath}`, error);
          }
        }
      }

      this.metadataSuggestionCache = { authors };
    }

    return {
      authors: Array.from(this.metadataSuggestionCache.authors).sort((a, b) => a.localeCompare(b))
    };
  }

  private addSuggestionValue(target: Set<string>, value: string): void {
    const trimmed = value.trim();
    if (trimmed.length > 0) {
      target.add(trimmed);
    }
  }

  /**
   * Normalises optional metadata inputs so blank strings collapse to null while preserving undefined for untouched fields.
   */
  private normaliseMetadataInput(value: string | null | undefined): string | null | undefined {
    if (value === undefined) {
      return undefined;
    }
    if (value === null) {
      return null;
    }
    const trimmed = value.trim();
    return trimmed.length > 0 ? trimmed : null;
  }

  private updateMetadataSuggestionsCache(author?: string | null): void {
    if (!this.metadataSuggestionCache) {
      return;
    }
    if (author) {
      this.addSuggestionValue(this.metadataSuggestionCache.authors, author);
    }
  }

  /**
   * Drops the in-memory metadata suggestion cache so the next read reflects the current library state.
   */
  private resetMetadataSuggestionsCache(): void {
    this.metadataSuggestionCache = null;
  }

  private async renumberConflictingFiles(
    conflicts: AudioFileSummary[],
    folderPath: string,
    baseName: string,
    libraryRoot: string
  ): Promise<void> {
    if (conflicts.length === 0) {
      return;
    }

    const targetDirectory = path.resolve(libraryRoot, folderPath);
    
    // Get fresh file records from database in case they were updated by previous operations
    const freshConflicts = conflicts.map(c => this.database.getFileById(c.id));
    
    console.log(`[renumberConflictingFiles] planning renumber for ${freshConflicts.length} conflict(s) in ${folderPath}`);

    const sortedConflicts = freshConflicts
      .slice()
      .sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { numeric: true, sensitivity: 'base' }));

    const plans = sortedConflicts.map((file, index) => {
      const sequence = this.organization.formatSequenceNumber(index + 1);
      const finalFileName = `${baseName}_${sequence}.wav`;
      const finalAbsolutePath = path.join(targetDirectory, finalFileName);
      const relativePath = this.toLibraryRelativePath(folderPath, finalFileName);
      const tempPath = `${finalAbsolutePath}.${Date.now()}-${file.id}-${Math.random().toString(16).slice(2)}.tmp`;
      return {
        file,
        tempPath,
        finalAbsolutePath,
        relativePath,
        fileName: finalFileName,
        displayName: path.basename(finalFileName, '.wav')
      };
    });

    const movedToTemp: typeof plans = [];

    try {
      for (const plan of plans) {
        await fs.rename(plan.file.absolutePath, plan.tempPath);
        movedToTemp.push(plan);
      }

      for (const plan of plans) {
        await fs.rename(plan.tempPath, plan.finalAbsolutePath);
        this.database.updateFileLocation(
          plan.file.id,
          plan.finalAbsolutePath,
          plan.relativePath,
          plan.fileName,
          plan.displayName
        );
      }
    } catch (error) {
      console.error('Error during batch rename, attempting rollback:', error);
      for (const plan of movedToTemp) {
        try {
          await fs.access(plan.tempPath);
          await fs.rename(plan.tempPath, plan.file.absolutePath);
        } catch (rollbackError) {
          console.error(`Failed to rollback ${plan.tempPath}:`, rollbackError);
          try {
            await fs.rename(plan.tempPath, plan.finalAbsolutePath);
          } catch {
            // Recovery failed, file may be lost
          }
        }
      }
      throw error;
    }
  }

  private toLibraryRelativePath(folderPath: string, fileName: string): string {
    return this.normalizeRelativePath(path.join(folderPath, fileName));
  }

  private normalizeAbsolutePath(value: string): string {
    return path.resolve(value).replace(/\\/g, '/');
  }

  private async pathExists(targetPath: string): Promise<boolean> {
    try {
      await fs.access(targetPath);
      return true;
    } catch {
      return false;
    }
  }

  /**
   * Determines the divisor required to normalise raw sample amplitudes for the given wave file.
   */
  private resolveWaveformAmplitudeScale(wave: WaveFile): number {
    const bitDepthText = typeof wave.bitDepth === 'string' ? wave.bitDepth.trim() : '';
    if (bitDepthText.toLowerCase().includes('f')) {
      return 1;
    }
    const parsedBitDepth = Number.parseInt(bitDepthText, 10);
    if (Number.isFinite(parsedBitDepth) && parsedBitDepth > 0) {
      return 2 ** (parsedBitDepth - 1);
    }
    const fmtDepth = (wave as WaveFile & { fmt?: { bitsPerSample?: number } }).fmt?.bitsPerSample;
    if (typeof fmtDepth === 'number' && Number.isFinite(fmtDepth) && fmtDepth > 0) {
      return 2 ** (fmtDepth - 1);
    }
    return 1;
  }

  /**
   * Scales an array of sample peak magnitudes into the 0..1 range while guarding against NaN values.
   */
  private normaliseWaveformArray(values: number[]): number[] {
    let peak = 0;
    for (const value of values) {
      if (Number.isFinite(value) && value > peak) {
        peak = value;
      }
    }
    if (peak <= 0) {
      return values.map(() => 0);
    }
    return values.map((value) => {
      if (!Number.isFinite(value)) {
        return 0;
      }
      const normalised = value / peak;
      if (normalised <= 0) {
        return 0;
      }
      if (normalised >= 1) {
        return 1;
      }
      return normalised;
    });
  }

  private normalizeRelativePath(value: string): string {
    return value.replace(/\\/g, '/');
  }

  /**
   * Gathers candidate audio files from the provided sources, handling both folders and individual files.
   */
  private async collectImportCandidates(sourcePaths: string[]): Promise<{
    files: string[];
    failures: ImportFailureEntry[];
  }> {
    const discovered = new Set<string>();
    const failures: ImportFailureEntry[] = [];
    const uniqueSources = Array.from(
      new Set((sourcePaths ?? []).map((entry) => entry?.trim()).filter((entry): entry is string => Boolean(entry)))
    );

    for (const rawSource of uniqueSources) {
      const absoluteSource = path.resolve(rawSource);
      try {
        const stats = await fs.stat(absoluteSource);
        if (stats.isDirectory()) {
          try {
            const matches = await fg(['**/*.wav', '**/*.wave'], {
              cwd: absoluteSource,
              absolute: true,
              onlyFiles: true,
              suppressErrors: true,
              caseSensitiveMatch: false
            });
            for (const match of matches) {
              discovered.add(path.resolve(match));
            }
          } catch (error) {
            failures.push({
              path: absoluteSource,
              message: error instanceof Error ? error.message : String(error)
            });
          }
        } else if (stats.isFile()) {
          discovered.add(absoluteSource);
        } else {
          failures.push({ path: absoluteSource, message: 'Unsupported file system entry.' });
        }
      } catch (error) {
        failures.push({
          path: absoluteSource,
          message: error instanceof Error ? error.message : String(error)
        });
      }
    }

    const files = Array.from(discovered).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
    return { files, failures };
  }

  /**
   * Copies a single source file into the library and registers it in the database, returning the stored record.
   */
  private async copyAndRegisterImportedFile(options: {
    sourcePath: string;
    checksum: string;
    importFolderAbsolute: string;
    folderForJoin: string;
    libraryRoot: string;
    usedNames: Set<string>;
  }): Promise<AudioFileSummary> {
    const { sourcePath, checksum, importFolderAbsolute, folderForJoin, libraryRoot, usedNames } = options;
    const extension = path.extname(sourcePath);
    const baseName = path.basename(sourcePath, extension);
    let sanitizedBase = this.organization.sanitizeCustomName(baseName);
    if (!sanitizedBase) {
      sanitizedBase = 'Imported';
    }

    let attempt = 0;
    let candidateName: string = '';
    let candidateAbsolutePath: string = '';
    while (true) {
      if (attempt === 0) {
        candidateName = this.normaliseFileName(`${sanitizedBase}.wav`);
      } else {
        const suffix = this.organization.formatSequenceNumber(attempt);
        candidateName = this.normaliseFileName(`${sanitizedBase}_${suffix}.wav`);
      }

      if (!usedNames.has(candidateName)) {
        candidateAbsolutePath = path.join(importFolderAbsolute, candidateName);
        const exists = await this.pathExists(candidateAbsolutePath);
        if (!exists) {
          break;
        }
      }

      attempt += 1;
      if (attempt > 9999) {
        throw new Error('Unable to allocate a unique filename for the imported file.');
      }
    }

    usedNames.add(candidateName);
    this.assertWithinLibrary(libraryRoot, candidateAbsolutePath);

    await fs.copyFile(sourcePath, candidateAbsolutePath);

    try {
      const stats = await fs.stat(candidateAbsolutePath);
      const metadata = await this.extractAudioMetadata(candidateAbsolutePath);
      const relativePath = this.toLibraryRelativePath(folderForJoin, candidateName);
      const record = this.database.upsertFile({
        absolutePath: candidateAbsolutePath,
        relativePath,
        fileName: candidateName,
        displayName: path.basename(candidateName, path.extname(candidateName)),
        modifiedAt: stats.mtimeMs,
        createdAt: Number.isNaN(stats.birthtimeMs) ? null : stats.birthtimeMs,
        size: stats.size,
        durationMs: metadata.durationMs,
        sampleRate: metadata.sampleRate,
        bitDepth: metadata.bitDepth,
        checksum,
        tags: metadata.tags,
        categories: metadata.categories
      });

      try {
        const embedded = this.tagService.readMetadata(candidateAbsolutePath);
        this.updateMetadataSuggestionsCache(embedded.author ?? null);
      } catch (metadataError) {
        // eslint-disable-next-line no-console -- Import should continue even if metadata read fails.
        console.warn('Failed to read metadata from imported file', metadataError);
      }

      return record;
    } catch (error) {
      await fs.rm(candidateAbsolutePath, { force: true }).catch(() => undefined);
      throw error;
    }
  }

  /**
   * Resolves and caches the music-metadata parseFile implementation.
   */
  private async resolveMusicMetadataParser(): Promise<MusicMetadataParser> {
    if (this.musicMetadataParseFile) {
      return this.musicMetadataParseFile;
    }

    const namespace = (await import('music-metadata')) as MusicMetadataNamespace;
    const candidate = typeof namespace.parseFile === 'function'
      ? namespace.parseFile
      : namespace.default && typeof namespace.default.parseFile === 'function'
        ? namespace.default.parseFile
        : null;

    if (!candidate) {
      throw new Error('music-metadata parseFile not found');
    }

    this.musicMetadataParseFile = candidate;
    return candidate;
  }

  /**
   * Extracts metadata from the WAV container, falling back to defaults when parsing fails.
   */
  private async extractAudioMetadata(filePath: string): Promise<{
    durationMs: number | null;
    sampleRate: number | null;
    bitDepth: number | null;
    tags: string[];
    categories: string[];
  }> {
    try {
      const parseFile = await this.resolveMusicMetadataParser();
      const metadata = await parseFile(filePath, { duration: true });
      const infoTags = this.tagService.readInfoTags(filePath);
      const splitInfoValues = (input: string | undefined): string[] =>
        input
          ? input
              .split(/[,;]+/)
              .map((entry) => entry.trim())
              .filter((entry) => entry.length > 0)
          : [];
      const duration = metadata.format.duration ? Math.round(metadata.format.duration * 1000) : null;
      const sampleRate = metadata.format.sampleRate ?? null;
      const bitDepth = metadata.format.bitsPerSample ?? null;
      const comment = (metadata.common.comment ?? [])
          .map((entry: unknown) => {
            if (typeof entry === 'string') {
              return entry;
            }
            if (entry && typeof entry === 'object' && 'text' in entry) {
              const value = (entry as { text?: unknown }).text;
              return typeof value === 'string' ? value : '';
            }
            return '';
          })
          .filter((value: string) => value.length > 0);
        const genre = (metadata.common.genre ?? [])
          .map((entry: unknown) => (typeof entry === 'string' ? entry : ''))
          .filter((entry: string) => entry.length > 0);
      const infoTagValues = splitInfoValues(infoTags.IKEY);
      const tagSet = new Set<string>();
      if (infoTagValues.length > 0) {
        for (const entry of infoTagValues) {
          tagSet.add(entry);
        }
      } else {
        for (const entry of comment) {
          tagSet.add(entry);
        }
        for (const entry of genre) {
          tagSet.add(entry);
        }
      }
        
        // Extract subtitle field and use it as CatID for category matching
        const subtitle = metadata.common.subtitle;
        const categoriesSet = new Set<string>();
        const subtitleValues = typeof subtitle === 'string' ? splitInfoValues(subtitle) : [];
        for (const value of subtitleValues) {
          categoriesSet.add(value);
        }

        const subjectValues = splitInfoValues(infoTags.ISBJ);
        for (const value of subjectValues) {
          categoriesSet.add(value);
        }

        if (categoriesSet.size === 0) {
          const legacyGenreValues = splitInfoValues(infoTags.IGNR);
          for (const value of legacyGenreValues) {
            categoriesSet.add(value);
          }
        }

        const categories: string[] = [];
        for (const candidate of categoriesSet) {
          const category = this.database.getCategoryById(candidate);
          if (category) {
            categories.push(candidate);
          }
        }
        
      const fallback = await this.extractWaveFallback(filePath, {
        durationMs: duration,
        sampleRate,
        bitDepth
      });

      const filteredTags = Array.from(tagSet).filter((entry) => !categories.includes(entry));

      return {
        durationMs: fallback.durationMs,
        sampleRate: fallback.sampleRate,
        bitDepth: fallback.bitDepth,
        tags: filteredTags,
        categories
      };
    } catch (error) {
      if (!this.metadataFailures.has(filePath)) {
        this.metadataFailures.add(filePath);
        // eslint-disable-next-line no-console -- Provide visibility once per file for metadata parsing issues.
        console.warn(`Failed to parse metadata for ${filePath}`, error);
      }
      const fallback = await this.extractWaveFallback(filePath);
      return {
        durationMs: fallback.durationMs,
        sampleRate: fallback.sampleRate,
        bitDepth: fallback.bitDepth,
        tags: [],
        categories: []
      };
    }
  }

  /**
   * Attempts to parse metadata directly from the WAV container when the primary parser misses fields.
   */
  private async extractWaveFallback(
    filePath: string,
    baseline?: {
      durationMs: number | null;
      sampleRate: number | null;
      bitDepth: number | null;
    }
  ): Promise<{
    durationMs: number | null;
    sampleRate: number | null;
    bitDepth: number | null;
  }> {
    const current = {
      durationMs: baseline?.durationMs ?? null,
      sampleRate: baseline?.sampleRate ?? null,
      bitDepth: baseline?.bitDepth ?? null
    };

    if (current.durationMs !== null && current.sampleRate !== null && current.bitDepth !== null) {
      return current;
    }

    try {
      const buffer = await fs.readFile(filePath);
      const wave = new WaveFile(buffer);

      const fmt = wave.fmt as {
        sampleRate?: number;
        bitsPerSample?: number;
        blockAlign?: number;
      };
      const data = wave.data as {
        chunkSize?: number;
      };

      const sampleRate = current.sampleRate !== null ? current.sampleRate : typeof fmt.sampleRate === 'number' ? fmt.sampleRate : null;
      const bitDepth = current.bitDepth !== null ? current.bitDepth : typeof fmt.bitsPerSample === 'number' ? fmt.bitsPerSample : null;

      let durationMs = current.durationMs;
      const blockAlign = typeof fmt.blockAlign === 'number' ? fmt.blockAlign : null;
      const chunkSize = typeof data.chunkSize === 'number' ? data.chunkSize : null;
      if (durationMs === null && sampleRate && blockAlign && blockAlign > 0 && chunkSize !== null && sampleRate > 0) {
        const totalSamples = chunkSize / blockAlign;
        durationMs = Math.round((totalSamples / sampleRate) * 1000);
      }

      return {
        durationMs,
        sampleRate,
        bitDepth
      };
    } catch (error) {
      if (!this.metadataFailures.has(`${filePath}::wave`)) {
        this.metadataFailures.add(`${filePath}::wave`);
        // eslint-disable-next-line no-console -- Provide visibility once per file for metadata fallback issues.
        console.warn(`Failed to parse WAV metadata for ${filePath}`, error);
      }
      return current;
    }
  }

  /**
   * Computes checksum from audio data only, excluding metadata chunks for stability.
   */
  private async computeFileChecksum(filePath: string): Promise<string | null> {
    try {
      const buffer = await fs.readFile(filePath);
      const wave = new WaveFile(buffer);
      
      // Hash only the raw audio samples, not metadata or other chunks
      const hash = createHash('md5');
      const samples = wave.getSamples();
      
      if (Array.isArray(samples)) {
        // Multi-channel: hash each channel
        for (const channel of samples) {
          hash.update(Buffer.from(channel.buffer));
        }
      } else {
        // Single channel
        hash.update(Buffer.from(samples.buffer));
      }
      
      return hash.digest('hex');
    } catch (error) {
      if (!this.checksumFailures.has(filePath)) {
        this.checksumFailures.add(filePath);
        // eslint-disable-next-line no-console -- Only log checksum issues once per file.
        console.warn(`Failed to compute checksum for ${filePath}`, error);
      }
      return null;
    }
  }

  /**
   * Normalises file names, ensuring the .wav extension is present.
   */
  private normaliseFileName(input: string): string {
    const trimmed = input.trim();
    if (trimmed.length === 0) {
      throw new Error('File name cannot be empty.');
    }
    const extension = path.extname(trimmed);
    if (!extension) {
      return `${trimmed}.wav`;
    }
    if (extension.toLowerCase() !== '.wav') {
      throw new Error('Only WAV files are supported.');
    }
    return trimmed;
  }

  /**
   * Ensures paths stay inside the configured library root.
   */
  private assertWithinLibrary(libraryRoot: string, candidate: string): void {
    const normalisedRoot = path.resolve(libraryRoot);
    const normalisedCandidate = path.resolve(candidate);
    const rootWithSeparator = normalisedRoot.endsWith(path.sep)
      ? normalisedRoot
      : `${normalisedRoot}${path.sep}`;
    if (
      normalisedCandidate !== normalisedRoot &&
      !normalisedCandidate.toLowerCase().startsWith(rootWithSeparator.toLowerCase())
    ) {
      throw new Error('Target path must remain inside the configured library.');
    }
  }
}
