"use strict";

const path = require("path");
const fs = require("fs-extra");
const http = require("http");
const https = require("https");
const { URL } = require("url");
const AdmZip = require("adm-zip");
const semver = require("semver");
const crypto = require("crypto");

const APP_ROOT = path.resolve(__dirname, "..");
const UPDATE_DIR = path.join(APP_ROOT, ".updates");
const DOWNLOADS_DIR = path.join(UPDATE_DIR, "downloads");
const EXTRACT_DIR = path.join(UPDATE_DIR, "extracted");
const STATE_FILE = path.join(UPDATE_DIR, "state.json");

const DEFAULT_BASE_URL = "https://pinetwork.deploy.ecoquimic.com";

function sanitizeBaseUrl(url) {
  if (!url) {
    return DEFAULT_BASE_URL;
  }
  return url.endsWith("/") ? url.slice(0, -1) : url;
}

function requestJson(urlString, timeout = 10000) {
  const target = new URL(urlString);
  const client = target.protocol === "https:" ? https : http;

  return new Promise((resolve, reject) => {
    const req = client.request(
      target,
      {
        method: "GET",
        headers: {
          "User-Agent": "pi-network-updater/1.0",
          "Cache-Control": "no-cache",
        },
        timeout,
      },
      (res) => {
        if (
          res.statusCode &&
          res.statusCode >= 300 &&
          res.statusCode < 400 &&
          res.headers.location
        ) {
          const redirected = new URL(res.headers.location, target);
          res.resume();
          requestJson(redirected.toString(), timeout).then(resolve, reject);
          return;
        }

        if (res.statusCode !== 200) {
          reject(
            new Error(
              `Unexpected status ${res.statusCode} while fetching ${target}`
            )
          );
          res.resume();
          return;
        }

        let data = "";
        res.setEncoding("utf8");
        res.on("data", (chunk) => (data += chunk));
        res.on("end", () => {
          try {
            resolve(JSON.parse(data));
          } catch (error) {
            reject(
              new Error(
                `Unable to parse JSON from ${target}: ${error.message}`
              )
            );
          }
        });
      }
    );

    req.on("error", reject);
    req.on("timeout", () => {
      req.destroy(new Error(`Request to ${target} timed out after ${timeout}ms`));
    });
    req.end();
  });
}

async function downloadFile(urlString, destination, timeout = 15000) {
  const target = new URL(urlString);
  const client = target.protocol === "https:" ? https : http;

  await fs.ensureDir(path.dirname(destination));

  return new Promise((resolve, reject) => {
    const req = client.request(
      target,
      {
        method: "GET",
        headers: { "User-Agent": "pi-network-updater/1.0" },
        timeout,
      },
      (res) => {
        if (
          res.statusCode &&
          res.statusCode >= 300 &&
          res.statusCode < 400 &&
          res.headers.location
        ) {
          const redirected = new URL(res.headers.location, target);
          res.resume();
          downloadFile(redirected.toString(), destination, timeout).then(
            resolve,
            reject
          );
          return;
        }

        if (res.statusCode !== 200) {
          reject(
            new Error(
              `Unexpected status ${res.statusCode} while downloading ${target}`
            )
          );
          res.resume();
          return;
        }

        const fileStream = fs.createWriteStream(destination);
        res.pipe(fileStream);
        fileStream.on("finish", () => {
          fileStream.close(resolve);
        });
        fileStream.on("error", reject);
      }
    );

    req.on("error", reject);
    req.on("timeout", () => {
      req.destroy(new Error(`Request to ${target} timed out after ${timeout}ms`));
    });
    req.end();
  });
}

async function verifyChecksum(filePath, expected) {
  if (!expected) {
    return;
  }

  const [algorithm, value] = expected.split("-");
  if (algorithm !== "sha256" || !value) {
    console.warn(
      `[update] Unsupported checksum format "${expected}", skipping verification`
    );
    return;
  }

  const hash = crypto.createHash("sha256");
  const stream = fs.createReadStream(filePath);

  await new Promise((resolve, reject) => {
    stream.on("error", reject);
    stream.on("data", (chunk) => hash.update(chunk));
    stream.on("end", resolve);
  });

  const digest = hash.digest("hex");
  if (digest !== value) {
    throw new Error(
      `Checksum mismatch for ${path.basename(filePath)}. Expected ${value}, received ${digest}`
    );
  }
}

async function extractZip(zipPath, destination) {
  await fs.remove(destination);
  await fs.ensureDir(destination);
  const zip = new AdmZip(zipPath);
  zip.extractAllTo(destination, true);
}

async function readState() {
  try {
    const state = await fs.readJson(STATE_FILE);
    return state;
  } catch {
    return null;
  }
}

