import config from "../config.ts";
import {
  S3Client,
  GetObjectCommand,
  HeadObjectCommand,
  NotFound,
} from "@aws-sdk/client-s3";
import { v4 as uuidv4 } from "uuid";
import sharp from "sharp";
import axios from "axios";
import fs from "node:fs";
import fsPromises from "node:fs/promises";
import path from "node:path";
import { Readable } from "stream";
import { getDetails } from "./bufferingService.ts";
import { uuidLockEcecutor } from "../execHelper.ts";
import {
  cacheMap,
  lruMap,
  completedImageURLs,
  submittedCalendarIds,
  calendarIdCountMap,
} from "../state.ts";

const localCacheDir = config.assetsMapping.localCacheDir;
const maxCacheSize = config.cache.maxSize;

const s3 = new S3Client({
  region: config.aws.region,
  credentials: {
    accessKeyId: config.aws.accessKeyId,
    secretAccessKey: config.aws.secretAccessKey,
  },
});

export async function download(url: string, localPath: string): Promise<void> {
  try {
    console.log(`Attempting to download from URL: ${url}`);

    // 1. Parse the S3 URL to get the bucket and key.
    const urlObj = new URL(url);
    // The bucket name is typically the first part of the hostname, e.g., 'pixika5-live'
    const bucket = urlObj.hostname.split(".")[0];
    // The key is the path without the leading slash.
    const key = urlObj.pathname.substring(1);

    console.log(`Parsed URL -> Bucket: '${bucket}', Key: '${key}'`);

    // 2. Create the GetObjectCommand with the extracted bucket and key.
    const getObjectCommand = new GetObjectCommand({
      Bucket: bucket,
      Key: key,
    });

    // 3. Send the command to the S3 client to get the object.
    const response = await s3.send(getObjectCommand);

    if (!response.Body) {
      throw new Error("S3 response body is empty.");
    }

    // 4. Create a write stream for the local file.
    const fileWriteStream = fs.createWriteStream(localPath);

    // 5. Pipe the S3 response stream (Readable) to the file write stream.
    // This is an efficient way to handle large files without loading them into memory.
    await new Promise<void>((resolve, reject) => {
      (response.Body as Readable)
        .pipe(fileWriteStream)
        .on("finish", () => {
          console.log(`Successfully downloaded file to: ${localPath}`);
          resolve();
        })
        .on("error", (err) => {
          fileWriteStream.close();
          reject(
            new Error(`Failed to write file to ${localPath}: ${err.message}`)
          );
        });
    });
  } catch (error) {
    console.error(`An error occurred during download: ${error}`);
    // Rethrow the error to be handled by the caller.
    throw error;
  }
}

/**
 * Resizes an image file, preserving its aspect ratio, and overwrites the original file.
 * The longest side of the resized image will be equal to the specified maxSideLength.
 * @param filePath The absolute or relative path to the image file.
 * @param maxSideLength The desired maximum length of the longest side (in pixels).
 */
async function resize(filePath: string, maxSideLength: number): Promise<void> {
  try {
    if (typeof maxSideLength !== "number" || maxSideLength <= 0) {
      console.error("Error: maxSideLength must be a positive number.");
      return;
    }

    // Check if the file exists before attempting to read it
    try {
      await fsPromises.access(filePath, fs.constants.F_OK);
    } catch (error) {
      console.error(`Error: File not found at path "${filePath}".`);
      return;
    }

    const image = sharp(filePath);
    const metadata = await image.metadata();

    if (!metadata.width || !metadata.height) {
      console.error(
        `Error: Could not get dimensions for image at "${filePath}".`
      );
      return;
    }

    const { width, height } = metadata;
    let newWidth: number;
    let newHeight: number;

    // Calculate new dimensions while preserving aspect ratio
    if (width > height) {
      newWidth = maxSideLength;
      newHeight = Math.round(height * (maxSideLength / width));
    } else {
      newHeight = maxSideLength;
      newWidth = Math.round(width * (maxSideLength / height));
    }

    // Resize the image to a buffer, then overwrite the original file
    const resizedBuffer = await image.resize(newWidth, newHeight).toBuffer();

    await fsPromises.writeFile(filePath, resizedBuffer, { encoding: "binary" });

    console.log(
      `Successfully resized image at "${filePath}" to ${newWidth}x${newHeight}.`
    );
  } catch (error) {
    console.error(`An error occurred during image resizing: ${error}`);
  }
}

