/*
  Provision an AI agent user with a declared scope — one user per (environment × scope).

  Replaces the single hardcoded grant in provisionSandbox.ts. Scopes live in
  scopes.ts so a reader can audit what an agent may do without opening Dolibarr,
  and so a sandbox refresh can restore the exact same rights instead of losing
  hand-granted ones.

  Usage:
    # audit what a user actually holds vs what its scope declares (no writes)
    deno run -A test/provisionAiUser.ts --scope read --env sandbox --audit

    # create (or align) the user and emit its API key
    DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab \
      deno run -A test/provisionAiUser.ts --scope sandbox-write --env sandbox

    # production requires the explicit opt-in from guard.ts, twice over
    DOLIBARR_ADDRESS=https://erp.arcodange.lab \
    ARCO_ALLOW_PRODUCTION=erp.arcodange.lab \
    ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD \
      deno run -A test/provisionAiUser.ts --scope prod-write --env production

  The API key is written to a gitignored file, never printed.
*/
import "load_dotenv";
import { chromium } from "playwright";
import login from "./scripts/login.ts";
import userSetup from "./scripts/admin/userSetup.ts";
import { assertSandbox } from "./scripts/guard.ts";
import { loginFor, PERMISSION_LABELS, resolveScope } from "./scopes.ts";

const argv = Deno.args;
const pick = (flag: string, dflt = "") =>
  argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : dflt;

const scopeName = pick("--scope");
const env = pick("--env", "sandbox") as "sandbox" | "production";
const auditOnly = argv.includes("--audit");
const userLogin = pick("--login") || loginFor(scopeName, env);

if (!scopeName) {
  console.error("--scope is required (see test/scopes.ts for the declared scopes)");
  Deno.exit(2);
}
const scope = resolveScope(scopeName, env);

// guard.ts refuses anything that is not the sandbox unless the production
// opt-in is set explicitly — provisioning a prod writer is a deliberate act.
const dolibarrAddress = assertSandbox();
if (env === "production" && dolibarrAddress.includes("erp-sandbox")) {
  console.error("--env production but the address is the sandbox — refusing.");
  Deno.exit(2);
}

console.log(`scope   : ${scopeName} — ${scope.purpose}`);
console.log(`target  : ${dolibarrAddress} (${env})`);
console.log(`user    : ${userLogin}`);
console.log(`rights  : ${scope.rights.length} declared`);
for (const r of scope.rights) {
  console.log(`          ${String(r).padStart(4)} ${PERMISSION_LABELS[r] ?? "(libellé inconnu — vérifier)"}`);
}

const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ locale: "fr-FR" });
const page = await context.newPage();
const globalCtx = {
  page,
  dolibarrAddress,
  imgFolderPath: "", // required by UserCtx (forms.fillForm signature); no file input here
  adminCredentials: {
    username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
    password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
  },
};

/**
 * Find a user id by login, from the user list. Implemented here rather than
 * imported: the trunk's userSetup.ts has a findUserId, but it is uncommitted
 * work-in-progress — a provisioning script must not depend on someone's WIP.
 */
async function findUserId(userLogin: string): Promise<number | undefined> {
  // Two traps, both observed live on Dolibarr 22:
  //   - the list TRUNCATES long logins with an ellipsis, so
  //     `ai_agent_sandbox_sandbox_write` renders as `ai_agent_sandbox_sandbox…`
  //     and an exact-text match silently fails;
  //   - a miss makes the caller CREATE a duplicate privileged user, which is the
  //     worst failure mode a provisioning script has.
  // So: filter server-side with search_login, then accept an exact match or a
  // truncated prefix of the login we asked for.
  await page.goto(
    `${dolibarrAddress}/user/list.php?search_login=${encodeURIComponent(userLogin)}`,
  );
  const href = await page.locator('a[href*="/user/card.php?id="]').evaluateAll(
    (as: { textContent: string | null; getAttribute(n: string): string | null }[], want: string) => {
      for (const a of as) {
        const text = (a.textContent ?? "").trim();
        const bare = text.replace(/[…\.]+$/, "");
        if (text === want || (bare.length >= 8 && want.startsWith(bare))) {
          return a.getAttribute("href");
        }
      }
      return null;
    },
    userLogin,
  );
  const m = href?.match(/id=(\d+)/);
  return m ? Number(m[1]) : undefined;
}

