import {
  BadRequestException,
  Injectable,
  NotFoundException,
} from "@nestjs/common";
import { randomUUID } from "crypto";
import { Readable } from "stream";

import { MinioService } from "../minio/minio.service";
import { FilesRepository } from "./files.repository";
import { FileRecord, FileReviewStatus } from "./entities/file.entity";

export interface CreateFileInput {
  resourceId: string;
  resource: string;
  code: string;
  file: Express.Multer.File;
}

/**
 * Make a filename safe to use as a MinIO object-key segment: collapse runs of
 * spaces/unsafe characters to a single underscore while keeping the dot before
 * the extension. Prevents percent-encoding mismatches between the stored URL
 * and the actual object key.
 */
function sanitizeObjectName(name: string): string {
  return name
    .normalize("NFKD")
    .replace(/[^\w.\-]+/g, "_")
    .replace(/_{2,}/g, "_")
    .replace(/^_+|_+$/g, "");
}

@Injectable()
export class FilesService {
  // Defense-in-depth for ANY caller of upload() (not just the driver-docs
  // route). This is deliberately BROADER than the driver controller's strict
  // images+pdf Multer filter, because the same method also stores generated
  // PDFs, PNG signatures, and customer/customs booking documents (scans, office
  // docs). It rejects the actual attack surface (executables/scripts/HTML) while
  // permitting every business-document type these flows legitimately upload.
  // No file-upload-settings row governs raw byte size, so the cap is a sane,
  // generous default that won't reject large scanned documents.
  private static readonly MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
  private static readonly ALLOWED_UPLOAD_MIME = new Set([
    "image/jpeg",
    "image/png",
    "image/webp",
    "image/gif",
    "image/heic",
    "image/tiff",
    "application/pdf",
    "application/msword",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    "application/vnd.ms-excel",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    "text/csv",
    "text/plain",
  ]);

  constructor(
    private readonly filesRepository: FilesRepository,
    private readonly minioService: MinioService,
  ) {}

