import { DispatcherService } from '@actions/dispatcher.service'
import { BadRequestException, Injectable, NotFoundException, PayloadTooLargeException } from '@nestjs/common'
import { S3Service } from '@s3/s3.service'
import type { AuthorizationContext, FileId, User } from '@types'
import { ViewService } from '@view/view.service'

import type { MultipartFile } from '@fastify/multipart'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { Readable } from 'node:stream'

import { UploadConfig } from './upload.config'
import {
  DEFAULT_MAX_SIZE_BYTES,
  type StoredUploadRecord,
  type UploadGuardOptions,
  type UploadedFileDataPayload,
  type UploadStorageMode,
} from './upload.types'

type FileTypeRule = {
  extensions: string[]
  mimeTypes: string[]
  mimePrefixes: string[]
}

const FILE_TYPE_RULES: Record<string, FileTypeRule> = {
  image: {
    extensions: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
    mimeTypes: [],
    mimePrefixes: ['image/'],
  },
  excel: {
    extensions: ['.xlsx', '.xls'],
    mimeTypes: [
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
      'application/vnd.ms-excel',
      'application/octet-stream',
    ],
    mimePrefixes: [],
  },
  json: {
    extensions: ['.json'],
    mimeTypes: ['application/json', 'text/json'],
    mimePrefixes: [],
  },
  csv: {
    extensions: ['.csv'],
    mimeTypes: ['text/csv', 'application/csv', 'application/vnd.ms-excel'],
    mimePrefixes: [],
  },
  pdf: {
    extensions: ['.pdf'],
    mimeTypes: ['application/pdf'],
    mimePrefixes: [],
  },
  text: {
    extensions: ['.txt', '.md'],
    mimeTypes: ['text/plain', 'text/markdown'],
    mimePrefixes: ['text/'],
  },
}

@Injectable()
export class UploadService {
  private readonly inMemoryFiles = new Map<FileId, Buffer>()

  constructor(
    private readonly uploadConfig: UploadConfig,
    private readonly dispatcherService: DispatcherService,
    private readonly viewService: ViewService,
    private readonly s3Service: S3Service,
  ) {}

  public async processUpload({
    multipartFile,
    user,
    options,
  }: {
    multipartFile: MultipartFile
    user: User
    options?: UploadGuardOptions
  }): Promise<UploadedFileDataPayload> {
    const maxBytes = options?.maxFileSizeBytes ?? this.uploadConfig.values.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES
    const filename = multipartFile.filename || 'upload.bin'
    const mimetype = multipartFile.mimetype || 'application/octet-stream'
    const extension = path.extname(filename).toLowerCase()

    this.validateConstraints({ extension, mimetype, options })

    const buffer = await this.toBufferWithLimit(multipartFile.file, maxBytes)
    if (buffer.length === 0) {
      throw new BadRequestException('Uploaded file is empty')
    }

    const storageMode = this.resolveStorageMode(options)
    const createdFile = await this.dispatcherService.dispatch({
      action: {
        scope: 'file',
        type: 'create',
        payload: {
          name: filename,
          mimetype,
          size: buffer.length,
          url: '',
        },
      },
      user,
    })

    try {
      const stored = await this.storeUpload({ fileId: createdFile.id, filename, extension, buffer, storageMode })

      const updatedFile = await this.dispatcherService.dispatch({
        action: {
          scope: 'file',
          type: 'update',
          payload: {
            id: createdFile.id,
            name: filename,
            mimetype,
            size: buffer.length,
            url: stored.location,
          },
        },
        user,
      })

      return {
        file: updatedFile,
        buffer,
        filename,
        mimetype,
        size: buffer.length,
        fields: multipartFile.fields,
      }
    } catch (error) {
      await this.deleteFailedUploadRecord({ fileId: createdFile.id, user })
      throw error
    }
  }

  public async getStoredFileBuffer({
    fileId,
    authorization,
  }: {
    fileId: FileId
    authorization: AuthorizationContext
  }): Promise<Buffer> {
    const file = await this.viewService.files.get({ id: fileId, authorization })
    if (!file) {
      throw new NotFoundException(`File ${fileId} not found`)
    }

    return this.readStoredFile({ fileId, location: file.url })
  }

  public async getStoredFileRecord({ fileId, authorization }: { fileId: FileId; authorization: AuthorizationContext }) {
    const file = await this.viewService.files.get({ id: fileId, authorization })
    if (!file) {
      throw new NotFoundException(`File ${fileId} not found`)
    }

    return file
  }

  public async getBufferFromFileRecord({ fileId, location }: { fileId: FileId; location: string }): Promise<Buffer> {
    return this.readStoredFile({ fileId, location })
  }

  public getRootUser(): User {
    return this.viewService.users.data.rootUser
  }

