const { DeleteObjectCommand, PutObjectCommand, S3Client } = require('@aws-sdk/client-s3');
const cors = require('cors');
const crypto = require('crypto');
const didJwt = require('did-jwt');
const { Resolver } = require('did-resolver');
const express = require('express');
const { getResolver } = require('ethr-did-resolver');
const fs = require('fs');
const { DateTime } = require('luxon');
const multer = require('multer');
const path = require('path');
const sqlite3 = require('sqlite3').verbose();

require('dotenv').config()

const app = express();
app.use(cors());

const port = process.env.PORT || 3001;
// file name also referenced in flyway.conf and potentially in .env files or in environment variables
const dbFile = process.env.SQLITE_FILE || './image-db.sqlite';
const bucketName = process.env.S3_BUCKET_NAME || 'gifts-image-test';
const imageServer = process.env.DOWNLOAD_IMAGE_SERVER || 'test-image.timesafari.app';

const ethrDidResolver = getResolver;
const resolver =
  new Resolver({
    ...ethrDidResolver({
      infuraProjectId: process.env.INFURA_PROJECT_ID || 'fake-infura-project-id'
    })
  })

// Open a connection to the SQLite database
const db = new sqlite3.Database(dbFile, (err) => {
  if (err) {
    console.error('Error opening database:', err);
  }
});

const endorserApiUrl = process.env.ENDORSER_API_URL || 'https://test-api.endorser.ch';

// Configure AWS
const s3Client = new S3Client({
  endpoint: 'https://' + process.env.S3_ENDPOINT_SERVER,
  region: process.env.S3_REGION,
  forcePathStyle: true,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY,
    secretAccessKey: process.env.S3_SECRET_KEY
  }
});

const uploadDir = 'uploads';
const uploadMulter = multer({ dest: uploadDir + '/' });

app.get('/ping', async (req, res) => {
    res.send('pong v1.0.0');
});

app.get('/image-limits', async (req, res) => {
  limitsResult = await retrievelimits(req, res);
  if (!limitsResult.success) {
    return limitsResult.result;
  }
  return res.status(200).send(JSON.stringify({
    success: true,
    doneImagesThisWeek: limitsResult.doneImagesThisWeek,
    maxImagesPerWeek: limitsResult.maxImagesPerWeek,
    nextWeekBeginDateTime: limitsResult.nextWeekBeginDateTime
  }));
});

// POST endpoint to upload an image
app.post('/image', uploadMulter.single('image'), async (req, res) => {
  const reqFile = req.file;
  if (reqFile == null) {
    return res.status(400).send(JSON.stringify({ success: false, message: 'No file uploaded.' }));
  }
  if (reqFile.size > 10000000) {
    fs.rm(reqFile.path, (err) => {
      if (err) {
        console.error("Error deleting too-large temp file", reqFile.path, "with error (but continuing):", err);
      }
    });
    return res.status(400).send(JSON.stringify({success: false, message: 'File size is too large. Maximum file size is 10MB.'}));
  }

  try {
    limitsResult = await retrievelimits(req, res);
    if (!limitsResult.success) {
      return limitsResult.result;
    }
    const doneImagesThisWeek = limitsResult.doneImagesThisWeek;
    const maxImagesPerWeek = limitsResult.maxImagesPerWeek;
    const issuerDid = limitsResult.issuerDid;

    if (doneImagesThisWeek >= maxImagesPerWeek) {
      return res.status(400).send(JSON.stringify({ success: false, message: 'You have reached your weekly limit of ' + maxImagesPerWeek + ' images.' }));
    }

    // Read the file from the temporary location
    fs.readFile(reqFile.path, async (err, data) => {
      if (err) throw err; // Handle error

      const hashSum = crypto.createHash('sha256');
      hashSum.update(data);
      const hashHex = hashSum.digest('hex');

      const fileName = hashHex + path.extname(reqFile.originalname);

      try {

        // look to see if this image already exists
        const imageUrl = await new Promise((resolve, reject) => {
          db.get(
            'SELECT url FROM image WHERE final_file = ? and did = ?',
            [ fileName, issuerDid ],
            (dbErr, row) => {
              if (dbErr) {
                console.error(currentDate, 'Error getting image for user from database:', dbErr)
                // continue anyway
              }
              resolve(row?.url);
            }
          );
        });
        if (imageUrl) {
          return res.status(201).send(JSON.stringify({ success: true, url: imageUrl, message: 'This image already existed.' }));
        }

        // record the upload in the database
        const currentDate = new Date().toISOString();
        const localFile = reqFile.path.startsWith(uploadDir + '/') ? reqFile.path.substring(uploadDir.length + 1) : reqFile.path;
        const finalUrl = `https://${imageServer}/${fileName}`;
        const claimType = req.body.claimType;
        const handleId = req.body.handleId;
        await new Promise((resolve, reject) => {
          db.run(
            'INSERT INTO image (time, did, claim_type, handle_id, local_file, size, final_file, mime_type, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
            [
              currentDate,
              issuerDid,
              claimType,
              handleId,
              localFile,
              reqFile.size,
              fileName,
              reqFile.mimetype,
              finalUrl
            ],
            (dbErr) => {
              if (dbErr) {
                console.error(currentDate, "Error inserting record from", issuerDid, "into database:", dbErr);
                // don't continue because then we'll have storage we cannot track (and potentially limit)
                reject(dbErr);
              }
              resolve();
            }
          );
        });

        // send to S3
        const params = {
          Body: data,
          Bucket: bucketName, // S3 Bucket name
          ContentType: reqFile.mimetype, // File content type
          Key: fileName, // File name to use in S3
        };
        if (process.env.S3_SET_ACL === 'true') {
          params.ACL = 'public-read';
        }
        const command = new PutObjectCommand(params);
        const response = await s3Client.send(command);
        if (response.$metadata.httpStatusCode !== 200) {
          const errorTime = new Date().toISOString();
          console.error(errorTime, "Error uploading to S3 with bad HTTP status, with metadata:", response.$metadata);
          return res.status(500).send(JSON.stringify({
            success: false,
            message: "Got bad status of " + response.$metadata.httpStatusCode + " from S3. See server logs at " + errorTime
          }));
        } else {
          fs.rm(reqFile.path, (err) => {
            if (err) {
              console.error("Error deleting temp file", reqFile.path, "with error (but continuing):", err);
            }
          });
          // AWS URL: https://gifts-image-test.s3.amazonaws.com/gifts-image-test/FILE
          // American Cloud URL: https://a2-west.americancloud.com/TENANT:giftsimagetest/FILE
          return res.status(200).send(JSON.stringify({success: true, url: finalUrl}));
        }
      } catch (uploadError) {
        const errorTime = new Date().toISOString();
        console.error(errorTime, "Error uploading to S3:", uploadError);
        return res.status(500).send(JSON.stringify({
          success: false,
          message: "Got error uploading file. See server logs at " + errorTime + " Error Details: " + uploadError
        }));
      }
    })
  } catch (error) {
    const errorTime = new Date().toISOString();
    console.error(errorTime, "Error processing image upload:", error);
    res.status(500).send(JSON.stringify({
      success: false,
      message: "Got error processing image upload. See server logs at " + errorTime + " Error Details: " + error
    }));
  }
});