  async upload(input: CreateFileInput): Promise<FileRecord> {
    const { resourceId, resource, code, file } = input;

    if (!FilesService.ALLOWED_UPLOAD_MIME.has(file.mimetype)) {
      throw new BadRequestException(`Unsupported file type: ${file.mimetype}`);
    }
    if (file.size > FilesService.MAX_UPLOAD_BYTES) {
      throw new BadRequestException(
        `File exceeds the ${FilesService.MAX_UPLOAD_BYTES / (1024 * 1024)}MB upload limit`,
      );
    }
    // Keep the object key URL-safe so it survives the round-trip through the
    // stored URL (spaces/unicode in the original name would otherwise be
    // percent-encoded in the URL and no longer match the MinIO key). The
    // human-readable name is preserved separately on the record below.
    const safeName = sanitizeObjectName(file.originalname);
    // The random segment is load-bearing, not decoration. `Date.now()` alone is
    // NOT unique across a batch: callers upload with Promise.all, every callback
    // runs to its first await in the same tick, so they all read the same
    // millisecond. Two files with one name in one batch — e.g. pasting two
    // screenshots, which browsers both call "image.png" — would build identical
    // keys, and the second putObject would overwrite the first while both rows
    // persisted pointing at the same object.
    const objectName = `${resource}/${resourceId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
    const url = await this.minioService.uploadFile(
      objectName,
      file.buffer,
      file.mimetype,
    );

    return this.filesRepository.create({
      resourceId,
      resource,
      code,
      name: file.originalname,
      url,
      size: file.size,
      mimeType: file.mimetype,
    });
  }

  /**
   * Replace the file stored under a resource + code (e.g. contract PDF). The
   * previous version is retired, not destroyed — pass `replacedBy` to record who
   * swapped it and why, which is what the version history shows.
   */
  async upsertByCode(
    input: CreateFileInput,
    replacedBy?: { userId?: string | null; reason?: string | null },
  ): Promise<FileRecord> {
    const { resourceId, resource, code } = input;
    await this.filesRepository.deleteByCode(
      resourceId,
      resource,
      code,
      replacedBy,
    );
    return this.upload(input);
  }

  /**
   * Every stored version of one document, newest first. `isCurrent` marks the
   * live row; the rest are superseded uploads kept for audit.
   */
  async versionHistory(
    resourceId: string,
    resource: string,
    code: string,
  ): Promise<
    Array<{
      id: string;
      name: string;
      url: string;
      size: number;
      mimeType: string;
      uploadedAt: string;
      isCurrent: boolean;
      replacedAt: string | null;
      replacedByUserId: string | null;
      replaceReason: string | null;
    }>
  > {
    const rows = await this.filesRepository.findVersionHistory(
      resourceId,
      resource,
      code,
    );
    return rows.map((row) => ({
      id: row.id,
      name: row.name,
      url: row.url,
      size: row.size,
      mimeType: row.mimeType,
      uploadedAt: row.createdAt.toISOString(),
      isCurrent: row.deletedAt == null,
      replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
      replacedByUserId: row.replacedByUserId,
      replaceReason: row.replaceReason,
    }));
  }

  async deleteByCode(
    resourceId: string,
    resource: string,
    code: string,
  ): Promise<void> {
    await this.filesRepository.deleteByCode(resourceId, resource, code);
  }

  async uploadMany(
    resourceId: string,
    resource: string,
    files: Express.Multer.File[],
  ): Promise<FileRecord[]> {
    return Promise.all(
      files.map((file) =>
        this.upload({ resourceId, resource, code: file.fieldname, file }),
      ),
    );
  }

  /**
   * Attach already-stored files (e.g. a company profile's onboarding documents)
   * to a resource by reference — creates FileRecord rows pointing at the existing
   * object-storage URLs, without re-uploading bytes. The snapshot is fixed at call
   * time, so later changes to the source documents never alter what was attached.
   */
  async attachExistingFiles(
    resourceId: string,
    resource: string,
    files: Array<{
      code: string;
      name: string;
      url: string;
      size: number;
      mimeType?: string;
    }>,
  ): Promise<FileRecord[]> {
    return Promise.all(
      files.map((f) =>
        this.filesRepository.create({
          resourceId,
          resource,
          code: f.code,
          name: f.name,
          url: f.url,
          size: f.size,
          mimeType: f.mimeType ?? "application/octet-stream",
        }),
      ),
    );
  }

  async findById(id: string): Promise<FileRecord> {
    const record = await this.filesRepository.findById(id);
    if (!record) throw new NotFoundException(`File ${id} not found`);
    return record;
  }

  /**
   * Record a reviewer verdict on one document. `change_requested` keeps the note
   * (the customer sees it verbatim); any other verdict clears it, so a stale
   * reason can never outlive the request it explained.
   */
  async setReviewStatus(
    id: string,
    status: FileReviewStatus,
    note: string | null,
    reviewerId?: string,
  ): Promise<FileRecord> {
    const record = await this.findById(id);
    const updated = await this.filesRepository.update(record.id, {
      reviewStatus: status,
      reviewNote: status === "change_requested" ? (note ?? null) : null,
      reviewedBy: reviewerId ?? null,
      reviewedAt: new Date(),
    });
    if (!updated) throw new NotFoundException(`File ${id} not found`);
    return updated;
  }

  /**
   * Drop any reviewer verdict from a document, returning it to "not reviewed".
   * Called when a customer re-uploads: the new bytes have not been looked at, so
   * carrying the old `change_requested` forward would keep them blocked forever.
   */
  async clearReview(id: string): Promise<void> {
    await this.filesRepository.update(id, {
      reviewStatus: null,
      reviewNote: null,
      reviewedBy: null,
      reviewedAt: null,
    });
  }

  /** Documents across these resources still awaiting a customer correction. */
  findWithOpenChangeRequest(
    resourceIds: string[],
    resource: string,
  ): Promise<FileRecord[]> {
    return this.filesRepository.findWithOpenChangeRequest(
      resourceIds,
      resource,
    );
  }

  /** Soft-delete a stored file row by id (object bytes are left in MinIO). */
  async remove(id: string): Promise<void> {
    await this.filesRepository.softDelete(id);
  }

  /**
   * Re-slot a stored file under a new `code` (e.g. promote a staged
   * `business_license_pending` file to the live `business_license` code once a
   * change request is approved). Bytes and URL are untouched.
   */
  async setCode(id: string, code: string): Promise<void> {
    await this.filesRepository.update(id, { code });
  }

  findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
    return this.filesRepository.findByResource(resourceId, resource);
  }

  /**
   * Files for many resources of one kind, grouped by resource id. Resources with
   * no files are absent from the map (callers should default to `[]`).
   */
  async findByResourceIdsGrouped(
    resourceIds: string[],
    resource: string,
  ): Promise<Map<string, FileRecord[]>> {
    const records = await this.filesRepository.findByResourceIds(
      resourceIds,
      resource,
    );
    const grouped = new Map<string, FileRecord[]>();
    for (const record of records) {
      const bucket = grouped.get(record.resourceId);
      if (bucket) bucket.push(record);
      else grouped.set(record.resourceId, [record]);
    }
    return grouped;
  }

  /**
   * Short-lived signed URL for a stored file's raw MinIO URL. The persisted
   * `url` is an un-signed object path that a browser cannot fetch directly;
   * callers that expose files for preview/download must sign them first.
   */
  async signUrl(rawUrl: string, expirySeconds = 300): Promise<string> {
    const objectName = this.minioService.getObjectNameFromUrl(rawUrl);
    return this.minioService.getSignedUrl(objectName, expirySeconds);
  }

  async findByCode(
    resourceId: string,
    resource: string,
    code: string,
  ): Promise<FileRecord> {
    const record = await this.filesRepository.findByCode(
      resourceId,
      resource,
      code,
    );
    if (!record)
      throw new NotFoundException(
        `File with code "${code}" not found for ${resource} ${resourceId}`,
      );
    return record;
  }

  async streamById(
    id: string,
  ): Promise<{ stream: Readable; record: FileRecord }> {
    const record = await this.findById(id);
    const objectName = this.minioService.getObjectNameFromUrl(record.url);
    const stream = await this.minioService.getFileStream(objectName);
    return { stream, record };
  }
}
