import {
  BadRequestException,
  Injectable,
  InternalServerErrorException,
  NotFoundException,
} from '@nestjs/common';
import { spawn } from 'node:child_process';
import { access, mkdir, open, rename, stat, unlink } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { randomUUID } from 'node:crypto';
import ffmpegPath from 'ffmpeg-static';
import sharp from 'sharp';
import { Prisma } from '../generated/prisma/client.js';
import { Role, UploadAssetType } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import { UploadAssetQueryDto } from './dto/upload-asset-query.dto.js';
import {
  ensureUploadDirectories,
  resolveUploadRootDir,
  resolveUploadTempDir,
} from './upload-path.util.js';

const SAFE_FILE_MIME_TYPES: Record<string, string> = {
  '.pdf': 'application/pdf',
  '.doc': 'application/msword',
  '.docx':
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  '.xls': 'application/vnd.ms-excel',
  '.xlsx':
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};

const OLE_SIGNATURE = Buffer.from([
  0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1,
]);

@Injectable()
export class UploadsService {
  constructor(private readonly prisma: PrismaService) {}

  /**
   * 创建上传资产记录并落盘到公开目录。
   */
  async createUpload(
    actor: ActorContext,
    file: Express.Multer.File | undefined,
    requestedHospitalId?: number,
  ) {
    if (!file) {
      throw new BadRequestException(MESSAGES.UPLOAD.FILE_REQUIRED);
    }

    const hospitalId = await this.resolveHospitalScope(actor, requestedHospitalId);
    const type = this.detectAssetType(file.mimetype);
    ensureUploadDirectories();
    const prepared = await this.prepareStoredFile(file, type);
    const date = new Date();
    const relativeDir = join(
      `${date.getFullYear()}`,
      `${date.getMonth() + 1}`.padStart(2, '0'),
      `${date.getDate()}`.padStart(2, '0'),
    );
    const absoluteDir = join(resolveUploadRootDir(), relativeDir);
    await mkdir(absoluteDir, { recursive: true });

    const finalFileName = await this.buildFinalFileName(
      absoluteDir,
      file.originalname,
      prepared.outputExtension,
      date,
    );
    const relativePath = join(relativeDir, finalFileName);
    const absolutePath = join(resolveUploadRootDir(), relativePath);

    try {
      await rename(prepared.tempPath, absolutePath);
      if (prepared.tempPath !== file.path) {
        await this.safeUnlink(file.path);
      }
    } catch (error) {
      await this.safeUnlink(file.path);
      await this.safeUnlink(prepared.tempPath);
      throw error;
    }

    const url = `/${relativePath.replace(/\\/g, '/')}`.replace(/^\/+/, '/uploads/');

    try {
      return await this.prisma.uploadAsset.create({
        data: {
          hospitalId,
          creatorId: actor.id,
          type,
          originalName: file.originalname,
          fileName: finalFileName,
          storagePath: relativePath.replace(/\\/g, '/'),
          url,
          mimeType: prepared.mimeType,
          fileSize: prepared.fileSize,
        },
        include: {
          hospital: { select: { id: true, name: true } },
          creator: { select: { id: true, name: true, role: true } },
        },
      });
    } catch (error) {
      await this.safeUnlink(absolutePath);
      throw error;
    }
  }

