import { createHash, randomBytes, scryptSync, timingSafeEqual, randomUUID } from "node:crypto";
import { mkdir, open, readFile, rename, rm, stat, unlink } from "node:fs/promises";
import { join } from "node:path";
import { createDevicePairingPayload } from "../../../packages/shared/src/device-pairing.js";

const AUTH_STATE_SCHEMA_VERSION = 1;
const PAIRING_LIFETIME_MS = 5 * 60 * 1000;
const DEVICE_LIFETIME_MS = 180 * 24 * 60 * 60 * 1000;
const MAX_PAIRING_FAILURES = 5;
const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";

export function createDeviceAuthStore(dataDir, dependencies = {}) {
  const now = dependencies.now ?? (() => new Date());
  const random = dependencies.randomBytes ?? randomBytes;
  const securityDir = join(dataDir, "security");
  const statePath = join(securityDir, "device-auth.json");
  const lockPath = join(securityDir, "device-auth.lock");

  return {
    async status() {
      const state = await loadState(statePath);
      const currentTime = now().getTime();
      const pairingAvailable = Boolean(state.pendingPairing && currentTime < new Date(state.pendingPairing.expiresAt).getTime());
      return {
        paired: state.devices.some((device) => !device.revokedAt && currentTime < new Date(device.expiresAt).getTime()),
        pairingAvailable,
        ...(pairingAvailable ? { pairingAttemptId: state.pendingPairing.id } : {})
      };
    },

    async createPairing({ origin }) {
      const createdAt = now();
      const attemptId = base64Url(random(18));
      const qrSecret = base64Url(random(32));
      const code = pairingCode(random(8));
      const codeSalt = base64Url(random(16));
      const expiresAt = new Date(createdAt.getTime() + PAIRING_LIFETIME_MS).toISOString();
      const qrPayload = createDevicePairingPayload({ origin, attemptId, secret: qrSecret, expiresAt });

      await mutateState({ securityDir, statePath, lockPath }, (state) => {
        state.pendingPairing = {
          id: attemptId,
          codeSalt,
          codeHash: pairingCodeHash(code, codeSalt),
          qrSecretHash: secretHash(qrSecret),
          createdAt: createdAt.toISOString(),
          expiresAt,
          failedAttempts: 0
        };
      });

      return {
        attemptId,
        code: formatPairingCode(code),
        expiresAt,
        qrPayload
      };
    },

    async completePairing({ attemptId, code, qrSecret, label }) {
      return mutateState({ securityDir, statePath, lockPath }, (state) => {
        const pending = state.pendingPairing;
        if (!pending || pending.id !== attemptId) throw authError("pairing_unavailable");
        if (now().getTime() >= new Date(pending.expiresAt).getTime()) {
          state.pendingPairing = null;
          throw authError("pairing_expired", { persist: true });
        }

        const valid = qrSecret
          ? safeHashEqual(secretHash(qrSecret), pending.qrSecretHash)
          : safeHashEqual(pairingCodeHash(normalizePairingCode(code), pending.codeSalt), pending.codeHash);
        if (!valid) {
          pending.failedAttempts += 1;
          if (pending.failedAttempts >= MAX_PAIRING_FAILURES) state.pendingPairing = null;
          throw authError(pending.failedAttempts >= MAX_PAIRING_FAILURES ? "pairing_locked" : "pairing_invalid", { persist: true });
        }

        const issued = issueDeviceCredential({ now: now(), random, label });
        state.devices.push(issued.record);
        state.pendingPairing = null;
        return { device: publicDevice(issued.record, issued.record.id), credential: issued.credential };
      });
    },

    async authenticate(credential) {
      const parsed = parseCredential(credential);
      const state = await loadState(statePath);
      const device = parsed && state.devices.find((item) => item.id === parsed.deviceId);
      if (!device || !device.tokenHash || !safeHashEqual(secretHash(credential), device.tokenHash)) {
        throw authError("authentication_invalid");
      }
      if (device.revokedAt) throw authError("authentication_revoked");
      if (now().getTime() >= new Date(device.expiresAt).getTime()) throw authError("authentication_expired");
      return publicDevice(device, device.id);
    },

    async rotateDevice(deviceId) {
      return mutateState({ securityDir, statePath, lockPath }, (state) => {
        const device = state.devices.find((item) => item.id === deviceId && !item.revokedAt);
        if (!device) throw authError("device_not_found");
        const secret = base64Url(random(32));
        const credential = `v1.${device.id}.${secret}`;
        const rotatedAt = now();
        device.tokenHash = secretHash(credential);
        device.rotatedAt = rotatedAt.toISOString();
        device.expiresAt = new Date(rotatedAt.getTime() + DEVICE_LIFETIME_MS).toISOString();
        return { device: publicDevice(device, device.id), credential };
      });
    },

    async revokeDevice(deviceId) {
      return mutateState({ securityDir, statePath, lockPath }, (state) => {
        const device = state.devices.find((item) => item.id === deviceId);
        if (!device) throw authError("device_not_found");
        if (!device.revokedAt) device.revokedAt = now().toISOString();
        device.tokenHash = null;
        return publicDevice(device);
      });
    },

    async listDevices(currentDeviceId) {
      const state = await loadState(statePath);
      return state.devices.map((device) => publicDevice(device, currentDeviceId));
    }
  };
}

