const formidable = require('formidable');
const fs = require('fs');
const config = require('../config');
const path = require('path');
const { pipeline } = require('stream/promises');
const { v4: uuid } = require('uuid');
const { format } = require("util");
const {
  S3Client,
  PutObjectCommand,
  GetObjectCommand,
  DeleteObjectCommand,
} = require('@aws-sdk/client-s3');

const ensureDirectoryExistence = (filePath) => {
  const dirname = path.dirname(filePath);

  if (fs.existsSync(dirname)) {
    return true;
  }

  ensureDirectoryExistence(dirname);
  fs.mkdirSync(dirname);
}

const UPLOAD_SESSIONS_DIR = path.join(config.uploadDir, 'upload_sessions');
const UPLOAD_SESSION_TTL_MS = 24 * 60 * 60 * 1000;

const sanitizeFolder = (folder) => {
  const value = String(folder || '')
    .trim()
    .replace(/^\/+|\/+$/g, '');

  if (!value || value.includes('..')) {
    return null;
  }

  return value;
}

const sanitizeFilename = (filename) => {
  const value = path.basename(String(filename || '').trim());

  if (!value || value === '.' || value === '..') {
    return null;
  }

  return value;
}

const getSessionDir = (sessionId) => path.join(UPLOAD_SESSIONS_DIR, sessionId);
const getSessionMetaPath = (sessionId) => path.join(getSessionDir(sessionId), 'meta.json');
const getSessionChunksDir = (sessionId) => path.join(getSessionDir(sessionId), 'chunks');
const getSessionChunkPath = (sessionId, chunkIndex) =>
  path.join(getSessionChunksDir(sessionId), `${String(chunkIndex)}.part`);

const readSessionMeta = (sessionId) => {
  const metaPath = getSessionMetaPath(sessionId);

  if (!fs.existsSync(metaPath)) {
    return null;
  }

  const raw = fs.readFileSync(metaPath, 'utf8');
  return JSON.parse(raw);
}

const writeSessionMeta = (sessionId, payload) => {
  const metaPath = getSessionMetaPath(sessionId);
  ensureDirectoryExistence(metaPath);
  fs.writeFileSync(metaPath, JSON.stringify(payload, null, 2), 'utf8');
}

const removeUploadSession = (sessionId) => {
  const sessionDir = getSessionDir(sessionId);

  if (fs.existsSync(sessionDir)) {
    fs.rmSync(sessionDir, { recursive: true, force: true });
  }
}

const cleanupExpiredUploadSessions = () => {
  if (!fs.existsSync(UPLOAD_SESSIONS_DIR)) {
    return;
  }

  const now = Date.now();
  const sessionIds = fs.readdirSync(UPLOAD_SESSIONS_DIR);

  sessionIds.forEach((sessionId) => {
    try {
      const meta = readSessionMeta(sessionId);
      if (!meta) {
        removeUploadSession(sessionId);
        return;
      }

      const updatedAt = new Date(meta.updatedAt || meta.createdAt || 0).getTime();
      if (!updatedAt || now - updatedAt > UPLOAD_SESSION_TTL_MS) {
        removeUploadSession(sessionId);
      }
    } catch (error) {
      console.error(`Failed to cleanup upload session ${sessionId}`, error);
      removeUploadSession(sessionId);
    }
  });
}

const streamAppendFile = async (targetPath, sourcePath) => {
  await new Promise((resolve, reject) => {
    const writeStream = fs.createWriteStream(targetPath, { flags: 'a' });
    const readStream = fs.createReadStream(sourcePath);

    writeStream.on('error', reject);
    readStream.on('error', reject);
    writeStream.on('finish', resolve);
    readStream.pipe(writeStream, { end: true });
  });
}