  /**
   * 查询上传资产列表：按医院作用域隔离。
   */
  async findAll(actor: ActorContext, query: UploadAssetQueryDto) {
    const page = query.page && query.page > 0 ? query.page : 1;
    const pageSize =
      query.pageSize && query.pageSize > 0 && query.pageSize <= 100
        ? query.pageSize
        : 20;

    const where: Prisma.UploadAssetWhereInput = {};
    const scopedHospitalId = this.resolveReadableHospitalScope(actor, query.hospitalId);
    if (scopedHospitalId != null) {
      where.hospitalId = scopedHospitalId;
    }
    if (query.type) {
      where.type = query.type;
    }
    if (query.keyword?.trim()) {
      where.originalName = {
        contains: query.keyword.trim(),
        mode: 'insensitive',
      };
    }

    const [total, list] = await this.prisma.$transaction([
      this.prisma.uploadAsset.count({ where }),
      this.prisma.uploadAsset.findMany({
        where,
        include: {
          hospital: { select: { id: true, name: true } },
          creator: { select: { id: true, name: true, role: true } },
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * pageSize,
        take: pageSize,
      }),
    ]);

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

  private detectAssetType(mimeType: string) {
    if (mimeType.startsWith('image/')) {
      return UploadAssetType.IMAGE;
    }
    if (mimeType.startsWith('video/')) {
      return UploadAssetType.VIDEO;
    }
    return UploadAssetType.FILE;
  }

  private async resolveHospitalScope(
    actor: ActorContext,
    requestedHospitalId?: number,
  ) {
    if (actor.role === Role.SYSTEM_ADMIN) {
      if (
        requestedHospitalId == null ||
        !Number.isInteger(requestedHospitalId) ||
        requestedHospitalId <= 0
      ) {
        throw new BadRequestException(
          MESSAGES.UPLOAD.SYSTEM_ADMIN_HOSPITAL_REQUIRED,
        );
      }

      const hospital = await this.prisma.hospital.findUnique({
        where: { id: requestedHospitalId },
        select: { id: true },
      });
      if (!hospital) {
        throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
      }
      return hospital.id;
    }

    if (!actor.hospitalId) {
      throw new BadRequestException(MESSAGES.UPLOAD.ACTOR_HOSPITAL_REQUIRED);
    }
    return actor.hospitalId;
  }

  private async prepareStoredFile(
    file: Express.Multer.File,
    type: UploadAssetType,
  ) {
    const baseName = randomUUID();

    if (type === UploadAssetType.IMAGE) {
      const tempPath = join(resolveUploadTempDir(), `${baseName}.webp`);
      try {
        await sharp(file.path)
          .rotate()
          .resize({
            width: 2560,
            height: 2560,
            fit: 'inside',
            withoutEnlargement: true,
          })
          .webp({
            quality: 82,
            effort: 4,
          })
          .toFile(tempPath);
      } catch {
        await this.safeUnlink(tempPath);
        throw new BadRequestException(MESSAGES.UPLOAD.INVALID_IMAGE_FILE);
      }

      const fileInfo = await stat(tempPath);
      return {
        tempPath,
        outputExtension: '.webp',
        mimeType: 'image/webp',
        fileSize: fileInfo.size,
      };
    }

    if (type === UploadAssetType.VIDEO) {
      const tempPath = join(resolveUploadTempDir(), `${baseName}.mp4`);
      try {
        await this.compressVideo(file.path, tempPath);
      } catch (error) {
        await this.safeUnlink(tempPath);
        if (error instanceof BadRequestException) {
          throw error;
        }
        throw new BadRequestException(MESSAGES.UPLOAD.INVALID_VIDEO_FILE);
      }

      const fileInfo = await stat(tempPath);
      return {
        tempPath,
        outputExtension: '.mp4',
        mimeType: 'video/mp4',
        fileSize: fileInfo.size,
      };
    }

    return this.prepareGenericFile(file);
  }

  private async buildFinalFileName(
    absoluteDir: string,
    originalName: string,
    outputExtension: string,
    date: Date,
  ) {
    const timestamp = this.formatDateToSecond(date);
    const originalBaseName =
      this.normalizeOriginalBaseName(originalName) || 'upload-file';
    const extension = outputExtension || extname(originalName) || '';
    const baseFileName = `${timestamp}-${originalBaseName}`;
    let candidate = `${baseFileName}${extension}`;
    let duplicateIndex = 1;

    while (await this.fileExists(join(absoluteDir, candidate))) {
      candidate = `${baseFileName}-${duplicateIndex}${extension}`;
      duplicateIndex += 1;
    }

    return candidate;
  }

  private normalizeOriginalBaseName(originalName: string) {
    const rawBaseName = originalName.replace(/\.[^.]+$/, '');
    const sanitized = rawBaseName
      .normalize('NFKC')
      .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
      .replace(/\s+/g, ' ')
      .trim()
      .replace(/-+/g, '-');

    return sanitized || 'upload-file';
  }

  private formatDateToSecond(date: Date) {
    const year = String(date.getFullYear());
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const day = String(date.getDate()).padStart(2, '0');
    const hours = String(date.getHours()).padStart(2, '0');
    const minutes = String(date.getMinutes()).padStart(2, '0');
    const seconds = String(date.getSeconds()).padStart(2, '0');

    return `${year}${month}${day}${hours}${minutes}${seconds}`;
  }

  private async fileExists(path: string) {
    try {
      await access(path);
      return true;
    } catch {
      return false;
    }
  }

  private async prepareGenericFile(file: Express.Multer.File) {
    const outputExtension = this.normalizeSafeFileExtension(file.originalname);
    const signature = await this.readFileSignature(file.path, 8);

    if (!this.isSafeFileSignature(outputExtension, signature)) {
      throw new BadRequestException(MESSAGES.UPLOAD.INVALID_FILE_SIGNATURE);
    }

    const fileInfo = await stat(file.path);
    return {
      tempPath: file.path,
      outputExtension,
      mimeType: SAFE_FILE_MIME_TYPES[outputExtension],
      fileSize: fileInfo.size,
    };
  }

  private normalizeSafeFileExtension(originalName: string) {
    const extension = extname(originalName).toLowerCase();
    if (!SAFE_FILE_MIME_TYPES[extension]) {
      throw new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE);
    }
    return extension;
  }

  private async readFileSignature(path: string, byteLength: number) {
    const handle = await open(path, 'r');

    try {
      const buffer = Buffer.alloc(byteLength);
      const { bytesRead } = await handle.read(buffer, 0, byteLength, 0);
      return buffer.subarray(0, bytesRead);
    } finally {
      await handle.close();
    }
  }

  private isSafeFileSignature(extension: string, signature: Buffer) {
    switch (extension) {
      case '.pdf':
        return signature.subarray(0, 5).toString('ascii') === '%PDF-';
      case '.doc':
      case '.xls':
        return signature.length >= OLE_SIGNATURE.length &&
          signature.subarray(0, OLE_SIGNATURE.length).equals(OLE_SIGNATURE);
      case '.docx':
      case '.xlsx':
        return (
          signature.length >= 4 &&
          signature[0] === 0x50 &&
          signature[1] === 0x4b &&
          [0x03, 0x05, 0x07].includes(signature[2]) &&
          [0x04, 0x06, 0x08].includes(signature[3])
        );
      default:
        return false;
    }
  }

  private async compressVideo(inputPath: string, outputPath: string) {
    const command = ffmpegPath as unknown as string | null;
    if (!command) {
      throw new InternalServerErrorException(MESSAGES.UPLOAD.FFMPEG_NOT_AVAILABLE);
    }

    const args = [
      '-y',
      '-i',
      inputPath,
      '-map',
      '0:v:0',
      '-map',
      '0:a?',
      '-vf',
      "scale='if(gt(iw,ih),min(1280,iw),-2)':'if(gt(iw,ih),-2,min(1280,ih))'",
      '-c:v',
      'libx264',
      '-preset',
      'veryfast',
      '-crf',
      '29',
      '-pix_fmt',
      'yuv420p',
      '-movflags',
      '+faststart',
      '-c:a',
      'aac',
      '-b:a',
      '128k',
      outputPath,
    ];

    await new Promise<void>((resolve, reject) => {
      const child = spawn(command, args);
      let stderr = '';

      child.stderr?.on('data', (chunk) => {
        stderr += String(chunk);
      });
      child.on('error', reject);
      child.on('close', (code) => {
        if (code === 0) {
          resolve();
          return;
        }
        reject(
          new BadRequestException(
            stderr.trim() || MESSAGES.UPLOAD.INVALID_VIDEO_FILE,
          ),
        );
      });
    });
  }

  private resolveReadableHospitalScope(
    actor: ActorContext,
    requestedHospitalId?: number,
  ) {
    if (actor.role === Role.SYSTEM_ADMIN) {
      return requestedHospitalId && requestedHospitalId > 0
        ? requestedHospitalId
        : null;
    }

    if (!actor.hospitalId) {
      throw new BadRequestException(MESSAGES.UPLOAD.ACTOR_HOSPITAL_REQUIRED);
    }
    return actor.hospitalId;
  }

  private async safeUnlink(path: string | undefined) {
    if (!path) {
      return;
    }
    try {
      await unlink(path);
    } catch {
      // 临时文件清理失败不影响主流程，避免吞掉原始错误。
    }
  }
}