  private resolveStorageMode(options?: UploadGuardOptions): UploadStorageMode {
    if (this.uploadConfig.values.dryRun) {
      return 'none'
    }

    return options?.storageMode ?? this.uploadConfig.values.storage
  }

  private async storeUpload({
    fileId,
    filename,
    extension,
    buffer,
    storageMode,
  }: {
    fileId: FileId
    filename: string
    extension: string
    buffer: Buffer
    storageMode: UploadStorageMode
  }): Promise<StoredUploadRecord> {
    if (storageMode === 'none') {
      this.inMemoryFiles.set(fileId, buffer)
      return {
        fileId,
        location: `memory://${fileId}`,
        storageMode,
      }
    }

    if (storageMode === 'local') {
      const sanitizedName = filename.replaceAll(/[^A-Za-z0-9._-]/g, '_')
      const storageDir = path.resolve(this.uploadConfig.values.localDirectory)
      const targetPath = path.join(storageDir, `${fileId}-${sanitizedName}`)

      await mkdir(storageDir, { recursive: true })
      await writeFile(targetPath, buffer)

      return {
        fileId,
        location: `file://${targetPath}`,
        storageMode,
      }
    }

    const key = await this.s3Service.send(
      Readable.from(buffer),
      String(fileId),
      extension ? extension.slice(1) : undefined,
    )

    return {
      fileId,
      location: `s3://${key}`,
      storageMode,
    }
  }

  private async readStoredFile({ fileId, location }: { fileId: FileId; location: string }): Promise<Buffer> {
    if (location.startsWith('memory://')) {
      const cached = this.inMemoryFiles.get(fileId)
      if (!cached) {
        throw new NotFoundException(`No in-memory binary found for ${fileId}`)
      }
      return cached
    }

    if (location.startsWith('file://')) {
      const filePath = location.slice('file://'.length)
      return readFile(filePath)
    }

    if (location.startsWith('s3://')) {
      const key = location.slice('s3://'.length)
      const body = await this.s3Service.getFile(key)
      if (!body) {
        throw new NotFoundException(`Stored object ${key} not found in S3`)
      }
      return Buffer.from(body)
    }

    throw new BadRequestException(`Unsupported upload location format: ${location}`)
  }

  private validateConstraints({
    extension,
    mimetype,
    options,
  }: {
    extension: string
    mimetype: string
    options?: UploadGuardOptions
  }): void {
    if (options?.allowedMimeTypes && options.allowedMimeTypes.length > 0) {
      if (!options.allowedMimeTypes.includes(mimetype)) {
        throw new BadRequestException(`Unsupported mimetype: ${mimetype}`)
      }
    }

    if (options?.allowedFileExtensions && options.allowedFileExtensions.length > 0) {
      const allowedExtensions = options.allowedFileExtensions.map((ext) => ext.toLowerCase())
      if (!allowedExtensions.includes(extension)) {
        throw new BadRequestException(`Unsupported file extension: ${extension || '(none)'}`)
      }
    }

    if (options?.allowedFileTypes && options.allowedFileTypes.length > 0) {
      const allowed = options.allowedFileTypes.some((type) => {
        const rule = FILE_TYPE_RULES[type]
        return (
          rule.extensions.includes(extension) ||
          rule.mimeTypes.includes(mimetype) ||
          rule.mimePrefixes.some((prefix) => mimetype.startsWith(prefix))
        )
      })

      if (!allowed) {
        throw new BadRequestException(
          `File type is not allowed for mimetype ${mimetype} and extension ${extension || '(none)'}`,
        )
      }
    }
  }

  private async toBufferWithLimit(stream: AsyncIterable<unknown>, maxBytes: number): Promise<Buffer> {
    const chunks: Buffer[] = []
    let size = 0

    for await (const chunk of stream) {
      const buffer = toBuffer(chunk)
      size += buffer.length
      if (size > maxBytes) {
        throw new PayloadTooLargeException(`Uploaded file exceeds ${Math.floor(maxBytes / (1024 * 1024))} MB limit`)
      }
      chunks.push(buffer)
    }

    return Buffer.concat(chunks)
  }

  private async deleteFailedUploadRecord({ fileId, user }: { fileId: FileId; user: User }): Promise<void> {
    try {
      await this.dispatcherService.dispatch({
        action: {
          scope: 'file',
          type: 'delete',
          payload: fileId,
        },
        user,
      })
    } catch {
      // Best-effort rollback to avoid orphan file records when storage fails.
    }
  }
}

function toBuffer(chunk: unknown): Buffer {
  if (Buffer.isBuffer(chunk)) {
    return chunk
  }
  if (chunk instanceof Uint8Array) {
    return Buffer.from(chunk)
  }
  return Buffer.from(String(chunk))
}