const uploadLocal = (
  folder,
  validations = {
    entity: null,
    folderIncludesAuthenticationUid: false,
  },
) => {
  return (req, res) => {
    if (!req.currentUser) {
      res.sendStatus(403);
      return;
    }

    if (
      validations.entity
    ) {
      res.sendStatus(403);
      return;
    }

    if (validations.folderIncludesAuthenticationUid) {
      folder = folder.replace(
        ':userId',
        req.currentUser.authenticationUid,
      );
      if (
        !req.currentUser.authenticationUid ||
        !folder.includes(req.currentUser.authenticationUid)
      ) {
        res.sendStatus(403);
        return;
      }
    }

    const form = new formidable.IncomingForm();
    form.uploadDir = config.uploadDir;

    form.parse(req, function (err, fields, files) {
      const filename = String(fields.filename);
      const fileTempUrl = files.file.path;

      if (!filename) {
        fs.unlinkSync(fileTempUrl);
        res.sendStatus(500);
        return;
      }

      const privateUrl = path.join(
        form.uploadDir,
        folder,
        filename,
      );
      ensureDirectoryExistence(privateUrl);
      fs.renameSync(fileTempUrl, privateUrl);
      res.sendStatus(200);
    });

    form.on('error', function (err) {
      res.status(500).send(err);
    });
  }
}

const downloadLocal = async (req, res) => {
    const privateUrl = req.query.privateUrl;
    if (!privateUrl) {
      return res.sendStatus(404);
    }
    res.download(path.join(config.uploadDir, privateUrl));
}

const deleteLocal = async (privateUrl) => {
  try {
    if (!privateUrl) {
      return;
    }

    const filePath = path.join(config.uploadDir, privateUrl);

    if (fs.existsSync(filePath)) {
      fs.unlinkSync(filePath);
    }
  } catch (error) {
    console.error(`Cannot delete local file ${privateUrl}`, error);
    throw error;
  }
}

const initGCloud = () => {
  const processFile = require("../middlewares/upload");
  const { Storage } = require("@google-cloud/storage");

  const hash = config.gcloud.hash

  const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, "\n");

  const storage = new Storage({
      projectId: process.env.GC_PROJECT_ID,
      credentials: {
          client_email: process.env.GC_CLIENT_EMAIL,
          private_key: privateKey
      }
  });

  const bucket = storage.bucket(config.gcloud.bucket);
  return {hash, bucket, processFile};
}

const getFileStorageProvider = () => {
  const provider = (process.env.FILE_STORAGE_PROVIDER || '').trim().toLowerCase();

  if (provider) {
    return provider;
  }

  const hasS3Credentials = Boolean(
    config.s3.bucket &&
    config.s3.region &&
    config.s3.accessKeyId &&
    config.s3.secretAccessKey,
  );

  if (hasS3Credentials) {
    return 's3';
  }

  const hasGCloudCredentials = Boolean(
    process.env.GC_PROJECT_ID &&
    process.env.GC_CLIENT_EMAIL &&
    process.env.GC_PRIVATE_KEY &&
    config.gcloud.bucket &&
    config.gcloud.hash,
  );

  if (hasGCloudCredentials) {
    return 'gcloud';
  }

  return 'local';
}

const initS3 = () => {
  const processFile = require('../middlewares/upload');
  const client = new S3Client({
    region: config.s3.region,
    credentials: {
      accessKeyId: config.s3.accessKeyId,
      secretAccessKey: config.s3.secretAccessKey,
    },
  });

  return {
    client,
    bucket: config.s3.bucket,
    region: config.s3.region,
    prefix: config.s3.prefix,
    processFile,
  };
}

const buildStoragePath = (prefix, privateUrl) => {
  const cleanPrefix = (prefix || '').replace(/^\/+|\/+$/g, '');
  const cleanPrivateUrl = String(privateUrl || '').replace(/^\/+/, '');

  if (!cleanPrefix) {
    return cleanPrivateUrl;
  }

  return `${cleanPrefix}/${cleanPrivateUrl}`;
}

const uploadGCloud = async (folder, req, res) => {
  try {
    const {hash, bucket, processFile} = initGCloud();
    await processFile(req, res);
    let buffer = await req.file.buffer;
    let filename = await req.body.filename;

    if (!req.file) {
      return res.status(400).send({ message: "Please upload a file!" });
    }

    let path = `${hash}/${folder}/${filename}`;
    let blob = bucket.file(path);

    console.log(path);

    const blobStream = blob.createWriteStream({
      resumable: false,
    });

    blobStream.on("error", (err) => {
      console.log('Upload error');
      console.log(err.message);
      res.status(500).send({ message: err.message });
    });

    console.log(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);

    blobStream.on("finish", async () => {
      const publicUrl = format(
        `https://storage.googleapis.com/${bucket.name}/${blob.name}`
      );

      res.status(200).send({
        message: "Uploaded the file successfully: " + path,
        url: publicUrl,
      });
    });

    blobStream.end(buffer)
  } catch (err) {
    console.log(err);

    res.status(500).send({
      message: `Could not upload the file. ${err}`
    });
  }
}