async function writeState(state) {
  await fs.ensureDir(UPDATE_DIR);
  await fs.writeJson(STATE_FILE, state, { spaces: 2 });
}

async function applyUpdate(extractedDir, manifest) {
  if (!manifest || !Array.isArray(manifest.files)) {
    throw new Error("Invalid update manifest format.");
  }

  const directories = manifest.files
    .filter((entry) => entry.type === "directory")
    .sort((a, b) => b.path.length - a.path.length);
  const files = manifest.files
    .filter((entry) => entry.type !== "directory")
    .sort((a, b) => b.path.length - a.path.length);

  const copyOptions = { overwrite: true, errorOnExist: false };

  for (const dir of directories) {
    const source = path.join(extractedDir, dir.path);
    const target = path.join(APP_ROOT, dir.path);
    await fs.remove(target);
    await fs.copy(source, target, copyOptions);
  }

  for (const file of files) {
    const source = path.join(extractedDir, file.path);
    const target = path.join(APP_ROOT, file.path);
    await fs.remove(target);
    await fs.ensureDir(path.dirname(target));
    await fs.copy(source, target, copyOptions);
  }
}

async function ensureLatest() {
  const baseUrl = sanitizeBaseUrl(
    process.env.PI_NETWORK_UPDATE_BASE_URL || process.env.UPDATE_BASE_URL
  );
  const manifestUrl = `${baseUrl}/version.json`;

  await fs.ensureDir(UPDATE_DIR);

  const pkg = await fs.readJson(path.join(APP_ROOT, "package.json"));
  const localVersion = pkg.version;

  let remoteManifest;
  try {
    remoteManifest = await requestJson(manifestUrl);
  } catch (error) {
    console.warn(`[update] Unable to fetch remote manifest: ${error.message}`);
    return {
      updated: false,
      reason: "manifest-fetch-failed",
    };
  }

  if (!remoteManifest || !remoteManifest.version) {
    console.warn("[update] Remote manifest missing version property.");
    return { updated: false, reason: "invalid-manifest" };
  }

  const remoteVersion = remoteManifest.version;
  const downloadUrl = new URL(
    remoteManifest.downloadUrl || "",
    manifestUrl
  ).toString();

  if (!downloadUrl) {
    console.warn("[update] Remote manifest missing downloadUrl.");
    return { updated: false, reason: "missing-download-url" };
  }

  const state = await readState();
  if (
    state &&
    state.version === remoteVersion &&
    !semver.gt(remoteVersion, localVersion)
  ) {
    return { updated: false, reason: "already-current" };
  }

  if (!semver.valid(remoteVersion)) {
    console.warn(
      `[update] Remote version "${remoteVersion}" is not a valid semver string.`
    );
  } else if (!semver.gt(remoteVersion, localVersion)) {
    return { updated: false, reason: "up-to-date" };
  }

  const zipPath = path.join(
    DOWNLOADS_DIR,
    `${remoteVersion.replace(/\//g, "_")}.zip`
  );

  try {
    await downloadFile(downloadUrl, zipPath);
    await verifyChecksum(zipPath, remoteManifest.checksum);
  } catch (error) {
    console.warn(`[update] Failed downloading update: ${error.message}`);
    return { updated: false, reason: "download-failed" };
  }

  try {
    await extractZip(zipPath, EXTRACT_DIR);
  } catch (error) {
    console.warn(`[update] Unable to extract update archive: ${error.message}`);
    return { updated: false, reason: "extract-failed" };
  }

  let updateManifest;
  try {
    updateManifest = await fs.readJson(
      path.join(EXTRACT_DIR, "update-manifest.json")
    );
  } catch (error) {
    console.warn(
      `[update] Missing update-manifest.json in archive: ${error.message}`
    );
    return { updated: false, reason: "missing-update-manifest" };
  }

  try {
    await applyUpdate(EXTRACT_DIR, updateManifest);
  } catch (error) {
    console.warn(`[update] Failed while applying update: ${error.message}`);
    return { updated: false, reason: "apply-failed" };
  }

  await writeState({
    version: updateManifest.version || remoteVersion,
    checksum: remoteManifest.checksum || null,
    downloadedFrom: downloadUrl,
    appliedAt: new Date().toISOString(),
  });

  // Clean temporary data once the update is applied successfully.
  await fs.remove(zipPath).catch(() => {});
  await fs.remove(EXTRACT_DIR).catch(() => {});

  return {
    updated: true,
    requiresInstall:
      updateManifest.requiresInstall !== undefined
        ? Boolean(updateManifest.requiresInstall)
        : true,
    targetVersion: updateManifest.version || remoteVersion,
  };
}

module.exports = {
  ensureLatest,
};