/**
 * DELETE endpoint, with 204 on successful delete
 * returns { success: true } if successful
 * returns { success: false, message: string } if not successful
 */
app.delete('/image/:url', async (req, res) => {
  try {
    const decoded = await decodeJwt(req, res)
    if (!decoded.success) {
      return decoded.result;
    }
    const issuerDid = decoded.issuerDid;

    const url = req.params.url;

    const currentDate = new Date().toISOString();

    // look for file name of this image
    const thisUserImageFile = await new Promise((resolve, reject) => {
      db.get(
        'SELECT final_file FROM image WHERE url = ? and did = ?',
        [ url, issuerDid ],
        (dbErr, row) => {
          if (dbErr) {
            console.error(currentDate, 'Error getting image for user from database:', dbErr)
            reject(dbErr);
          }
          resolve(row?.final_file);
        }
      );
    });
    if (!thisUserImageFile) {
      console.error('No image entry found for user', issuerDid, '& URL', url, 'so returning 404.');
      return res.status(404).send(JSON.stringify({ success: false, message: 'No image entry found for user ' + issuerDid + ' & URL ' + url }));
    }

    // check if any other user recorded this image
    const otherUserImage = await new Promise((resolve, reject) => {
      db.get(
        'SELECT did FROM image WHERE url = ? and did != ?',
        [ url, issuerDid ],
        (dbErr, row) => {
          if (dbErr) {
            console.error(currentDate, 'Error getting image for other users from database:', dbErr)
            reject(dbErr);
          }
          resolve(row?.did);
        }
      );
    });

    if (!otherUserImage) {
      // remove from S3 since nobody else recorded it
      const params = {
        Bucket: bucketName, // S3 Bucket name
        Key: thisUserImageFile, // File name to use in S3
      };
      const command = new DeleteObjectCommand(params);
      const response = await s3Client.send(command);
      if (response.$metadata.httpStatusCode !== 200
          && response.$metadata.httpStatusCode !== 202
          && response.$metadata.httpStatusCode !== 204) {
        const errorTime = new Date().toISOString();
        console.error(errorTime, "Error deleting from S3 with bad HTTP status, with metadata:", response.$metadata);
        return res.status(500).send(JSON.stringify({
          success: false,
          message: "Got bad status of " + response.$metadata.httpStatusCode + " from S3. See server logs at " + errorTime
        }));
      }
    }

    // now remove the DB record for requesting user
    await new Promise((resolve, reject) => {
      db.run(
        'DELETE FROM image where url = ? AND did = ?',
        [ url, issuerDid ],
        (dbErr) => {
          if (dbErr) {
            const currentDate = new Date().toISOString();
            console.error(currentDate, "Error deleting record from", issuerDid, "into database:", dbErr);
            // don't continue because then we'll have storage we cannot track (and potentially limit)
            reject(dbErr);
          }
          resolve();
        }
      );
    });
    return res.status(204).send(JSON.stringify({ success: true }));
  } catch (error) {
    const errorTime = new Date().toISOString();
    console.error(errorTime, "Error processing image delete:", error);
    return res.status(500).send(JSON.stringify({
      success: false,
      message: "Got error processing image delete. See server logs at " + errorTime + " Error Details: " + error
    }));
  }
});