const downloadGCloud = async (req, res) => {
  try {
    const {hash, bucket} = initGCloud();

    const privateUrl = await req.query.privateUrl;
    const filePath = `${hash}/${privateUrl}`;
    const file = bucket.file(filePath)
    const fileExists = await file.exists();

    if (fileExists[0]) {
      const stream = file.createReadStream();
      stream.pipe(res);
    }
    else {
      res.status(404).send({
        message: "Could not download the file.",
      });      
    }
  } catch (err) {
    res.status(404).send({
      message: "Could not download the file. " + err,
    });
  }
}

const uploadS3 = async (folder, req, res) => {
  try {
    const { client, bucket, region, prefix, processFile } = initS3();
    await processFile(req, res);

    if (!req.file) {
      return res.status(400).send({ message: "Please upload a file!" });
    }

    const filename = req.body.filename;

    if (!filename) {
      return res.status(400).send({ message: "Missing filename" });
    }

    const privateUrl = `${folder}/${filename}`;
    const key = buildStoragePath(prefix, privateUrl);

    await client.send(
      new PutObjectCommand({
        Bucket: bucket,
        Key: key,
        Body: req.file.buffer,
        ContentType: req.file.mimetype,
      }),
    );

    return res.status(200).send({
      message: `Uploaded the file successfully: ${privateUrl}`,
      url: `https://${bucket}.s3.${region}.amazonaws.com/${key}`,
    });
  } catch (error) {
    console.error('S3 upload error', error);
    return res.status(500).send({
      message: `Could not upload the file. ${error.message || error}`,
    });
  }
}

const downloadS3 = async (req, res) => {
  try {
    const privateUrl = req.query.privateUrl;

    if (!privateUrl) {
      return res.status(404).send({ message: "Missing privateUrl" });
    }

    const { client, bucket, prefix } = initS3();
    const key = buildStoragePath(prefix, privateUrl);
    const output = await client.send(
      new GetObjectCommand({
        Bucket: bucket,
        Key: key,
      }),
    );

    if (!output || !output.Body) {
      return res.status(404).send({
        message: "Could not download the file.",
      });
    }

    if (output.ContentType) {
      res.setHeader('Content-Type', output.ContentType);
    }

    if (typeof output.Body.pipe === 'function') {
      output.Body.pipe(res);
      return;
    }

    if (typeof output.Body.transformToByteArray === 'function') {
      const bytes = await output.Body.transformToByteArray();
      res.send(Buffer.from(bytes));
      return;
    }

    return res.send(output.Body);
  } catch (error) {
    const statusCode = error && error.name === 'NoSuchKey' ? 404 : 500;
    return res.status(statusCode).send({
      message: `Could not download the file. ${error.message || error}`,
    });
  }
}

const deleteGCloud = async (privateUrl) => {
  try {
    const {hash, bucket} = initGCloud();
    const filePath = `${hash}/${privateUrl}`;

    const file = bucket.file(filePath)
    const fileExists = await file.exists();

    if (fileExists[0]) {
      file.delete();
    }
  } catch (err) {
    console.log(`Cannot find the file ${privateUrl}`);
  }
}

const deleteS3 = async (privateUrl) => {
  try {
    if (!privateUrl) {
      return;
    }

    const { client, bucket, prefix } = initS3();
    const key = buildStoragePath(prefix, privateUrl);
    await client.send(
      new DeleteObjectCommand({
        Bucket: bucket,
        Key: key,
      }),
    );
  } catch (error) {
    console.error(`Cannot delete S3 file ${privateUrl}`, error);
    throw error;
  }
}

const uploadStreamToS3 = async (privateUrl, sourcePath, contentType) => {
  const { client, bucket, region, prefix } = initS3();
  const key = buildStoragePath(prefix, privateUrl);

  await client.send(
    new PutObjectCommand({
      Bucket: bucket,
      Key: key,
      Body: fs.createReadStream(sourcePath),
      ContentType: contentType || undefined,
    }),
  );

  return `https://${bucket}.s3.${region}.amazonaws.com/${key}`;
}

