import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { setupGitHooks } from "./setup-git-hooks.js";

/**
 * Configure git to use HTTPS instead of SSH for GitHub URLs.
 * This fixes npm install errors when libsignal-node tries to clone from GitHub.
 */
function configureGitUrlRedirects(repoRoot) {
  const configs = [
    ["url.https://github.com/.insteadOf", "ssh://git@github.com/"],
    ["url.https://.insteadOf", "git://"],
  ];

  for (const [key, value] of configs) {
    const result = spawnSync("git", ["config", key, value], {
      cwd: repoRoot,
      stdio: "pipe",
    });
    if (result.status === 0) {
      console.log(`[postinstall] git config: ${key} = ${value}`);
    }
  }
}

function detectPackageManager(ua = process.env.npm_config_user_agent ?? "") {
  // Examples:
  // - "pnpm/10.23.0 npm/? node/v22.21.1 darwin arm64"
  // - "npm/10.9.4 node/v22.12.0 linux x64"
  // - "bun/1.2.2"
  const normalized = String(ua).trim();
  if (normalized.startsWith("pnpm/")) return "pnpm";
  if (normalized.startsWith("bun/")) return "bun";
  if (normalized.startsWith("npm/")) return "npm";
  if (normalized.startsWith("yarn/")) return "yarn";
  return "unknown";
}

function shouldApplyPnpmPatchedDependenciesFallback(pm = detectPackageManager()) {
  // pnpm already applies pnpm.patchedDependencies itself; re-applying would fail.
  return pm !== "pnpm";
}

function getRepoRoot() {
  const here = path.dirname(fileURLToPath(import.meta.url));
  return path.resolve(here, "..");
}

function ensureExecutable(targetPath) {
  if (process.platform === "win32") return;
  if (!fs.existsSync(targetPath)) return;
  try {
    const mode = fs.statSync(targetPath).mode & 0o777;
    if (mode & 0o100) return;
    fs.chmodSync(targetPath, 0o755);
  } catch (err) {
    console.warn(`[postinstall] chmod failed: ${err}`);
  }
}

function hasGit(repoRoot) {
  const result = spawnSync("git", ["--version"], { cwd: repoRoot, stdio: "ignore" });
  return result.status === 0;
}

function extractPackageName(key) {
  if (key.startsWith("@")) {
    const idx = key.indexOf("@", 1);
    if (idx === -1) return key;
    return key.slice(0, idx);
  }
  const idx = key.lastIndexOf("@");
  if (idx <= 0) return key;
  return key.slice(0, idx);
}

function stripPrefix(p) {
  if (p.startsWith("a/") || p.startsWith("b/")) return p.slice(2);
  return p;
}

function parseRange(segment) {
  // segment: "-12,5" or "+7"
  const [startRaw, countRaw] = segment.slice(1).split(",");
  const start = Number.parseInt(startRaw, 10);
  const count = countRaw ? Number.parseInt(countRaw, 10) : 1;
  if (Number.isNaN(start) || Number.isNaN(count)) {
    throw new Error(`invalid hunk range: ${segment}`);
  }
  return { start, count };
}

function parsePatch(patchText) {
  const lines = patchText.split("\n");
  const files = [];
  let i = 0;

  while (i < lines.length) {
    if (!lines[i].startsWith("diff --git ")) {
      i += 1;
      continue;
    }

    const file = { oldPath: null, newPath: null, hunks: [] };
    i += 1;

    // Skip index line(s)
    while (i < lines.length && lines[i].startsWith("index ")) i += 1;

    if (i < lines.length && lines[i].startsWith("--- ")) {
      file.oldPath = stripPrefix(lines[i].slice(4).trim());
      i += 1;
    }
    if (i < lines.length && lines[i].startsWith("+++ ")) {
      file.newPath = stripPrefix(lines[i].slice(4).trim());
      i += 1;
    }

    while (i < lines.length && lines[i].startsWith("@@")) {
      const header = lines[i];
      const match = /^@@\s+(-\d+(?:,\d+)?)\s+(\+\d+(?:,\d+)?)\s+@@/.exec(header);
      if (!match) throw new Error(`invalid hunk header: ${header}`);
      const oldRange = parseRange(match[1]);
      const newRange = parseRange(match[2]);
      i += 1;

      const hunkLines = [];
      while (i < lines.length) {
        const line = lines[i];
        if (line.startsWith("@@") || line.startsWith("diff --git ")) break;
        if (line === "") {
          i += 1;
          continue;
        }
        if (line.startsWith("\\ No newline at end of file")) {
          i += 1;
          continue;
        }
        hunkLines.push(line);
        i += 1;
      }

      file.hunks.push({
        oldStart: oldRange.start,
        oldLines: oldRange.count,
        newStart: newRange.start,
        newLines: newRange.count,
        lines: hunkLines,
      });
    }

    if (file.newPath && file.hunks.length > 0) {
      files.push(file);
    }
  }

  return files;
}

function readFileLines(targetPath) {
  if (!fs.existsSync(targetPath)) {
    throw new Error(`target file missing: ${targetPath}`);
  }
  const raw = fs.readFileSync(targetPath, "utf-8");
  const hasTrailingNewline = raw.endsWith("\n");
  const parts = raw.split("\n");
  if (hasTrailingNewline) parts.pop();
  return { lines: parts, hasTrailingNewline };
}

