import { execFile } from 'child_process';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { existsSync } from 'fs';
import { mkdir, readdir, rename, rm, stat, writeFile } from 'fs/promises';
import { basename, extname, isAbsolute, join, relative, resolve } from 'path';
import { promisify } from 'util';

type FileManagerItem = {
  name: string;
  relativePath: string;
  kind: 'file' | 'folder';
  extension: string;
  fileType: string;
  size: number;
  modifiedAt: string;
  isImage: boolean;
  isArchive: boolean;
};

type UploadedMemoryFile = {
  originalname: string;
  mimetype: string;
  buffer?: Buffer;
  path?: string;
  size: number;
};

const execFileAsync = promisify(execFile);

@Injectable()
export class FileManagerService {
  private readonly rootDir = resolve(process.cwd(), 'storage', 'file-manager');
  private readonly uploadTmpDir = resolve(process.cwd(), 'storage', 'file-manager-upload-tmp');

  async list(path = '', limit = 18, offset = 0) {
    await this.ensureRootDir();
    const directoryPath = this.resolveStoragePath(path);
    const directoryStat = await this.safeStat(directoryPath);

    if (!directoryStat || !directoryStat.isDirectory()) {
      throw new NotFoundException('Folder not found.');
    }

    const safeLimit = this.normalizeLimit(limit);
    const safeOffset = this.normalizeOffset(offset);
    const entries = await readdir(directoryPath, { withFileTypes: true });
    const sortedEntries = entries.sort((left, right) => {
      if (left.isDirectory() !== right.isDirectory()) {
        return left.isDirectory() ? -1 : 1;
      }

      return left.name.localeCompare(right.name, undefined, {
        sensitivity: 'base',
        numeric: true,
      });
    });

    const total = sortedEntries.length;
    const pageEntries = sortedEntries.slice(safeOffset, safeOffset + safeLimit);
    const items = await Promise.all(
      pageEntries.map(async (entry) => {
        const entryFullPath = join(directoryPath, entry.name);
        const entryStat = await stat(entryFullPath);
        const entryRelativePath = this.toRelativePath(entryFullPath);
        const extension = entry.isDirectory() ? '' : extname(entry.name).replace('.', '').toUpperCase();
        const fileType = this.fileTypeOf(entry.name, entry.isDirectory());
        const isImage = !entry.isDirectory() && this.isImageExtension(extension);
        const isArchive = !entry.isDirectory() && this.isArchiveExtension(extension);

        return {
          name: entry.name,
          relativePath: entryRelativePath,
          kind: entry.isDirectory() ? 'folder' : 'file',
          extension,
          fileType,
          size: entry.isDirectory() ? 0 : entryStat.size,
          modifiedAt: entryStat.mtime.toISOString(),
          isImage,
          isArchive,
        } satisfies FileManagerItem;
      }),
    );

    return {
      rootName: 'File Manager Storage',
      currentPath: this.normalizeRelativePath(path),
      parentPath: this.parentPathOf(path),
      items,
      total,
      offset: safeOffset,
      limit: safeLimit,
      hasMore: safeOffset + items.length < total,
    };
  }

  async createFolder(path: string, name: string) {
    await this.ensureRootDir();
    const targetDir = this.buildChildPath(path, name);
    if (existsSync(targetDir)) {
      throw new BadRequestException('A file or folder with this name already exists.');
    }
    await mkdir(targetDir, { recursive: true });
    return { created: true };
  }

  async createFile(path: string, name: string, content: string) {
    await this.ensureRootDir();
    const targetFile = this.buildChildPath(path, name);
    if (existsSync(targetFile)) {
      throw new BadRequestException('A file or folder with this name already exists.');
    }
    await writeFile(targetFile, content || '', 'utf8');
    return { created: true };
  }

  async rename(path: string, nextName: string) {
    await this.ensureRootDir();
    const sourcePath = this.resolveStoragePath(path);
    const sourceStat = await this.safeStat(sourcePath);
    if (!sourceStat) {
      throw new NotFoundException('File or folder not found.');
    }

    const safeName = this.sanitizeName(nextName);
    const destinationPath = this.buildSiblingPath(path, safeName);
    if (existsSync(destinationPath)) {
      throw new BadRequestException('A file or folder with the new name already exists.');
    }

    await rename(sourcePath, destinationPath);
    return { renamed: true };
  }

