import type { Context, MiddlewareHandler } from 'hono';
import { mkdirSync, statSync, renameSync } from 'node:fs';
import { appendFile } from 'node:fs/promises';
import { join } from 'node:path';
import { DATA_PATH } from '../data-path';
import { isKnownIdentity, knownIdentityCount } from './known-users';

// Every attempt to authenticate as somebody this platform has never heard of, written to a file.
//
// Officer has exactly one account and is reachable from the public internet, so a request naming any
// other identity is never a mistyped login — it is somebody working through a list. That is worth a
// durable record, and a durable record is the point: this is deliberately a FILE, not a table. It has
// to survive the database being unreachable, has to be readable with `tail` from a terminal at 3am,
// and has to be one `grep` away from an IP. JSON lines, appended, rotated once at 5 MB.
//
// WHAT IS NEVER WRITTEN: the password, or anything else credential-shaped. The attempt is the signal;
// the secret adds nothing to it and would turn this file into a thing worth stealing. Bodies are
// recorded as their KEY NAMES plus the identity fields — see safeBody(). Headers are recorded in full
// except authorization/cookie, which become `[redacted:<length>]`, because how long a bearer somebody
// forged is occasionally interesting and its bytes never are.
//
// The middleware only ever observes. It cannot reject, cannot change a response, and swallows its own
// failures — a full disk must not be able to lock the owner out of their own platform.

const LOG_DIR = join(DATA_PATH, 'logs');
const LOG_PATH = join(LOG_DIR, 'auth-attempts.log');
const ROTATED_PATH = join(LOG_DIR, 'auth-attempts.1.log');
const MAX_BYTES = 5 * 1024 * 1024;

/** Header names whose VALUE is a credential. Kept as a set of exact lowercase names, not a regex. */
const SECRET_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization', 'x-api-key']);

/** Body keys safe to record verbatim. Everything else contributes its name only. */
const PUBLIC_BODY_KEYS = new Set(['email', 'username', 'name']);

// Appends are chained rather than fired in parallel: two concurrent probes must not interleave halves
// of two JSON lines into one unparseable one.
let writeQueue: Promise<void> = Promise.resolve();
let dirReady = false;

function rotateIfNeeded() {
  try {
    if (statSync(LOG_PATH).size < MAX_BYTES) return;
    renameSync(LOG_PATH, ROTATED_PATH);
  } catch {
    /* no file yet, or the rename lost a race — either way the append below still works */
  }
}

function writeRecord(record: Record<string, unknown>) {
  writeQueue = writeQueue
    .then(async () => {
      if (!dirReady) {
        mkdirSync(LOG_DIR, { recursive: true });
        dirReady = true;
      }
      rotateIfNeeded();
      await appendFile(LOG_PATH, `${JSON.stringify(record)}\n`, 'utf8');
    })
    .catch((err) => {
      console.error('[auth-audit] could not write the attempt log:', String(err));
    });
}

const headerSnapshot = (ctx: Context): Record<string, string> => {
  const out: Record<string, string> = {};
  for (const [name, value] of Object.entries(ctx.req.header())) {
    out[name] = SECRET_HEADERS.has(name.toLowerCase()) ? `[redacted:${value.length}]` : value;
  }
  return out;
};

/** Key names always; values only for the fields that identify rather than authenticate. */
function safeBody(body: Record<string, unknown> | undefined): { keys: string[]; values: Record<string, string> } {
  const keys = body ? Object.keys(body) : [];
  const values: Record<string, string> = {};
  for (const key of keys) {
    if (!PUBLIC_BODY_KEYS.has(key)) continue;
    const value = body?.[key];
    if (typeof value === 'string') values[key] = value.slice(0, 200);
  }
  return { keys, values };
}

/**
 * The identity a request is claiming. `POST /signin` and friends carry it in the body; the passkey
 * routes put it in the last path segment (`/passkeys/signin/:email`). Read from the path directly
 * rather than through `ctx.req.param`, which is not populated for a middleware mounted at the router
 * root — the value would silently be undefined for exactly the unauthenticated routes that matter.
 */
function claimedIdentity(ctx: Context, path: string): string | null {
  const body = ctx.get('body') as Record<string, unknown> | undefined;
  for (const field of ['email', 'username'] as const) {
    const value = body?.[field];
    if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 320);
  }

  if (!path.includes('/auth/passkeys/')) return null;
  const last = decodeURIComponent(path.split('/').pop() ?? '');
  return last.includes('@') ? last.slice(0, 320) : null;
}

const clientAddress = (ctx: Context): string =>
  ctx.req.header('x-forwarded-for')?.split(',')[0]?.trim() || ctx.req.header('x-real-ip') || 'unknown';

/**
 * Mount on the auth router directly after `bodyParser()` and before everything else, so it wraps origin
 * validation and the rate limiter too — a probe rejected at the origin check is precisely the one the
 * owner wants a record of, and it never reaches a handler.
 */
export const authAudit: MiddlewareHandler = async (ctx, next) => {
  const started = Date.now();
  let failure: unknown = null;
  try {
    await next();
  } catch (err) {
    failure = err;
    throw err;
  } finally {
    const url = new URL(ctx.req.url);
    const identity = claimedIdentity(ctx, url.pathname);

    // No name claimed → nothing to classify. Signout, /me and the token routes authenticate with a
    // bearer, and their failures are the userMiddleware's business, not this file's.
    if (identity) {
      // Deliberately after next(): the handler may be slow, and blocking a login on a lookup that only
      // decides whether to write a log line would be the wrong trade. The set is in memory anyway.
      void isKnownIdentity(identity)
        .then((known) => {
          if (known) return;
          const body = safeBody(ctx.get('body') as Record<string, unknown> | undefined);
          const record = {
            ts: new Date().toISOString(),
            event: 'auth.unknown-identity',
            identity,
            method: ctx.req.method,
            path: url.pathname,
            query: url.search || null,
            status: failure ? 'threw' : ctx.res.status,
            ms: Date.now() - started,
            ip: clientAddress(ctx),
            forwardedFor: ctx.req.header('x-forwarded-for') ?? null,
            realIp: ctx.req.header('x-real-ip') ?? null,
            host: ctx.req.header('host') ?? null,
            origin: ctx.req.header('origin') ?? null,
            referer: ctx.req.header('referer') ?? null,
            userAgent: ctx.req.header('user-agent') ?? null,
            acceptLanguage: ctx.req.header('accept-language') ?? null,
            contentType: ctx.req.header('content-type') ?? null,
            bodyKeys: body.keys,
            bodyValues: body.values,
            headers: headerSnapshot(ctx),
            knownIdentities: knownIdentityCount(),
            error: failure ? String((failure as { message?: unknown })?.message ?? failure).slice(0, 300) : null,
          };
          writeRecord(record);
          // Also to the process log, one line, so it surfaces in `pm2 logs officer` without anyone
          // having to know this file exists.
          console.warn(
            `[auth-audit] unknown identity "${identity}" from ${record.ip} on ${record.method} ${url.pathname}`,
          );
        })
        .catch(() => {
          /* classification failed; never let it touch the response */
        });
    }
  }
};

export const AUTH_AUDIT_LOG_PATH = LOG_PATH;
