// admins-write — single chokepoint for the dual-file admin write
// (users.json device-level PIN auth + account.json account-level role).
//
// background: earlier the two writers — `admin-add`
// (platform/plugins/admin/mcp/src/index.ts) and `set-pin` POST
// (platform/ui/server/routes/onboarding.ts) — each performed the dual write
// inline. They drifted: admin-add returned `is_error: true` with a per-leg
// `[admin-auth-store]` line on partial failure; set-pin logged on stderr but
// otherwise rolled forward. This module collapses both into one routed write
// so the static check `grep -rnE 'admins\.push|config\.admins\s*=' platform/`
// returns 0 outside this file (excluding tests, scripts, and the reader paths
// which never push).
//
// Atomicity contract — single file: write-temp + rename, durable.
// Atomicity contract — across files: NOT atomic. POSIX has no two-file
// transaction. The helper writes users.json first, then account.json. On
// account.json failure with users.json already written, the result names the
// partial state. Callers MUST surface partial state to the operator (admin-add
// returns is_error: true with the [admins-write] line; set-pin returns 500
// with the same). Same trust model as the [admin-auth-store] pair.

import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, statSync } from "node:fs";
import { dirname, join } from "node:path";

export interface UserEntry {
  userId: string;
  pin: string; // SHA-256 hash
}

export interface AdminEntry {
  userId: string;
  role: "owner" | "admin";
}

export interface AdminWriteInput {
  userId: string;
  pin: string;          // SHA-256 hash
  role: "owner" | "admin";
  usersFile: string;    // absolute path to users.json
  accountDir: string;   // absolute path to account dir (containing account.json)
  caller: string;       // identifier for the [admins-write] log line
}

export interface AdminWriteResult {
  usersJsonResult: "ok" | "fail" | "noop";
  accountJsonResult: "ok" | "fail" | "noop";
  usersError?: string;
  accountError?: string;
}

function logLine(input: AdminWriteInput, result: AdminWriteResult): void {
  const userIdShort = input.userId.slice(0, 8);
  console.error(
    `[admins-write] caller=${input.caller} userId=${userIdShort} ` +
      `usersJsonResult=${result.usersJsonResult} accountJsonResult=${result.accountJsonResult}` +
      (result.usersError ? ` usersError=${result.usersError}` : "") +
      (result.accountError ? ` accountError=${result.accountError}` : ""),
  );
}

function writeFileAtomic(filePath: string, contents: string, mode?: number): void {
  mkdirSync(dirname(filePath), { recursive: true });
  const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
  writeFileSync(tempPath, contents, mode !== undefined ? { mode } : undefined);
  renameSync(tempPath, filePath);
}

/**
 * Write a single admin entry across both auth stores (users.json + account.json admins[]).
 *
 * Idempotent on userId: if the userId already exists in users.json, the PIN
 * field is updated in place rather than duplicated; admins[] is checked for
 * presence before pushing.
 *
 * Always emits exactly one `[admins-write] caller=… userId=<prefix> usersJsonResult=… accountJsonResult=…`
 * line — success and failure paths both fire it. Operators grep for this single
 * predicate when reconstructing a partial-write incident.
 */
export function writeAdminEntry(input: AdminWriteInput): AdminWriteResult {
  const result: AdminWriteResult = { usersJsonResult: "noop", accountJsonResult: "noop" };

  // 1. users.json — read-modify-write atomically.
  try {
    let users: UserEntry[] = [];
    if (existsSync(input.usersFile)) {
      const raw = readFileSync(input.usersFile, "utf-8").trim();
      if (raw) {
        const parsed = JSON.parse(raw);
        if (!Array.isArray(parsed)) {
          throw new Error("users.json is not an array");
        }
        users = parsed as UserEntry[];
      }
    }

    const existing = users.findIndex(u => u.userId === input.userId);
    if (existing === -1) {
      users.push({ userId: input.userId, pin: input.pin });
    } else {
      // Preserve sibling fields other writers may have set; overwrite pin.
      users[existing] = { ...users[existing], pin: input.pin };
    }

    writeFileAtomic(input.usersFile, JSON.stringify(users, null, 2) + "\n", 0o600);
    result.usersJsonResult = "ok";
  } catch (err) {
    result.usersJsonResult = "fail";
    result.usersError = err instanceof Error ? err.message : String(err);
    logLine(input, result);
    return result;
  }

  // 2. account.json — read-modify-write atomically.
  try {
    const accountJsonPath = join(input.accountDir, "account.json");
    if (!existsSync(accountJsonPath)) {
      throw new Error(`account.json not found at ${accountJsonPath}`);
    }
    const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record<string, unknown>;
    const admins = (config.admins ?? []) as AdminEntry[];
    if (admins.some(a => a.userId === input.userId)) {
      result.accountJsonResult = "noop";
    } else {
      admins.push({ userId: input.userId, role: input.role });
      config.admins = admins;
      writeFileAtomic(accountJsonPath, JSON.stringify(config, null, 2) + "\n");
      result.accountJsonResult = "ok";
    }
  } catch (err) {
    result.accountJsonResult = "fail";
    result.accountError = err instanceof Error ? err.message : String(err);
  }

  logLine(input, result);
  return result;
}

export interface AdminRemoveInput {
  userId: string;
  accountDir: string;
  caller: string;
}

export interface AdminRemoveResult {
  accountJsonResult: "ok" | "fail" | "noop";
  accountError?: string;
}

/**
 * Remove a single admin entry from account.json admins[] — does NOT touch
 * users.json (the device-level entry is preserved because the user may admin
 * other accounts). Single-file operation; the [admins-write] line records
 * `usersJsonResult=skip` so the grep-by-helper-call still picks it up.
 */
