import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';

import { detectMediaType, isPdfContent } from './file-type.helper';
import {
  deleteFileFromDisk,
  getStoredFilePath,
  saveFileToDisk,
} from './local-disk.storage';

export interface UploadOptions {
  // Allowed media families, for example ['image', 'pdf'].
  allowedMediaTypes?: string[];
  // Allowed exact mime types, for example ['application/pdf'].
  allowedMimeTypes?: string[];
}

export type UploadedFileMetadata = {
  path: string;
  name: string;
  size: number;
  mimeType: string;
};

// Central place every module uses to store and delete uploaded files.
@Injectable()
export class UploadService {
  private readonly logger = new Logger(UploadService.name);

  // Checks the file, writes it to disk, and returns its public URL.
  async save(
    file: Express.Multer.File,
    folderName?: string,
    options?: UploadOptions,
  ): Promise<{ url: string; mediaType: string }> {
    const mediaType = detectMediaType(file.mimetype);

    if (
      options?.allowedMediaTypes &&
      !options.allowedMediaTypes.includes(mediaType)
    ) {
      throw new BadRequestException(
        `Unsupported file type: ${file.mimetype}. Allowed: ${options.allowedMediaTypes.join(', ')}`,
      );
    }

    if (
      options?.allowedMimeTypes &&
      !options.allowedMimeTypes.includes(file.mimetype)
    ) {
      throw new BadRequestException(
        `Unsupported file type: ${file.mimetype}. Allowed: ${options.allowedMimeTypes.join(', ')}`,
      );
    }

    // A file that claims to be a PDF must really start with PDF bytes.
    // This stops renamed files from sneaking past the checks above.
    if (file.mimetype === 'application/pdf' && !isPdfContent(file.buffer)) {
      throw new BadRequestException('The uploaded file is not a valid PDF');
    }

    const url = await saveFileToDisk(file, folderName);
    return { url, mediaType };
  }

  // Deletes a stored file by URL. This method never throws: a file that
  // fails to delete only means some disk space is wasted, and that must
  // not break the request that already updated the database.
  async remove(fileUrl: string | null | undefined): Promise<boolean> {
    if (!fileUrl) return false;

    try {
      return await deleteFileFromDisk(fileUrl);
    } catch (error) {
      this.logger.warn(`Could not delete uploaded file ${fileUrl}`, error);
      return false;
    }
  }

  // Reads metadata for an existing stored file without exposing disk paths.
  // A deleted or invalid file returns null so a broken upload does not break
  // the API response for its parent record.
  async getMetadata(
    fileUrl: string | null | undefined,
  ): Promise<UploadedFileMetadata | null> {
    if (!fileUrl) return null;

    const storedFilePath = getStoredFilePath(fileUrl);
    if (!storedFilePath) return null;

    try {
      const stats = await fs.promises.stat(storedFilePath);
      if (!stats.isFile()) return null;

      const storedName = path.basename(storedFilePath);

      return {
        path: fileUrl,
        name: this.toDisplayName(storedName),
        size: stats.size,
        mimeType: this.getMimeType(storedName),
      };
    } catch (error) {
      const code = (error as NodeJS.ErrnoException).code;
      if (code !== 'ENOENT') {
        this.logger.warn(`Could not read uploaded file ${fileUrl}`, error);
      }
      return null;
    }
  }

  private toDisplayName(storedName: string): string {
    // Supports both current timestamp-only filenames and older filenames that
    // also contained an eight-character random token.
    return (
      storedName.replace(/^\d+-(?:[a-f0-9]{8}(?:-[a-f0-9]{4}){0,3}-?)?/i, '') ||
      storedName
    );
  }

  private getMimeType(fileName: string): string {
    return path.extname(fileName).toLowerCase() === '.pdf'
      ? 'application/pdf'
      : 'application/octet-stream';
  }
}