async function preprocess(filePath: string, maxSideLength: number) {
  console.log(`Preprocessing image at "${filePath}"`);
  const startTime = Date.now();
  await uuidLockEcecutor(`./preprocess-images.sh ${maxSideLength} ${filePath}`);
  const endTime = Date.now();
  console.log(
    `Preprocessed image at "${filePath}" in ${endTime - startTime}ms`
  );
}

export async function evictCache() {
  // read all files in assets dir
  // then sort them by the timestamp in lruMap (assume 0 for those not in cache)
  // evict files to ensure that the dir size is within limits (loop delete lru until size is within limits)
  try {
    // Gather only files that this cache created and manages.
    // Safety: never touch files outside `cacheMap` to avoid deleting non-cache assets.
    const cachedFilePaths = new Set<string>(cacheMap.values());

    // If there are no cached files tracked, nothing to evict.
    if (cachedFilePaths.size === 0) return;

    // Read directory entries but consider only files present in `cacheMap`.
    const dirEntries = await fsPromises
      .readdir(localCacheDir, { withFileTypes: true })
      .catch(() => []);

    // Collect file info (path and size) for files the cache owns and that still exist.
    const fileInfos = (
      await Promise.all(
        dirEntries
          .filter((entry) => entry.isFile())
          .map(async (entry) => {
            const filePath = path.join(localCacheDir, entry.name);
            if (!cachedFilePaths.has(filePath)) return null; // skip non-cache files
            try {
              const stats = await fsPromises.stat(filePath);
              return { filePath, size: stats.size };
            } catch {
              return null;
            }
          })
      )
    ).filter((x): x is { filePath: string; size: number } => x !== null);

    // Clean up any cacheMap entries whose files no longer exist on disk.
    for (const [cacheKey, filePath] of [...cacheMap.entries()]) {
      try {
        await fsPromises.access(filePath, fs.constants.F_OK);
      } catch {
        cacheMap.delete(cacheKey);
        lruMap.delete(cacheKey);
      }
    }

    let totalSize = fileInfos.reduce((sum, f) => sum + f.size, 0);
    if (totalSize <= maxCacheSize) return;

    // Build reverse index from file path -> cache key (url)
    const filePathToCacheKey = new Map<string, string>();
    for (const [cacheKey, cachedPath] of cacheMap.entries()) {
      filePathToCacheKey.set(cachedPath, cacheKey);
    }

    // Prepare eviction candidates sorted by last used time (oldest first)
    const evictionCandidates = fileInfos
      .map((info) => {
        const cacheKey = filePathToCacheKey.get(info.filePath);
        const lastUsed = cacheKey ? lruMap.get(cacheKey) ?? 0 : 0;
        return { ...info, cacheKey, lastUsed };
      })
      .sort((a, b) => a.lastUsed - b.lastUsed);

    for (const candidate of evictionCandidates) {
      if (totalSize <= maxCacheSize) break;
      // Extra safety: only delete files that we explicitly manage
      if (!candidate.cacheKey) continue;
      try {
        await fsPromises.unlink(candidate.filePath);
        totalSize -= candidate.size;
        cacheMap.delete(candidate.cacheKey);
        lruMap.delete(candidate.cacheKey);
      } catch {
        // If deletion fails, skip and continue with others
        continue;
      }
    }
  } catch (error) {
    console.error("Failed to evict cache:", error);
  }
}

export async function getAssetPath(url: string): Promise<string> {
  const cacheKey = url;
  lruMap.set(cacheKey, Date.now());

  let filePath: string;
  if (cacheMap.has(cacheKey)) {
    filePath = cacheMap.get(cacheKey)!;
  } else {
    // not found in cache, download from s3 and preprocess
    filePath = `${localCacheDir}/${uuidv4()}.${url.split(".").pop()}`;
    await download(url, filePath);
    await preprocess(filePath, config.preprocessing.maxSideLength);
    // change the extension to png as the preprocess script converts to png
    cacheMap.set(
      cacheKey,
      filePath.split(".").reverse().splice(1).reverse().join(".") + ".png"
    );
  }

  lruMap.set(cacheKey, Date.now());
  // Fire-and-forget cache eviction
  void evictCache();
  return filePath;
}