export function removeAdminFromAccount(input: AdminRemoveInput): AdminRemoveResult {
  const result: AdminRemoveResult = { accountJsonResult: "noop" };
  const userIdShort = input.userId.slice(0, 8);

  try {
    const accountJsonPath = join(input.accountDir, "account.json");
    if (!existsSync(accountJsonPath)) {
      throw new Error(`account.json not found at ${accountJsonPath}`);
    }
    const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record<string, unknown>;
    const admins = (config.admins ?? []) as AdminEntry[];
    const targetIndex = admins.findIndex(a => a.userId === input.userId);
    if (targetIndex === -1) {
      result.accountJsonResult = "noop";
    } else {
      admins.splice(targetIndex, 1);
      config.admins = admins;
      writeFileAtomic(accountJsonPath, JSON.stringify(config, null, 2) + "\n");
      result.accountJsonResult = "ok";
    }
  } catch (err) {
    result.accountJsonResult = "fail";
    result.accountError = err instanceof Error ? err.message : String(err);
  }

  console.error(
    `[admins-write] caller=${input.caller} userId=${userIdShort} ` +
      `usersJsonResult=skip accountJsonResult=${result.accountJsonResult}` +
      (result.accountError ? ` accountError=${result.accountError}` : ""),
  );
  return result;
}

export interface InvariantCheckInput {
  /** Absolute path to users.json (persistent location). */
  usersFile: string;
  /** Absolute path to the accounts root (e.g. <INSTALL_DIR>/data/accounts). */
  accountsDir: string;
  /** Tag for the log prefix — `install-invariant` or `admin-invariant`. */
  tag: "install-invariant" | "admin-invariant";
}

export interface InvariantCheckResult {
  divergences: number;
  /** Userids in account.json admins[] that have no row in users.json. */
  accountWithoutUsers: Array<{ userId: string; source: string }>;
  /** Userids in users.json that no account.json admins[] references. */
  usersWithoutAccount: Array<{ userId: string }>;
}

/**
 * Walk every account.json under accountsDir and compare its admins[] to
 * users.json. Emits one log line per divergence and one summary line.
 *
 * Behaviour: log-only — does NOT throw, does NOT refuse boot or install.
 * Pre-Task-904 installs that already diverged would otherwise brick on the
 * first boot of a Task-904 device. The summary line `divergences=<n>` is
 * always emitted (wired-up assertion); per-divergence lines fire only when
 * divergences > 0. Operators grep `[install-invariant]` or `[admin-invariant]`
 * to detect the regression class without reading every install log.
 *
 * Promotion to refuse-or-degrade is a future sprint; capture as a TODO once
 * the deployed fleet has been audited clean.
 */
export function checkAdminAuthInvariant(input: InvariantCheckInput): InvariantCheckResult {
  const result: InvariantCheckResult = {
    divergences: 0,
    accountWithoutUsers: [],
    usersWithoutAccount: [],
  };

  // Read users.json userIds (persistent file). Absent file == empty set; do
  // not abort — first-boot before set-pin has run is a legitimate state.
  const usersUserIds = new Set<string>();
  if (existsSync(input.usersFile)) {
    try {
      const raw = readFileSync(input.usersFile, "utf-8").trim();
      if (raw) {
        const users = JSON.parse(raw) as UserEntry[];
        for (const u of users) {
          if (typeof u.userId === "string") usersUserIds.add(u.userId);
        }
      }
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      console.error(`[${input.tag}] users.json unreadable usersFile=${input.usersFile} error=${msg}`);
    }
  }

  // Walk account dirs. Skip dotfiles (.trash/) and missing account.json.
  const accountUserIds = new Set<string>();
  if (existsSync(input.accountsDir)) {
    let entries: string[];
    try {
      entries = readdirSync(input.accountsDir);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      console.error(`[${input.tag}] accounts-dir unreadable accountsDir=${input.accountsDir} error=${msg}`);
      console.error(`[${input.tag}] check complete divergences=0 (accounts-dir unreadable)`);
      return result;
    }

    for (const entry of entries) {
      if (entry.startsWith(".")) continue;
      const accountDir = join(input.accountsDir, entry);
      try {
        if (!statSync(accountDir).isDirectory()) continue;
      } catch {
        continue;
      }
      const accountJsonPath = join(accountDir, "account.json");
      if (!existsSync(accountJsonPath)) continue;
      let admins: AdminEntry[] = [];
      try {
        const config = JSON.parse(readFileSync(accountJsonPath, "utf-8")) as Record<string, unknown>;
        admins = (config.admins ?? []) as AdminEntry[];
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        console.error(`[${input.tag}] account.json unreadable source=${accountJsonPath} error=${msg}`);
        continue;
      }

      for (const a of admins) {
        if (typeof a.userId !== "string") continue;
        accountUserIds.add(a.userId);
        if (!usersUserIds.has(a.userId)) {
          const userIdShort = a.userId.slice(0, 8);
          console.error(
            `[${input.tag}] direction=account-without-users userId=${userIdShort} source=${accountJsonPath}`,
          );
          result.accountWithoutUsers.push({ userId: a.userId, source: accountJsonPath });
          result.divergences += 1;
        }
      }
    }
  }

  for (const uid of usersUserIds) {
    if (!accountUserIds.has(uid)) {
      const userIdShort = uid.slice(0, 8);
      console.error(
        `[${input.tag}] direction=users-without-account userId=${userIdShort} source=${input.usersFile}`,
      );
      result.usersWithoutAccount.push({ userId: uid });
      result.divergences += 1;
    }
  }

  console.error(`[${input.tag}] check complete divergences=${result.divergences}`);
  return result;
}
