"use strict";

const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");

class HoloLakeSessionManager {
  constructor(options = {}) {
    this.now = options.now || Date.now;
    this.sendEmail = options.sendEmail || (async () => false);
    this.pepper = String(options.pepper || "");
    if (this.pepper.length < 24) {
      throw new Error("HOLOLAKE_SESSION_PEPPER must contain at least 24 characters");
    }
    this.stateFile = String(options.stateFile || "");
    this.otpTtlMs = Number(options.otpTtlSeconds || 10 * 60) * 1000;
    this.sessionTtlMs = Number(options.sessionTtlSeconds || 24 * 60 * 60) * 1000;
    this.maxOtpAttempts = Math.max(1, Number(options.maxOtpAttempts || 5));
    this.requestLimit = Math.max(1, Number(options.requestLimit || 6));
    this.requestWindowMs = Number(options.requestWindowSeconds || 60 * 60) * 1000;
    this.registeredEmails = new Map(
      (options.registeredEmails || [])
        .map(normalizeEmail)
        .filter(validEmail)
        .map(email => [email, this.digest(`account:${email}`)]),
    );
    this.requestEvents = new Map();
    this.state = this.loadState();
  }

  async requestOtp({ email, deviceId, networkKey = "unknown" }) {
    const now = this.now();
    this.prune(now);
    const normalizedDevice = normalizeDeviceId(deviceId);
    const requestId = crypto.randomUUID();
    if (!normalizedDevice) return { accepted: false, error: "invalid_device" };
    if (!this.takeRequest(String(networkKey || "unknown"), now)) {
      return { accepted: false, error: "rate_limited" };
    }

    const normalizedEmail = normalizeEmail(email);
    const accountId = this.registeredEmails.get(normalizedEmail);
    if (!accountId) return { accepted: true, request_id: requestId };

    const code = String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
    this.state.challenges[requestId] = {
      account_id: accountId,
      device_id: normalizedDevice,
      code_digest: this.digest(
        `otp:${requestId}:${normalizedDevice}:${code}`,
      ),
      attempts: 0,
      expires_at: now + this.otpTtlMs,
    };
    this.persist();
    const sent = await this.sendEmail({
      to: normalizedEmail,
      subject: "HoloLake 登录验证码",
      text: [
        `你的 HoloLake 登录验证码是：${code}`,
        `验证码将在 ${Math.ceil(this.otpTtlMs / 60_000)} 分钟后失效。`,
        "如果不是你本人操作，请忽略这封邮件。",
      ].join("\n"),
    });
    if (!sent) {
      delete this.state.challenges[requestId];
      this.persist();
    }
    return { accepted: true, request_id: requestId };
  }

  verifyOtp({ requestId, code, deviceId }) {
    const now = this.now();
    this.prune(now);
    const id = String(requestId || "");
    const challenge = this.state.challenges[id];
    const normalizedDevice = normalizeDeviceId(deviceId);
    if (
      !challenge
      || !normalizedDevice
      || challenge.expires_at <= now
      || challenge.attempts >= this.maxOtpAttempts
    ) {
      if (challenge) {
        delete this.state.challenges[id];
        this.persist();
      }
      return invalidCode();
    }

    challenge.attempts += 1;
    const candidate = this.digest(
      `otp:${id}:${normalizedDevice}:${String(code || "")}`,
    );
    const matches = safeEqual(candidate, challenge.code_digest)
      && safeEqual(normalizedDevice, challenge.device_id);
    if (!matches) {
      if (challenge.attempts >= this.maxOtpAttempts) {
        delete this.state.challenges[id];
      }
      this.persist();
      return invalidCode();
    }

    delete this.state.challenges[id];
    const token = crypto.randomBytes(48).toString("base64url");
    const tokenDigest = this.digest(`session:${token}`);
    this.state.sessions[tokenDigest] = {
      account_id: challenge.account_id,
      device_id: normalizedDevice,
      created_at: now,
      expires_at: now + this.sessionTtlMs,
    };
    this.persist();
    return {
      ok: true,
      session_token: token,
      expires_at: (now + this.sessionTtlMs) / 1000,
      expires_in: this.sessionTtlMs / 1000,
    };
  }

