import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { defaultArchivePath } from "./asset-inliner.mjs";

const INDEX_FILE = ".archive-index.json";
const INDEX_VERSION = 1;
const COMMENT_RE = /<!--\s*Archived locally\. Source: ([\s\S]*?)\. Created: ([^.]*(?:\.[0-9]+)?Z)\.\s*-->/;

export function normalizeArchiveUrl(rawUrl) {
  const text = String(rawUrl || "").trim();
  if (!text) {
    throw new Error("URL is required");
  }

  let url;
  try {
    url = new URL(text);
  } catch {
    throw new Error("Enter a valid URL");
  }

  if (url.protocol !== "http:" && url.protocol !== "https:") {
    throw new Error("Only http and https URLs can be archived");
  }

  return url.href;
}

export function archiveIdForUrl(sourceUrl) {
  const url = new URL(normalizeArchiveUrl(sourceUrl));
  const stem =
    `${url.hostname}${url.pathname}`
      .replace(/\/+$/, "")
      .replace(/[^a-z0-9]+/gi, "-")
      .replace(/^-+|-+$/g, "")
      .slice(0, 72) || "archive";
  const digest = crypto.createHash("sha256").update(url.href).digest("hex").slice(0, 16);
  return `${stem}-${digest}`;
}

export function archiveFileNameForUrl(sourceUrl) {
  return `${archiveIdForUrl(sourceUrl)}.html`;
}

export class ArchiveCatalog {
  constructor(options = {}) {
    this.archivePath = path.resolve(options.archivePath || defaultArchivePath());
    this.publicBasePath = options.publicBasePath || "/archives";
    this.indexPath = path.join(this.archivePath, INDEX_FILE);
    this.index = {
      version: INDEX_VERSION,
      archives: {}
    };
    this.loadPromise = null;
    this.savePromise = Promise.resolve();
  }

  async findByUrl(rawUrl) {
    const sourceUrl = normalizeArchiveUrl(rawUrl);
    await this.ensureLoaded();

    const indexed = this.index.archives[sourceUrl];
    if (indexed && await this.hasArchiveFile(indexed.fileName)) {
      return this.toPublicRecord(indexed);
    }

    if (indexed) {
      delete this.index.archives[sourceUrl];
      await this.saveIndex();
    }

    const stableFileName = archiveFileNameForUrl(sourceUrl);
    if (await this.hasArchiveFile(stableFileName)) {
      const record = this.upsertRecord(sourceUrl, {
        id: path.basename(stableFileName, ".html"),
        fileName: stableFileName
      });
      await this.saveIndex();
      return this.toPublicRecord(record);
    }

    return null;
  }

  async recordResult(rawUrl, result) {
    const sourceUrl = normalizeArchiveUrl(rawUrl);
    await this.ensureLoaded();

    const fileName = path.basename(result.filePath || `${result.id}.html`);
    const id = result.id || path.basename(fileName, ".html");
    const record = this.upsertRecord(sourceUrl, {
      id,
      fileName,
      warningsCount: Array.isArray(result.warnings) ? result.warnings.length : 0,
      externalAssetsCount: Array.isArray(result.externalAssets) ? result.externalAssets.length : 0
    });
    await this.saveIndex();
    return this.toPublicRecord(record);
  }

  async ensureLoaded() {
    if (!this.loadPromise) {
      this.loadPromise = this.loadIndex();
    }
    await this.loadPromise;
  }

  async loadIndex() {
    await fs.mkdir(this.archivePath, { recursive: true });
    try {
      const data = JSON.parse(await fs.readFile(this.indexPath, "utf8"));
      if (data && data.version === INDEX_VERSION && data.archives && typeof data.archives === "object") {
        this.index = data;
      }
    } catch (error) {
      if (error.code !== "ENOENT") {
        throw error;
      }
    }

    if (await this.scanArchiveFiles()) {
      await this.saveIndex();
    }
  }

  async scanArchiveFiles() {
    let changed = false;
    const entries = await fs.readdir(this.archivePath, { withFileTypes: true }).catch((error) => {
      if (error.code === "ENOENT") {
        return [];
      }
      throw error;
    });

    for (const entry of entries) {
      if (!entry.isFile() || !entry.name.endsWith(".html")) {
        continue;
      }

      const filePath = path.join(this.archivePath, entry.name);
      const metadata = await readArchiveMetadata(filePath);
      if (!metadata?.sourceUrl) {
        continue;
      }

      let sourceUrl;
      try {
        sourceUrl = normalizeArchiveUrl(metadata.sourceUrl);
      } catch {
        continue;
      }
      const current = this.index.archives[sourceUrl];
      if (current?.fileName === entry.name) {
        continue;
      }

      this.index.archives[sourceUrl] = {
        id: path.basename(entry.name, ".html"),
        fileName: entry.name,
        sourceUrl,
        createdAt: metadata.createdAt || new Date().toISOString(),
        updatedAt: new Date().toISOString()
      };
      changed = true;
    }

    for (const [sourceUrl, record] of Object.entries(this.index.archives)) {
      if (!record?.fileName || !await this.hasArchiveFile(record.fileName)) {
        delete this.index.archives[sourceUrl];
        changed = true;
      }
    }

    return changed;
  }

  upsertRecord(sourceUrl, values) {
    const previous = this.index.archives[sourceUrl];
    const now = new Date().toISOString();
    const record = {
      id: values.id,
      fileName: values.fileName,
      sourceUrl,
      createdAt: previous?.createdAt || now,
      updatedAt: now,
      warningsCount: values.warningsCount ?? previous?.warningsCount ?? 0,
      externalAssetsCount: values.externalAssetsCount ?? previous?.externalAssetsCount ?? 0
    };
    this.index.archives[sourceUrl] = record;
    return record;
  }

  async hasArchiveFile(fileName) {
    if (!isSafeArchiveFileName(fileName)) {
      return false;
    }
    const stat = await fs.stat(path.join(this.archivePath, fileName)).catch(() => null);
    return !!stat?.isFile();
  }

  toPublicRecord(record) {
    return {
      ...record,
      archiveUrl: `${this.publicBasePath}/${encodeURIComponent(record.fileName)}`
    };
  }

  async saveIndex() {
    this.savePromise = this.savePromise.then(async () => {
      await fs.mkdir(this.archivePath, { recursive: true });
      const tmpPath = `${this.indexPath}.${process.pid}.tmp`;
      await fs.writeFile(tmpPath, `${JSON.stringify(this.index, null, 2)}\n`, "utf8");
      await fs.rename(tmpPath, this.indexPath);
    });
    return this.savePromise;
  }
}

export function isSafeArchiveFileName(fileName) {
  return (
    typeof fileName === "string" &&
    fileName === path.basename(fileName) &&
    fileName.endsWith(".html") &&
    !fileName.startsWith(".")
  );
}

async function readArchiveMetadata(filePath) {
  const handle = await fs.open(filePath, "r").catch(() => null);
  if (!handle) {
    return null;
  }

  try {
    const buffer = Buffer.alloc(4096);
    const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
    const head = buffer.subarray(0, bytesRead).toString("utf8");
    const match = head.match(COMMENT_RE);
    if (!match) {
      return null;
    }
    return {
      sourceUrl: match[1].replaceAll("- -", "--"),
      createdAt: match[2]
    };
  } finally {
    await handle.close();
  }
}