/**
 * What the user actually holds right now, read off the perms page — id AND the
 * live label. Reading the label matters: a catalogue in code goes stale across
 * Dolibarr versions, and an audit that cannot name what it found is not
 * actionable. The live page is the authority.
 */
async function readGrantedRights(userId: number): Promise<Map<number, string>> {
  await page.goto(`${dolibarrAddress}/user/perms.php?id=${userId}`);
  const rows = await page.locator('tr:has(a[href*="action=delrights"])').evaluateAll(
    (trs: { textContent: string | null; querySelector(s: string): { getAttribute(n: string): string | null } | null }[]) =>
      trs.map((tr) => ({
        href: tr.querySelector('a[href*="action=delrights"]')?.getAttribute("href") ?? "",
        text: (tr.textContent ?? "").replace(/\s+/g, " ").trim(),
      })),
  );
  const held = new Map<number, string>();
  for (const r of rows) {
    const m = r.href.match(/rights=(\d+)&/);
    if (!m) continue;
    // strip the trailing action words Dolibarr renders in the same row
    const label = r.text.replace(/(Tout \/ Aucun|Retirer|Ajouter)\s*$/i, "").trim();
    held.set(Number(m[1]), label.slice(0, 80) || "(libellé illisible)");
  }
  return held;
}

try {
  await login.doAdminLogin(globalCtx);
  console.log(`\nconnecté comme ${await login.whoAmI({ page })}`);

  let userId = await findUserId(userLogin);

  if (auditOnly) {
    if (!userId) {
      console.log(`\nAUDIT — l'utilisateur '${userLogin}' n'existe pas sur ${env}.`);
      Deno.exit(0);
    }
    const held = await readGrantedRights(userId);
    const declared = new Set(scope.rights);
    const missing = [...declared].filter((r) => !held.has(r));
    const extra = [...held.keys()].filter((r) => !declared.has(r));
    console.log(`\nAUDIT — utilisateur ${userLogin} (id=${userId})`);
    console.log(`  détenus : ${held.size} · déclarés : ${declared.size}`);
    if (missing.length) {
      console.log(`  MANQUANTS (le scope les déclare, l'utilisateur ne les a pas) :`);
      for (const r of missing) console.log(`    ${r} ${PERMISSION_LABELS[r] ?? "?"}`);
    }
    if (extra.length) {
      console.log(`  EN TROP (détenus hors scope — c'est ce qui rend la doctrine fausse) :`);
      for (const r of extra) console.log(`    ${String(r).padStart(6)} ${held.get(r)}`);
    }
    if (!missing.length && !extra.length) console.log("  conforme au scope déclaré.");
    Deno.exit(missing.length || extra.length ? 1 : 0);
  }

  if (!userId) {
    const pwd = crypto.randomUUID();
    userId = await userSetup.createUser(globalCtx, {
      login: userLogin,
      password: pwd,
      lastname: `AI Agent (${env}/${scopeName})`,
      admin: false,
    });
    console.log(`utilisateur créé : ${userLogin} (id=${userId})`);
  } else {
    console.log(`utilisateur existant : ${userLogin} (id=${userId})`);
  }

  await userSetup.assignRights(globalCtx, userId, [...scope.rights]);
  const held = await readGrantedRights(userId);
  const missing = scope.rights.filter((r) => !held.has(r));
  const extra = [...held.keys()].filter((r) => !scope.rights.includes(r));
  console.log(`\ndroits après application : ${held.size} détenus`);
  if (missing.length) console.log(`  ⚠️ non appliqués : ${missing.join(", ")}`);
  if (extra.length) {
    // Not auto-revoked: removing a right an operator granted on purpose is not
    // this script's call. Surface it and let a human decide.
    console.log(`  ⚠️ hors scope, à examiner :`);
    for (const r of extra) console.log(`     ${String(r).padStart(6)} ${held.get(r)}`);
    console.log(`     retrait : deno run -A test/grantAgentRight.ts --user-id ${userId} --match "<libellé>" --revoke`);
  }

  const apiKey = await userSetup.generateApiKey(globalCtx, userId);
  const keyFile = `.${userLogin}.key`;
  await Deno.writeTextFile(keyFile, apiKey + "\n");
  await Deno.chmod(keyFile, 0o600);
  console.log(`\nclé API écrite dans test/${keyFile} (mode 600, gitignoré) — jamais affichée.`);
} finally {
  await context.close();
  await browser.close();
}