  async move(paths: string[], destinationPath: string) {
    await this.ensureRootDir();
    const uniquePaths = [...new Set((paths || []).map((path) => this.normalizeRelativePath(path)).filter(Boolean))];
    if (!uniquePaths.length) {
      throw new BadRequestException('Select at least one item to move.');
    }

    const normalizedDestination = this.normalizeRelativePath(destinationPath);
    const destinationDirectory = this.resolveStoragePath(normalizedDestination);
    const destinationStat = await this.safeStat(destinationDirectory);
    if (!destinationStat || !destinationStat.isDirectory()) {
      throw new NotFoundException('Destination folder not found.');
    }

    const operations: Array<{ sourcePath: string; targetPath: string }> = [];
    let skipped = 0;

    for (const itemPath of uniquePaths) {
      const sourcePath = this.resolveStoragePath(itemPath);
      if (sourcePath === this.rootDir) {
        throw new BadRequestException('Root folder cannot be moved.');
      }

      const sourceStat = await this.safeStat(sourcePath);
      if (!sourceStat) {
        throw new NotFoundException(`Item not found: ${itemPath}`);
      }

      if (this.parentPathOf(itemPath) === normalizedDestination) {
        skipped += 1;
        continue;
      }

      if (sourceStat.isDirectory() && this.isSameOrInside(destinationDirectory, sourcePath)) {
        throw new BadRequestException('A folder cannot be moved into itself or one of its child folders.');
      }

      operations.push({
        sourcePath,
        targetPath: await this.nextAvailablePath(destinationDirectory, basename(sourcePath)),
      });
    }

    for (const operation of operations) {
      await rename(operation.sourcePath, operation.targetPath);
    }

    return {
      moved: operations.length,
      skipped,
      destinationPath: normalizedDestination,
    };
  }

  async remove(path: string) {
    await this.ensureRootDir();
    const targetPath = this.resolveStoragePath(path);
    if (targetPath === this.rootDir) {
      throw new BadRequestException('Root folder cannot be deleted.');
    }

    const targetStat = await this.safeStat(targetPath);
    if (!targetStat) {
      throw new NotFoundException('File or folder not found.');
    }

    await rm(targetPath, { recursive: true, force: false });
    return { deleted: true };
  }

  async upload(path: string, files: UploadedMemoryFile[]) {
    await this.ensureRootDir();
    if (!files.length) {
      throw new BadRequestException('Select at least one file to upload.');
    }

    const directoryPath = this.resolveStoragePath(path);
    const directoryStat = await this.safeStat(directoryPath);
    if (!directoryStat || !directoryStat.isDirectory()) {
      throw new NotFoundException('Upload folder not found.');
    }

    try {
      for (const file of files) {
        const fileName = this.sanitizeName(file.originalname);
        const targetPath = await this.nextAvailablePath(directoryPath, fileName);
        if (file.path) {
          await rename(file.path, targetPath);
        } else if (file.buffer) {
          await writeFile(targetPath, file.buffer);
        } else {
          throw new BadRequestException('Uploaded file content was not received.');
        }
      }
    } catch (error) {
      await Promise.all(files.map((file) => this.removeTempUpload(file.path)));
      throw error;
    }

    return { uploaded: files.length };
  }

  async unzip(path: string, destinationName: string) {
    await this.ensureRootDir();
    const sourcePath = this.resolveStoragePath(path);
    const sourceStat = await this.safeStat(sourcePath);

    if (!sourceStat || !sourceStat.isFile()) {
      throw new NotFoundException('ZIP file not found.');
    }

    if (extname(sourcePath).toLowerCase() !== '.zip') {
      throw new BadRequestException('Only .zip files can be extracted right now.');
    }

    await this.validateZipEntries(sourcePath);

    const parentPath = this.parentPathOf(path);
    const parentDirectory = this.resolveStoragePath(parentPath);
    const extractName = this.sanitizeName(destinationName || basename(sourcePath, extname(sourcePath)));
    const targetDirectory = await this.nextAvailableDirectoryPath(parentDirectory, extractName);

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

    try {
      await execFileAsync('unzip', ['-oq', sourcePath, '-d', targetDirectory]);
    } catch {
      await rm(targetDirectory, { recursive: true, force: true });
      throw new BadRequestException('Unable to extract this ZIP file.');
    }

    return {
      extracted: true,
      folder: this.toRelativePath(targetDirectory),
      name: basename(targetDirectory),
    };
  }

  async resolveFile(path: string) {
    await this.ensureRootDir();
    const fullPath = this.resolveStoragePath(path);
    const fileStat = await this.safeStat(fullPath);
    if (!fileStat || !fileStat.isFile()) {
      throw new NotFoundException('File not found.');
    }

    return {
      fullPath,
      name: basename(fullPath),
    };
  }

  private async ensureRootDir() {
    await Promise.all([
      mkdir(this.rootDir, { recursive: true }),
      mkdir(this.uploadTmpDir, { recursive: true }),
    ]);
  }

  private normalizeRelativePath(path: string) {
    return String(path || '')
      .replace(/\\/g, '/')
      .split('/')
      .map((segment) => segment.trim())
      .filter(Boolean)
      .join('/');
  }

  private resolveStoragePath(path: string) {
    const relativePath = this.normalizeRelativePath(path);
    const fullPath = resolve(this.rootDir, relativePath);
    if (!fullPath.startsWith(this.rootDir)) {
      throw new BadRequestException('Invalid path.');
    }
    return fullPath;
  }

  private buildChildPath(path: string, name: string) {
    const safeName = this.sanitizeName(name);
    return this.resolveStoragePath(
      this.normalizeRelativePath([this.normalizeRelativePath(path), safeName].filter(Boolean).join('/')),
    );
  }