function writeFileLines(targetPath, lines, hadTrailingNewline) {
  const content = lines.join("\n") + (hadTrailingNewline ? "\n" : "");
  fs.writeFileSync(targetPath, content, "utf-8");
}

function applyHunk(lines, hunk, offset) {
  let cursor = hunk.oldStart - 1 + offset;
  const expected = [];
  for (const raw of hunk.lines) {
    const marker = raw[0];
    if (marker === " " || marker === "+") {
      expected.push(raw.slice(1));
    }
  }
  if (cursor >= 0 && cursor + expected.length <= lines.length) {
    let alreadyApplied = true;
    for (let i = 0; i < expected.length; i += 1) {
      if (lines[cursor + i] !== expected[i]) {
        alreadyApplied = false;
        break;
      }
    }
    if (alreadyApplied) {
      const delta = hunk.newLines - hunk.oldLines;
      return offset + delta;
    }
  }

  for (const raw of hunk.lines) {
    const marker = raw[0];
    const text = raw.slice(1);
    if (marker === " ") {
      if (lines[cursor] !== text) {
        throw new Error(
          `context mismatch at line ${cursor + 1}: expected "${text}", found "${lines[cursor] ?? "<eof>"}"`,
        );
      }
      cursor += 1;
    } else if (marker === "-") {
      if (lines[cursor] !== text) {
        throw new Error(
          `delete mismatch at line ${cursor + 1}: expected "${text}", found "${lines[cursor] ?? "<eof>"}"`,
        );
      }
      lines.splice(cursor, 1);
    } else if (marker === "+") {
      lines.splice(cursor, 0, text);
      cursor += 1;
    } else {
      throw new Error(`unexpected hunk marker: ${marker}`);
    }
  }

  const delta = hunk.newLines - hunk.oldLines;
  return offset + delta;
}

function applyPatchToFile(targetDir, filePatch) {
  if (filePatch.newPath === "/dev/null") {
    // deletion not needed for our patches
    return;
  }
  const relPath = stripPrefix(filePatch.newPath ?? filePatch.oldPath ?? "");
  const targetPath = path.join(targetDir, relPath);
  const { lines, hasTrailingNewline } = readFileLines(targetPath);

  let offset = 0;
  for (const hunk of filePatch.hunks) {
    offset = applyHunk(lines, hunk, offset);
  }

  writeFileLines(targetPath, lines, hasTrailingNewline);
}

function applyPatchSet({ patchText, targetDir }) {
  let resolvedTarget = path.resolve(targetDir);
  if (!fs.existsSync(resolvedTarget) || !fs.statSync(resolvedTarget).isDirectory()) {
    console.warn(`[postinstall] skip missing target: ${resolvedTarget}`);
    return;
  }
  resolvedTarget = fs.realpathSync(resolvedTarget);

  const files = parsePatch(patchText);
  if (files.length === 0) return;

  for (const filePatch of files) {
    applyPatchToFile(resolvedTarget, filePatch);
  }
}

function applyPatchFile({ patchPath, targetDir }) {
  const absPatchPath = path.resolve(patchPath);
  if (!fs.existsSync(absPatchPath)) {
    throw new Error(`missing patch: ${absPatchPath}`);
  }
  const patchText = fs.readFileSync(absPatchPath, "utf-8");
  applyPatchSet({ patchText, targetDir });
}

function main() {
  const repoRoot = getRepoRoot();
  process.chdir(repoRoot);

  ensureExecutable(path.join(repoRoot, "dist", "entry.js"));
  setupGitHooks({ repoRoot });

  // Configure git to use HTTPS for GitHub (fixes libsignal-node SSH errors)
  if (hasGit(repoRoot)) {
    configureGitUrlRedirects(repoRoot);
  }

  if (!shouldApplyPnpmPatchedDependenciesFallback()) {
    return;
  }

  const pkgPath = path.join(repoRoot, "package.json");
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
  const patched = pkg?.pnpm?.patchedDependencies ?? {};

  // Bun does not support pnpm.patchedDependencies. Apply these patch files to
  // node_modules packages as a best-effort compatibility layer.
  for (const [key, relPatchPath] of Object.entries(patched)) {
    if (typeof relPatchPath !== "string" || !relPatchPath.trim()) continue;
    const pkgName = extractPackageName(String(key));
    if (!pkgName) continue;
    applyPatchFile({
      targetDir: path.join("node_modules", ...pkgName.split("/")),
      patchPath: relPatchPath,
    });
  }
}

try {
  const skip =
    process.env.CLAWDBOT_SKIP_POSTINSTALL === "1" ||
    process.env.VITEST === "true" ||
    process.env.NODE_ENV === "test";

  if (!skip) {
    main();
  }
} catch (err) {
  console.error(String(err));
  process.exit(1);
}

export {
  applyPatchFile,
  applyPatchSet,
  applyPatchToFile,
  detectPackageManager,
  parsePatch,
  shouldApplyPnpmPatchedDependenciesFallback,
};
