#!/usr/bin/env node

// src/web/auth.ts
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, copyFileSync, renameSync, unlinkSync } from "fs";
import { join } from "path";
import { createHmac, randomBytes, timingSafeEqual, pbkdf2Sync } from "crypto";
var USERS_FILE = "users.json";
var TOKEN_EXPIRY_HOURS = 24;
var TOKEN_EXPIRY_MS = TOKEN_EXPIRY_HOURS * 3600 * 1e3;
var USERS_DIR = "users";
var LOGIN_MAX_FAILS = 5;
var LOGIN_LOCKOUT_MS = 15 * 60 * 1e3;
var loginAttempts = /* @__PURE__ */ new Map();
function __resetLoginAttemptsForTests() {
  loginAttempts.clear();
}
var AuthManager = class {
  usersFile;
  baseDir;
  db;
  constructor(baseDir) {
    this.baseDir = baseDir;
    this.usersFile = join(baseDir, USERS_FILE);
    this.db = this.loadOrCreate();
  }
  // ── Public API ─────────────────────────────────────────────────
  /** Check if any users exist (first-run detection) */
  hasUsers() {
    return this.db.users.length > 0;
  }
  /** Check if auth is enabled (has users.json with at least 1 user) */
  isEnabled() {
    return this.hasUsers();
  }
  /** Register a new user. Returns error string or null on success. */
  register(username, password) {
    username = username.trim().toLowerCase();
    if (!username || username.length < 2 || username.length > 32) {
      return "Username must be 2-32 characters";
    }
    if (!/^[a-z0-9_-]+$/.test(username)) {
      return "Username can only contain a-z, 0-9, _ and -";
    }
    if (!password || password.length < 4) {
      return "Password must be at least 4 characters";
    }
    if (this.db.users.find((u) => u.username === username)) {
      return "Username already exists";
    }
    const salt = randomBytes(16).toString("hex");
    const passwordHash = this.hashPassword(password, salt);
    const dataDir = join(USERS_DIR, username);
    const fullDataDir = join(this.baseDir, dataDir);
    mkdirSync(join(fullDataDir, "history"), { recursive: true });
    const user = {
      username,
      passwordHash,
      salt,
      createdAt: (/* @__PURE__ */ new Date()).toISOString(),
      dataDir,
      hashVersion: 2
    };
    this.db.users.push(user);
    this.save();
    return null;
  }
  /**
   * Authenticate user. Returns JWT token or null on failure.
   *
   * Audit closure (5th audit, v0.4.114): integrates failed-login lockout
   * (CWE-307). After {@link LOGIN_MAX_FAILS} consecutive failures within a
   * lockout window, further attempts return null without checking the
   * password (avoiding pbkdf2 work + leaking timing). Successful login
   * resets the counter.
   */
  login(username, password) {
    username = username.trim().toLowerCase();
    const lockState = this.getLockState(username);
    if (lockState.locked) return null;
    const user = this.db.users.find((u) => u.username === username);
    if (!user) {
      this.recordFailedLogin(username);
      return null;
    }
    const isLegacy = !user.hashVersion || user.hashVersion < 2;
    const hash = isLegacy ? this.hashPasswordLegacy(password, user.salt) : this.hashPassword(password, user.salt);
    const a = Buffer.from(hash, "utf-8");
    const b = Buffer.from(user.passwordHash, "utf-8");
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      this.recordFailedLogin(username);
      return null;
    }
    if (isLegacy) {
      const newSalt = randomBytes(16).toString("hex");
      user.passwordHash = this.hashPassword(password, newSalt);
      user.salt = newSalt;
      user.hashVersion = 2;
      this.save();
    }
    loginAttempts.delete(username);
    return this.createToken(username);
  }
  /**
   * Returns current lockout state for a username and lazily expires it.
   * Exposed (read-only) for tests and the `aicli user` CLI status output.
   */
  getLockState(username) {
    username = username.trim().toLowerCase();
    const state = loginAttempts.get(username);
    if (!state) return { locked: false, remainingMs: 0, fails: 0 };
    if (state.lockedUntil > 0 && Date.now() >= state.lockedUntil) {
      loginAttempts.delete(username);
      return { locked: false, remainingMs: 0, fails: 0 };
    }
    if (state.lockedUntil > 0) {
      return { locked: true, remainingMs: state.lockedUntil - Date.now(), fails: state.fails };
    }
    return { locked: false, remainingMs: 0, fails: state.fails };
  }
  recordFailedLogin(username) {
    const state = loginAttempts.get(username) ?? { fails: 0, lockedUntil: 0 };
    state.fails += 1;
    if (state.fails >= LOGIN_MAX_FAILS) {
      state.lockedUntil = Date.now() + LOGIN_LOCKOUT_MS;
    }
    loginAttempts.set(username, state);
  }
  /** Verify a token. Returns username or null. */
  verifyToken(token) {
    try {
      const parts = token.split(".");
      if (parts.length !== 2) return null;
      const [payloadB64, signature] = parts;
      const expectedSig = this.sign(payloadB64);
      const sigBuf = Buffer.from(signature ?? "", "utf-8");
      const expBuf = Buffer.from(expectedSig, "utf-8");
      if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) return null;
      const payload = JSON.parse(
        Buffer.from(payloadB64, "base64url").toString("utf-8")
      );
      if (Date.now() > payload.exp) return null;
      const user = this.db.users.find((u) => u.username === payload.username);
      if (!user) return null;
      if (user.tokensRevokedBefore && (!payload.iat || payload.iat < user.tokensRevokedBefore)) {
        return null;
      }
      return payload.username;
    } catch {
      return null;
    }
  }
  /**
   * Revoke every outstanding token for the given user by bumping the
   * `tokensRevokedBefore` watermark to now. Audit closure (5th audit,
   * v0.4.114). Returns true if the user existed.
   */
  logoutAll(username) {
    username = username.trim().toLowerCase();
    const user = this.db.users.find((u) => u.username === username);
    if (!user) return false;
    user.tokensRevokedBefore = Date.now() + 1;
    this.save();
    return true;
  }
  /** Get user's data directory (absolute path) */
  getUserDataDir(username) {
    const user = this.db.users.find((u) => u.username === username);
    if (!user) throw new Error(`User not found: ${username}`);
    return join(this.baseDir, user.dataDir);
  }
  /** List all usernames */
  listUsers() {
    return this.db.users.map((u) => u.username);
  }
  /** Delete a user. Returns error string or null on success. */
  deleteUser(username) {
    username = username.trim().toLowerCase();
    const idx = this.db.users.findIndex((u) => u.username === username);
    if (idx === -1) return `User '${username}' not found`;
    this.db.users.splice(idx, 1);
    this.save();
    return null;
  }
  /** Reset a user's password. Returns error string or null on success. */
  resetPassword(username, newPassword) {
    username = username.trim().toLowerCase();
    const user = this.db.users.find((u) => u.username === username);
    if (!user) return `User '${username}' not found`;
    if (!newPassword || newPassword.length < 4) {
      return "Password must be at least 4 characters";
    }
    const salt = randomBytes(16).toString("hex");
    user.passwordHash = this.hashPassword(newPassword, salt);
    user.salt = salt;
    user.hashVersion = 2;
    user.tokensRevokedBefore = Date.now() + 1;
    this.save();
    return null;
  }
  /** Migrate existing single-user data to a named user account */
  migrateExistingData(username, password) {
    const err = this.register(username, password);
    if (err) return err;
    const userDir = this.getUserDataDir(username);
    const globalConfig = join(this.baseDir, "config.json");
    if (existsSync(globalConfig)) {
      try {
        const content = readFileSync(globalConfig, "utf-8");
        writeFileSync(join(userDir, "config.json"), content, "utf-8");
      } catch {
      }
    }
    const globalMemory = join(this.baseDir, "memory.md");
    if (existsSync(globalMemory)) {
      try {
        const content = readFileSync(globalMemory, "utf-8");
        writeFileSync(join(userDir, "memory.md"), content, "utf-8");
      } catch {
      }
    }
    const globalHistory = join(this.baseDir, "history");
    if (existsSync(globalHistory)) {
      try {
        const files = readdirSync(globalHistory).filter((f) => f.endsWith(".json"));
        const userHistory = join(userDir, "history");
        for (const f of files) {
          try {
            copyFileSync(join(globalHistory, f), join(userHistory, f));
          } catch {
          }
        }
      } catch {
      }
    }
    return null;
  }
  // ── Private methods ────────────────────────────────────────────
  loadOrCreate() {
    if (existsSync(this.usersFile)) {
      try {
        return JSON.parse(readFileSync(this.usersFile, "utf-8"));
      } catch {
      }
    }
    const db = {
      version: 1,
      secret: randomBytes(32).toString("hex"),
      users: []
    };
    this.saveDB(db);
    return db;
  }
  save() {
    this.saveDB(this.db);
  }
  saveDB(db) {
    mkdirSync(this.baseDir, { recursive: true });
    const tmp = `${this.usersFile}.tmp`;
    try {
      writeFileSync(tmp, JSON.stringify(db, null, 2), "utf-8");
      renameSync(tmp, this.usersFile);
    } catch (err) {
      try {
        unlinkSync(tmp);
      } catch {
      }
      throw err;
    }
  }
  /** Legacy hash — kept only for migrating old users (v0.2.x) */
  hashPasswordLegacy(password, salt) {
    return createHmac("sha256", salt).update(password).digest("hex");
  }
  hashPassword(password, salt) {
    return pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex");
  }
  createToken(username) {
    const now = Date.now();
    const payload = {
      username,
      iat: now,
      exp: now + TOKEN_EXPIRY_HOURS * 3600 * 1e3
    };
    const payloadB64 = Buffer.from(JSON.stringify(payload), "utf-8").toString("base64url");
    const signature = this.sign(payloadB64);
    return `${payloadB64}.${signature}`;
  }
  sign(data) {
    return createHmac("sha256", this.db.secret).update(data).digest("base64url");
  }
};

export {
  TOKEN_EXPIRY_MS,
  __resetLoginAttemptsForTests,
  AuthManager
};