  private buildSiblingPath(path: string, name: string) {
    const relativePath = this.normalizeRelativePath(path);
    const segments = relativePath.split('/').filter(Boolean);
    segments.pop();
    segments.push(name);
    return this.resolveStoragePath(segments.join('/'));
  }

  private parentPathOf(path: string) {
    const segments = this.normalizeRelativePath(path).split('/').filter(Boolean);
    segments.pop();
    return segments.join('/');
  }

  private toRelativePath(fullPath: string) {
    return fullPath
      .replace(`${this.rootDir}/`, '')
      .replace(this.rootDir, '')
      .replace(/\\/g, '/')
      .replace(/^\/+/, '');
  }

  private sanitizeName(name: string) {
    const trimmedName = String(name || '').trim();
    if (!trimmedName) {
      throw new BadRequestException('Name is required.');
    }
    if (trimmedName === '.' || trimmedName === '..') {
      throw new BadRequestException('This name is not allowed.');
    }
    if (trimmedName.includes('/') || trimmedName.includes('\\')) {
      throw new BadRequestException('Nested paths are not allowed in the name field.');
    }
    return trimmedName;
  }

  private normalizeLimit(limit: number) {
    const value = Number.isFinite(limit) ? Math.trunc(limit) : 18;
    return Math.min(Math.max(value || 18, 1), 60);
  }

  private normalizeOffset(offset: number) {
    const value = Number.isFinite(offset) ? Math.trunc(offset) : 0;
    return Math.max(value || 0, 0);
  }

  private async safeStat(targetPath: string) {
    try {
      return await stat(targetPath);
    } catch {
      return null;
    }
  }

  private async removeTempUpload(path?: string) {
    if (!path) return;
    const tempPath = resolve(path);
    if (!tempPath.startsWith(this.uploadTmpDir)) return;
    await rm(tempPath, { force: true });
  }

  private isSameOrInside(candidatePath: string, parentPath: string) {
    const candidate = resolve(candidatePath);
    const parent = resolve(parentPath);
    const distance = relative(parent, candidate);
    return !distance || (!distance.startsWith('..') && !isAbsolute(distance));
  }

  private async nextAvailablePath(directoryPath: string, fileName: string) {
    const extension = extname(fileName);
    const baseName = fileName.slice(0, extension ? -extension.length : undefined);
    let candidateName = fileName;
    let counter = 1;

    while (existsSync(join(directoryPath, candidateName))) {
      candidateName = `${baseName} (${counter})${extension}`;
      counter += 1;
    }

    return join(directoryPath, candidateName);
  }

  private async nextAvailableDirectoryPath(directoryPath: string, folderName: string) {
    let candidateName = folderName;
    let counter = 1;

    while (existsSync(join(directoryPath, candidateName))) {
      candidateName = `${folderName} (${counter})`;
      counter += 1;
    }

    return join(directoryPath, candidateName);
  }

  private async validateZipEntries(sourcePath: string) {
    let stdout = '';

    try {
      ({ stdout } = await execFileAsync('unzip', ['-l', sourcePath]));
    } catch {
      throw new BadRequestException('Unable to inspect this ZIP file.');
    }

    const entries = stdout
      .split(/\r?\n/)
      .map((line) => {
        const match = line.match(/^\s*\d+\s+\d{2}-\d{2}-\d{4}\s+\d{2}:\d{2}\s+(.*)$/);
        return match?.[1]?.trim() || '';
      })
      .filter(Boolean);

    for (const entry of entries) {
      const normalized = entry.replace(/\\/g, '/');
      if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) {
        throw new BadRequestException('ZIP file contains an invalid absolute path.');
      }

      const segments = normalized.split('/').filter(Boolean);
      if (segments.some((segment) => segment === '.' || segment === '..')) {
        throw new BadRequestException('ZIP file contains an unsafe path.');
      }
    }
  }

  private fileTypeOf(name: string, isDirectory: boolean) {
    if (isDirectory) return 'Folder';

    const extension = extname(name).replace('.', '').toUpperCase();
    if (this.isImageExtension(extension)) return 'Image';
    if (this.isArchiveExtension(extension)) return 'ZIP / Archive';
    if (['TXT', 'MD', 'JSON', 'CSV', 'TSV', 'LOG'].includes(extension)) return 'Text';
    if (['PDF'].includes(extension)) return 'PDF';
    if (['DOC', 'DOCX', 'RTF'].includes(extension)) return 'Document';
    if (['XLS', 'XLSX'].includes(extension)) return 'Spreadsheet';
    if (['PPT', 'PPTX'].includes(extension)) return 'Presentation';
    if (['MP4', 'AVI', 'MOV', 'WEBM'].includes(extension)) return 'Video';
    if (['MP3', 'WAV', 'AAC'].includes(extension)) return 'Audio';
    return extension ? `${extension} File` : 'File';
  }

  private isImageExtension(extension: string) {
    return ['PNG', 'JPG', 'JPEG', 'WEBP', 'GIF', 'BMP', 'SVG'].includes(extension);
  }

  private isArchiveExtension(extension: string) {
    return ['ZIP', 'RAR', '7Z', 'TAR', 'GZ', 'TGZ'].includes(extension);
  }
}