async function mutateState(paths, change) {
  return withFileLock(paths.securityDir, paths.lockPath, async () => {
    const state = await loadState(paths.statePath);
    try {
      const result = await change(state);
      await writeState(paths.securityDir, paths.statePath, state);
      return result;
    } catch (error) {
      if (error.persist) await writeState(paths.securityDir, paths.statePath, state);
      throw error;
    }
  });
}

async function loadState(path) {
  let text;
  try {
    text = await readFile(path, "utf8");
  } catch (error) {
    if (error.code === "ENOENT") return { schemaVersion: AUTH_STATE_SCHEMA_VERSION, pendingPairing: null, devices: [] };
    throw authError("device_auth_state_unavailable");
  }

  try {
    const state = JSON.parse(text);
    if (state?.schemaVersion !== AUTH_STATE_SCHEMA_VERSION || !Array.isArray(state.devices)) throw new Error();
    return state;
  } catch {
    throw authError("device_auth_state_invalid");
  }
}

async function writeState(directory, path, state) {
  await mkdir(directory, { recursive: true });
  const temporary = join(directory, `device-auth.${process.pid}.${randomUUID()}.pending`);
  let handle;
  try {
    handle = await open(temporary, "wx");
    await handle.writeFile(JSON.stringify(state));
    await handle.sync();
    await handle.close();
    handle = null;
    await rename(temporary, path);
  } finally {
    await handle?.close().catch(() => {});
    await rm(temporary, { force: true }).catch(() => {});
  }
}

async function withFileLock(directory, lockPath, operation) {
  await mkdir(directory, { recursive: true });
  const deadline = Date.now() + 3000;
  let handle;
  while (!handle) {
    try {
      handle = await open(lockPath, "wx");
    } catch (error) {
      if (error.code !== "EEXIST") throw authError("device_auth_state_unavailable");
      await removeStaleLock(lockPath);
      if (Date.now() >= deadline) throw authError("device_auth_state_busy");
      await new Promise((resolve) => setTimeout(resolve, 20));
    }
  }
  try {
    return await operation();
  } finally {
    await handle.close().catch(() => {});
    await unlink(lockPath).catch(() => {});
  }
}

async function removeStaleLock(lockPath) {
  try {
    const details = await stat(lockPath);
    if (Date.now() - details.mtimeMs > 30000) await unlink(lockPath);
  } catch {
    // Another process released the lock.
  }
}

function issueDeviceCredential({ now, random, label }) {
  const id = base64Url(random(18));
  const secret = base64Url(random(32));
  const credential = `v1.${id}.${secret}`;
  return {
    credential,
    record: {
      id,
      label: safeDeviceLabel(label),
      tokenHash: secretHash(credential),
      createdAt: now.toISOString(),
      expiresAt: new Date(now.getTime() + DEVICE_LIFETIME_MS).toISOString(),
      revokedAt: null
    }
  };
}

function parseCredential(value) {
  if (typeof value !== "string" || value.length > 256) return null;
  const match = value.match(/^v1\.([A-Za-z0-9_-]{16,96})\.([A-Za-z0-9_-]{32,128})$/);
  return match ? { deviceId: match[1] } : null;
}

function publicDevice(device, currentDeviceId) {
  return {
    id: device.id,
    label: device.label,
    createdAt: device.createdAt,
    expiresAt: device.expiresAt,
    revoked: Boolean(device.revokedAt),
    current: device.id === currentDeviceId
  };
}

function pairingCode(bytes) {
  let value = 0n;
  for (const byte of bytes) value = (value << 8n) | BigInt(byte);
  let output = "";
  for (let index = 0; index < 12; index += 1) {
    output = CODE_ALPHABET[Number(value & 31n)] + output;
    value >>= 5n;
  }
  return output;
}

function formatPairingCode(value) {
  return value.match(/.{1,4}/g).join("-");
}

function normalizePairingCode(value) {
  return typeof value === "string" ? value.replace(/[-\s]/g, "").toUpperCase() : "";
}

function pairingCodeHash(code, salt) {
  return base64Url(scryptSync(code, salt, 32));
}

function secretHash(value) {
  return createHash("sha256").update(value).digest("base64url");
}

function safeHashEqual(left, right) {
  try {
    const leftBytes = Buffer.from(left, "base64url");
    const rightBytes = Buffer.from(right, "base64url");
    return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
  } catch {
    return false;
  }
}

function safeDeviceLabel(value) {
  const label = typeof value === "string" ? value.trim().replace(/[\r\n\t]/g, " ") : "";
  return label.slice(0, 64) || "Mobile device";
}

function base64Url(value) {
  return Buffer.from(value).toString("base64url");
}

function authError(code, options = {}) {
  const error = new Error(code);
  error.safeLogCode = code;
  if (options.persist) error.persist = true;
  return error;
}