async function retrievelimits(req, res) {
  const decoded = await decodeJwt(req, res)
  if (!decoded.success) {
    return decoded.result;
  }
  const issuerDid = decoded.issuerDid;
  const jwt = decoded.jwt;

  // Check the user's limits, first from the DB and then from the server
  let maxImagesPerWeek = await new Promise((resolve, reject) => {
    db.get(
      'SELECT per_week FROM user WHERE did = ?',
      [ issuerDid ],
      (dbErr, row) => {
        if (dbErr) {
          console.error('Error getting user record from database (but continuing):', dbErr)
          // may not matter, so continue
        }
        resolve(row?.per_week);
      }
    );
  });
  if (maxImagesPerWeek == null) {
    const headers = {
      'Authorization': `Bearer ${jwt}`,
      'Content-Type': 'application/json'
    }
    const response = await fetch(endorserApiUrl + '/api/report/rateLimits', { headers });
    if (response.status !== 200) {
      console.error("Got bad response of", response.status, "when checking rate limits for", issuerDid);
      return {
        success: false,
        result: res.status(400).send(JSON.stringify({ success: false, message: 'Got bad status of ' + response.status + ' when checking limits with endorser server. Verify that the account exists and that the JWT works for that server.'}))
      };
    } else {
      const body = await response.json();
      maxImagesPerWeek = body.maxClaimsPerWeek / 4; // allowing fewer images than claims

      await new Promise((resolve, reject) => {
        db.run(
          'INSERT INTO user (did, per_week) VALUES (?, ?)',
          [issuerDid, maxImagesPerWeek],
          (dbErr) => {
            if (dbErr) {
              console.error("Error inserting user record for", issuerDid, "into database (but continuing):", dbErr);
              // we can continue... it just means we'll check the endorser server again next time
            }
            resolve();
          }
        );
      });
    }
  }

  if (maxImagesPerWeek == null) {
    return {
      success: false,
      result: res.status(400).send(JSON.stringify({ success: false, message: 'Unable to determine rate limits for this user. Verify that the account exists and that the JWT works for that server.' }))
    };
  }

  // check the user's claims so far this week
  const startOfWeekDate = DateTime.utc().startOf('week') // luxon weeks start on Mondays
  const startOfWeekString = startOfWeekDate.toISO()
  const imagesCount = await new Promise((resolve, reject) => {
    db.get(
      'SELECT COUNT(*) AS week_count FROM image WHERE did = ? AND time >= ?',
      [issuerDid, startOfWeekString],
      (dbErr, row) => {
        if (dbErr) {
          console.error(currentDate, "Error counting records for", issuerDid, "into database (but continuing):", dbErr);
          // we can continue... it just means we'll check the endorser server again next time
        }
        resolve(row?.week_count);
      }
    );
  });

  const nextWeekBeginDateTime = startOfWeekDate.plus({ week: 1 }); // luxon weeks start on Mondays
  const nextWeekBeginDateTimeString = nextWeekBeginDateTime.toISO()

  return {
    success: true,
    doneImagesThisWeek: imagesCount,
    maxImagesPerWeek: maxImagesPerWeek,
    nextWeekBeginDateTime: nextWeekBeginDateTimeString,
    issuerDid: issuerDid,
  };
}

/**
 * retrieve Bearer JWT from Authorization header and return either:
 * { success: true, issuerDid: string, jwt: string }
 * ... or ...
 * { success: false, result: HTTP send result }
 */
async function decodeJwt(req, res) {
  const auth = req.headers.authorization;
  if (!auth || !auth.startsWith('Bearer ')) {
    return {
      success: false,
      result: res.status(401).send(JSON.stringify({success: false, message: 'Missing "Bearer JWT" in Authorization header.'}))
    };
  }
  const jwt = auth.substring('Bearer '.length);
  const verified = await didJwt.verifyJWT(jwt, { resolver });
  if (!verified.verified) {
    const errorTime = new Date().toISOString();
    console.error(errorTime, 'Got invalid JWT in Authorization header:', verified);
    return {
      success: false,
      result: res.status(401).send(JSON.stringify({ success: false, message: 'Got invalid JWT in Authorization header. See server logs at ' + errorTime }))
    };
  }
  return { success: true, issuerDid: verified.issuer, jwt: jwt };
}

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

// Close the database connection when the Node.js app ends
process.on('SIGINT', () => {
  db.close((err) => {
    if (err) {
      console.error('Error closing DB connection:', err);
      return;
    }
    process.exit(0);
  });
});