const uploadStreamToGCloud = async (privateUrl, sourcePath) => {
  const { hash, bucket } = initGCloud();
  const fullPath = `${hash}/${privateUrl}`;
  const blob = bucket.file(fullPath);

  await pipeline(
    fs.createReadStream(sourcePath),
    blob.createWriteStream({ resumable: false }),
  );

  return format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`);
}

const initUploadSession = async (req, res) => {
  try {
    if (!req.currentUser || !req.currentUser.id) {
      return res.sendStatus(403);
    }

    cleanupExpiredUploadSessions();

    const folder = sanitizeFolder(req.body?.folder);
    const filename = sanitizeFilename(req.body?.filename);
    const totalChunks = Number(req.body?.totalChunks);
    const size = Number(req.body?.size);
    const contentType = String(req.body?.contentType || '').trim();

    if (!folder || !filename) {
      return res.status(400).send({ message: 'Invalid folder or filename' });
    }

    if (!Number.isInteger(totalChunks) || totalChunks <= 0) {
      return res.status(400).send({ message: 'Invalid totalChunks' });
    }

    if (!Number.isFinite(size) || size < 0) {
      return res.status(400).send({ message: 'Invalid file size' });
    }

    const sessionId = uuid();
    const chunksDir = getSessionChunksDir(sessionId);
    fs.mkdirSync(chunksDir, { recursive: true });

    const now = new Date().toISOString();
    const session = {
      id: sessionId,
      userId: req.currentUser.id,
      folder,
      filename,
      totalChunks,
      size,
      contentType,
      uploadedChunks: [],
      status: 'active',
      createdAt: now,
      updatedAt: now,
    };

    writeSessionMeta(sessionId, session);

    return res.status(200).send({
      sessionId,
      uploadedChunks: [],
      totalChunks,
    });
  } catch (error) {
    console.error('Failed to initialize upload session', error);
    return res.status(500).send({ message: 'Failed to initialize upload session' });
  }
}

const getUploadSession = async (req, res) => {
  try {
    if (!req.currentUser || !req.currentUser.id) {
      return res.sendStatus(403);
    }

    const sessionId = String(req.params.sessionId || '');
    const session = readSessionMeta(sessionId);

    if (!session) {
      return res.status(404).send({ message: 'Upload session not found' });
    }

    if (session.userId !== req.currentUser.id) {
      return res.sendStatus(403);
    }

    return res.status(200).send({
      sessionId: session.id,
      totalChunks: session.totalChunks,
      uploadedChunks: session.uploadedChunks || [],
      status: session.status,
    });
  } catch (error) {
    console.error('Failed to get upload session', error);
    return res.status(500).send({ message: 'Failed to get upload session' });
  }
}

const uploadChunk = async (req, res) => {
  try {
    if (!req.currentUser || !req.currentUser.id) {
      return res.sendStatus(403);
    }

    const sessionId = String(req.params.sessionId || '');
    const chunkIndex = Number(req.params.chunkIndex);

    if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
      return res.status(400).send({ message: 'Invalid chunk index' });
    }

    const session = readSessionMeta(sessionId);

    if (!session) {
      return res.status(404).send({ message: 'Upload session not found' });
    }

    if (session.userId !== req.currentUser.id) {
      return res.sendStatus(403);
    }

    if (chunkIndex >= Number(session.totalChunks)) {
      return res.status(400).send({ message: 'Chunk index is out of range' });
    }

    const chunkDir = getSessionChunksDir(sessionId);
    fs.mkdirSync(chunkDir, { recursive: true });

    const chunkPath = getSessionChunkPath(sessionId, chunkIndex);
    const tempChunkPath = `${chunkPath}.tmp`;

    if (fs.existsSync(tempChunkPath)) {
      fs.unlinkSync(tempChunkPath);
    }

    await pipeline(req, fs.createWriteStream(tempChunkPath));
    fs.renameSync(tempChunkPath, chunkPath);

    const uploadedChunks = Array.from(
      new Set([...(session.uploadedChunks || []), chunkIndex]),
    ).sort((a, b) => a - b);

    session.uploadedChunks = uploadedChunks;
    session.updatedAt = new Date().toISOString();

    writeSessionMeta(sessionId, session);

    return res.status(200).send({
      sessionId,
      chunkIndex,
      uploadedChunks: uploadedChunks.length,
      totalChunks: session.totalChunks,
    });
  } catch (error) {
    console.error('Failed to upload chunk', error);
    return res.status(500).send({ message: 'Failed to upload chunk' });
  }
}

const finalizeUploadSession = async (req, res) => {
  try {
    if (!req.currentUser || !req.currentUser.id) {
      return res.sendStatus(403);
    }

    const sessionId = String(req.params.sessionId || '');
    const session = readSessionMeta(sessionId);

    if (!session) {
      return res.status(404).send({ message: 'Upload session not found' });
    }

    if (session.userId !== req.currentUser.id) {
      return res.sendStatus(403);
    }

    const totalChunks = Number(session.totalChunks);

    for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
      const chunkPath = getSessionChunkPath(sessionId, chunkIndex);
      if (!fs.existsSync(chunkPath)) {
        return res.status(400).send({
          message: `Missing chunk ${chunkIndex}`,
          missingChunk: chunkIndex,
        });
      }
    }

    const assembledPath = path.join(getSessionDir(sessionId), 'assembled.bin');

    if (fs.existsSync(assembledPath)) {
      fs.unlinkSync(assembledPath);
    }

    for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
      const chunkPath = getSessionChunkPath(sessionId, chunkIndex);
      await streamAppendFile(assembledPath, chunkPath);
    }

    const assembledStats = fs.statSync(assembledPath);
    if (Number.isFinite(Number(session.size)) && Number(session.size) !== assembledStats.size) {
      return res.status(400).send({
        message: 'Assembled file size mismatch',
      });
    }

    const privateUrl = `${session.folder}/${session.filename}`;
    const provider = getFileStorageProvider();
    let publicUrl = '';

    if (provider === 's3') {
      publicUrl = await uploadStreamToS3(privateUrl, assembledPath, session.contentType);
    } else if (provider === 'gcloud') {
      publicUrl = await uploadStreamToGCloud(privateUrl, assembledPath);
    } else {
      const destinationPath = path.join(config.uploadDir, privateUrl);
      ensureDirectoryExistence(destinationPath);
      fs.renameSync(assembledPath, destinationPath);
      publicUrl = `/file/download?privateUrl=${encodeURIComponent(privateUrl)}`;
    }

    removeUploadSession(sessionId);

    return res.status(200).send({
      message: `Uploaded the file successfully: ${privateUrl}`,
      privateUrl,
      url: publicUrl,
    });
  } catch (error) {
    console.error('Failed to finalize upload session', error);
    return res.status(500).send({ message: 'Failed to finalize upload session' });
  }
}

const uploadFile = async (folder, req, res) => {
  const provider = getFileStorageProvider();

  if (provider === 's3') {
    return uploadS3(folder, req, res);
  }

  if (provider === 'gcloud') {
    return uploadGCloud(folder, req, res);
  }

  return uploadLocal(folder, {
    entity: null,
    folderIncludesAuthenticationUid: false,
  })(req, res);
}

const downloadFile = async (req, res) => {
  const provider = getFileStorageProvider();

  if (provider === 's3') {
    return downloadS3(req, res);
  }

  if (provider === 'gcloud') {
    return downloadGCloud(req, res);
  }

  return downloadLocal(req, res);
}

const deleteFile = async (privateUrl) => {
  const provider = getFileStorageProvider();

  if (provider === 's3') {
    return deleteS3(privateUrl);
  }

  if (provider === 'gcloud') {
    return deleteGCloud(privateUrl);
  }

  return deleteLocal(privateUrl);
}

module.exports = {
  initUploadSession,
  getUploadSession,
  uploadChunk,
  finalizeUploadSession,
  initGCloud,
  initS3,
  getFileStorageProvider,
  uploadFile,
  downloadFile,
  deleteFile,
  uploadLocal,
  downloadLocal,
  deleteLocal,
  deleteGCloud,
  deleteS3,
  uploadGCloud,
  downloadGCloud,
  uploadS3,
  downloadS3,
}