  authenticate(token, deviceId) {
    const now = this.now();
    const normalizedDevice = normalizeDeviceId(deviceId);
    const tokenDigest = this.digest(`session:${String(token || "")}`);
    const session = this.state.sessions[tokenDigest];
    if (!session) return { ok: false, error: "session_invalid" };
    if (session.expires_at <= now) {
      delete this.state.sessions[tokenDigest];
      this.persist();
      return { ok: false, error: "session_expired" };
    }
    if (!normalizedDevice || !safeEqual(normalizedDevice, session.device_id)) {
      return { ok: false, error: "session_device_mismatch" };
    }
    return {
      ok: true,
      session: {
        account_id: session.account_id,
        device_id: session.device_id,
        created_at: session.created_at / 1000,
        expires_at: session.expires_at / 1000,
      },
    };
  }

  revoke(token, deviceId) {
    const authenticated = this.authenticate(token, deviceId);
    if (!authenticated.ok) return authenticated;
    const tokenDigest = this.digest(`session:${String(token || "")}`);
    delete this.state.sessions[tokenDigest];
    this.persist();
    return { ok: true };
  }

  inspectState() {
    return JSON.parse(JSON.stringify(this.state));
  }

  digest(value) {
    return crypto.createHmac("sha256", this.pepper).update(value).digest("hex");
  }

  takeRequest(key, now) {
    const previous = (this.requestEvents.get(key) || [])
      .filter(timestamp => now - timestamp < this.requestWindowMs);
    if (previous.length >= this.requestLimit) {
      this.requestEvents.set(key, previous);
      return false;
    }
    previous.push(now);
    this.requestEvents.set(key, previous);
    return true;
  }

  prune(now) {
    let changed = false;
    for (const [id, challenge] of Object.entries(this.state.challenges)) {
      if (challenge.expires_at <= now) {
        delete this.state.challenges[id];
        changed = true;
      }
    }
    for (const [digest, session] of Object.entries(this.state.sessions)) {
      if (session.expires_at <= now) {
        delete this.state.sessions[digest];
        changed = true;
      }
    }
    if (changed) this.persist();
  }

  loadState() {
    if (!this.stateFile || !fs.existsSync(this.stateFile)) {
      return emptyState();
    }
    const parsed = JSON.parse(fs.readFileSync(this.stateFile, "utf8"));
    if (
      !parsed
      || parsed.schema !== "guanghu.hololake-session-state/v1"
      || !isRecord(parsed.challenges)
      || !isRecord(parsed.sessions)
    ) {
      throw new Error("invalid HoloLake session state");
    }
    return parsed;
  }

  persist() {
    if (!this.stateFile) return;
    fs.mkdirSync(path.dirname(this.stateFile), { recursive: true, mode: 0o700 });
    const temporary = `${this.stateFile}.${process.pid}.tmp`;
    fs.writeFileSync(temporary, `${JSON.stringify(this.state, null, 2)}\n`, {
      encoding: "utf8",
      mode: 0o600,
    });
    fs.renameSync(temporary, this.stateFile);
    fs.chmodSync(this.stateFile, 0o600);
  }
}

function emptyState() {
  return {
    schema: "guanghu.hololake-session-state/v1",
    challenges: {},
    sessions: {},
  };
}

function invalidCode() {
  return { ok: false, error: "invalid_or_expired_code" };
}

function normalizeEmail(value) {
  return String(value || "").trim().normalize("NFKC").toLowerCase();
}

function normalizeDeviceId(value) {
  const normalized = String(value || "").trim();
  return /^[A-Za-z0-9._:-]{8,120}$/.test(normalized) ? normalized : "";
}

function validEmail(value) {
  return value.length <= 254 && /^[^@\s]+@[^@\s]+$/.test(value);
}

function safeEqual(left, right) {
  const a = Buffer.from(String(left));
  const b = Buffer.from(String(right));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function isRecord(value) {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

module.exports = {
  HoloLakeSessionManager,
};
