import crypto from 'node:crypto';
import { promises as fs } from 'node:fs';
import path from 'node:path';

/**
 * Lean file-backed accounts + cloud-backup store. No external DB — a single
 * users.json plus one blob file per user under DATA_DIR. Passwords are scrypt-
 * hashed with a per-user salt; bearer tokens are stored hashed. Single-process
 * only (writes are serialised through a promise queue). Good enough for a small
 * self-hosted instance; the protocol leaves room to swap in a real DB later.
 */

interface User {
  id: string;
  username: string; // display
  salt: string;
  hash: string;
  tokens: string[]; // sha256(token)
  quotaBytes?: number; // admin override; 0/undefined = default
  createdAt: number;
  updatedAt: number;
}

const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
const MAX_TOKENS = 10;

function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); }
function hashPassword(password: string, salt: string): string { return crypto.scryptSync(password, salt, 64).toString('hex'); }
function timingEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a), bb = Buffer.from(b);
  return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}

export interface AuthResult { ok: true; token: string; username: string }
export interface AuthError { ok: false; code: string; message: string }

export class AccountStore {
  private users = new Map<string, User>(); // key: lowercased username
  private byId = new Map<string, User>();
  private queue: Promise<unknown> = Promise.resolve();
  private loaded = false;
  constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = []) {}

  private usersFile() { return path.join(this.dir, 'users.json'); }
  private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }

  isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); }

  async listUsers(): Promise<Array<{ id: string; username: string; quotaBytes?: number }>> {
    await this.load();
    return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) }));
  }

  async setQuota(username: string, bytes: number): Promise<boolean> {
    await this.load();
    const u = this.users.get(username.toLowerCase());
    if (!u) return false;
    u.quotaBytes = Math.max(0, Math.floor(bytes));
    u.updatedAt = this.now();
    await this.persist();
    return true;
  }

  /** Size in bytes of a user's stored backup blob (0 if none). */
  async blobBytes(id: string): Promise<number> {
    try { return (await fs.stat(this.blobFile(id))).size; } catch { return 0; }
  }

  async load(): Promise<void> {
    if (this.loaded) return;
    this.loaded = true;
    try {
      const raw = await fs.readFile(this.usersFile(), 'utf8');
      const arr = JSON.parse(raw) as User[];
      for (const u of arr) { this.users.set(u.username.toLowerCase(), u); this.byId.set(u.id, u); }
    } catch { /* no file yet */ }
  }

  private persist(): Promise<void> {
    this.queue = this.queue.then(async () => {
      await fs.mkdir(this.dir, { recursive: true });
      const tmp = `${this.usersFile()}.tmp`;
      await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
      await fs.rename(tmp, this.usersFile());
    }).catch(() => {});
    return this.queue as Promise<void>;
  }

  private issueToken(u: User): string {
    const token = crypto.randomBytes(32).toString('base64url');
    u.tokens.push(sha256(token));
    if (u.tokens.length > MAX_TOKENS) u.tokens = u.tokens.slice(-MAX_TOKENS);
    u.updatedAt = this.now();
    return token;
  }

  async register(username: string, password: string): Promise<AuthResult | AuthError> {
    await this.load();
    if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 3–32 letters, digits, . _ or -.' };
    if (typeof password !== 'string' || password.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' };
    if (this.users.has(username.toLowerCase())) return { ok: false, code: 'taken', message: 'That username is taken.' };
    const salt = crypto.randomBytes(16).toString('hex');
    const u: User = { id: crypto.randomUUID(), username, salt, hash: hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() };
    const token = this.issueToken(u);
    this.users.set(username.toLowerCase(), u);
    this.byId.set(u.id, u);
    await this.persist();
    return { ok: true, token, username: u.username };
  }

  async login(username: string, password: string): Promise<AuthResult | AuthError> {
    await this.load();
    const u = this.users.get((username ?? '').toLowerCase());
    if (!u || !timingEqual(u.hash, hashPassword(password ?? '', u.salt))) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' };
    const token = this.issueToken(u);
    await this.persist();
    return { ok: true, token, username: u.username };
  }

  async userByToken(token: string | undefined): Promise<User | null> {
    if (!token) return null;
    await this.load();
    const h = sha256(token);
    for (const u of this.users.values()) if (u.tokens.includes(h)) return u;
    return null;
  }

  async logout(token: string): Promise<void> {
    const u = await this.userByToken(token);
    if (!u) return;
    u.tokens = u.tokens.filter((t) => t !== sha256(token));
    await this.persist();
  }

  /** Write the backup blob and return its version token (the file mtime, ms). */
  async saveBlob(id: string, blob: string): Promise<number> {
    await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true });
    const tmp = `${this.blobFile(id)}.tmp`;
    await fs.writeFile(tmp, blob);
    await fs.rename(tmp, this.blobFile(id));
    return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs);
  }

  async loadBlob(id: string): Promise<string | null> {
    try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return null; }
  }

  /** Version token of the stored blob (file mtime, ms), or null if none. Used for
   *  optimistic-concurrency conflict detection so a second device can't silently
   *  overwrite a newer cloud copy. */
  async blobSavedAt(id: string): Promise<number | null> {
    try { return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs); } catch { return null; }
  }

  userCount(): number { return this.users.size; }
}