export function getResultingURL(
  sourceImageURL: string,
  targetImageURL: string
): string {
  const suffix = targetImageURL
    ?.split("/faceswap/")
    .at(-1)
    ?.split(".")
    .slice(0, -1)
    ?.at(0)
    ?.split("/")
    ?.join("-");

  if (!suffix) throw new Error("Invalid target image URL");

  const newURL =
    sourceImageURL.split(".").slice(0, -1).join(".") + `-${suffix}.png`;
  return newURL;
}

/**
 * Checks if a given URL is a valid S3 URL and if the corresponding object exists in the bucket.
 * This function uses the S3 HeadObjectCommand to efficiently check for the object's existence
 * without downloading the content.
 *
 * @param url The S3 URL to validate.
 * @returns A promise that resolves to `true` if the URL is valid and the object exists, otherwise `false`.
 */
async function isValidS3URL(url: string): Promise<boolean> {
  try {
    // 1. Parse the URL to get the bucket and key.
    const urlObject = new URL(url);
    // The bucket name is typically the first part of the hostname,
    // e.g., "pixika5-live" in "pixika5-live.s3.ap-south-1.amazonaws.com"
    const bucket = urlObject.hostname.split(".")[0];
    // The key is the pathname, e.g., "/assets/calendar/faceswap/superheros/male/square/4.png",
    // with the leading slash removed.
    const key = urlObject.pathname.substring(1);

    // 2. Use the S3 HeadObjectCommand to check if the object exists.
    // This command only retrieves metadata, making it a very lightweight check.
    const command = new HeadObjectCommand({
      Bucket: bucket,
      Key: key,
    });

    await s3.send(command);

    // If the command succeeds, the object exists.
    return true;
  } catch (error) {
    // 3. Handle potential errors.
    // If the object does not exist, the SDK will throw a NotFound error.
    if (error instanceof NotFound) {
      return false;
    }

    // Handle other errors, such as invalid URL format or permission issues.
    console.error(
      `An error occurred while validating the URL '${url}':`,
      error
    );
    return false;
  }
}

export const markImageAsCompleted = async (
  _calendarId: string,
  url: string
) => {
  if (!completedImageURLs.has(url)) {
    completedImageURLs.add(url);
  }
};

export const isImageCompleted = async (_calendarId: string, url: string) => {
  if (completedImageURLs.has(url)) return true;

  const exists = await isValidS3URL(url);
  if (exists) {
    if (!completedImageURLs.has(url)) completedImageURLs.add(url);
  }
  return exists;
};

export const isCalendarCompleted = async (calendarId: string) => {
  if (submittedCalendarIds.has(calendarId)) return true;

  const details = await getDetails(calendarId);
  const url = details.faceImageURL;

  const found = completedImageURLs.has(details.faceImageURL);

  if (found) return true;

  const count = calendarIdCountMap.get(calendarId) || 12;
  const resultURLS: string[] = [];
  for (let i = 1; i <= count; i++) {
    resultURLS.push(getResultingURL(url, details[`ef${i}`]));
  }

  // const resultURLS = [
  //   getResultingURL(url, details.ef1),
  //   getResultingURL(url, details.ef2),
  //   getResultingURL(url, details.ef3),
  //   getResultingURL(url, details.ef4),
  //   getResultingURL(url, details.ef5),
  //   getResultingURL(url, details.ef6),
  //   getResultingURL(url, details.ef7),
  //   getResultingURL(url, details.ef8),
  //   getResultingURL(url, details.ef9),
  //   getResultingURL(url, details.ef10),
  //   getResultingURL(url, details.ef11),
  //   getResultingURL(url, details.ef12),
  // ];

  // check if all the images exist in s3

  const isValidArray = await Promise.allSettled(
    resultURLS.map((url) => isImageCompleted(calendarId, url))
  );

  // if any of them failed or the result is false, return false
  for (const result of isValidArray) {
    if (result.status !== "fulfilled" || !result.value) {
      return false;
    }
  }

  // at this point, all the images exist in s3
  return true;
};
