import { randomUUID, randomBytes } from 'node:crypto';

// Generates the iOS configuration profile that sets up CalDAV + CardDAV in one install, and holds it
// against a single-use token until Safari fetches it.
//
// WHY A TOKEN AND NOT A SESSION. The profile is fetched by Safari, which has no platform JWT — the app
// hands the URL to the OS browser and loses all control of the request. So the token IS the credential.
// It is therefore minted long, used once, and dropped after five minutes, because the thing it protects
// is a live DAV password sitting in a file on a public endpoint. A profile URL that stays valid is a
// password that stays valid.
//
// WHY IN MEMORY. The profile contains the app password in PLAINTEXT — it has to, that is what makes the
// install one-tap. `createDavAppPassword` promises the plaintext "is not stored", and writing the
// profile to Postgres or to disk would quietly make that false. A process restart inside the five-minute
// window costs the owner one extra tap; persisting the secret costs the guarantee.
//
// See docs/mobile-dav-provisioning.md §3 for the payload keys and where they come from.

const TTL_MS = 5 * 60_000;
// A restart clears everything anyway; this is only a guard against a script hammering the mint endpoint.
const MAX_PENDING = 32;

type PendingProfile = { body: Uint8Array; expiresAt: number };

const pending = new Map<string, PendingProfile>();

const sweep = () => {
  const now = Date.now();
  for (const [token, entry] of pending) if (entry.expiresAt <= now) pending.delete(token);
};

const escapeXml = (value: string) =>
  value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

type ProfileParams = {
  host: string;
  port: number;
  username: string;
  password: string;
  /** The DAV principal, e.g. `/dav/1/`. Both payloads point at it and let the client discover the rest. */
  principalUrl: string;
  /** What the user sees as the account name in Settings. */
  accountName: string;
};

// One profile, both payloads. This is not a convenience: iOS keys accounts by server+username, so adding
// CardDAV separately with the same pair gets folded into the existing CalDAV account and contacts
// silently never appear. A single profile creates both accounts outright and sidesteps it. Detail in
// docs/mobile-dav-provisioning.md §1.6, which cost an evening to learn.
function buildPlist(params: ProfileParams): string {
  const { host, port, username, password, principalUrl, accountName } = params;
  // Stable per host, so re-running setup REPLACES the profile instead of stacking a second account, and
  // two Officer instances on one phone do not overwrite each other.
  const identifier = `dev.officer.dav.${host.replace(/[^a-zA-Z0-9]+/g, '-')}`;
  const e = escapeXml;

  const account = (kind: 'CalDAV' | 'CardDAV', label: string) => `    <dict>
      <key>PayloadType</key><string>com.apple.${kind.toLowerCase()}.account</string>
      <key>PayloadVersion</key><integer>1</integer>
      <key>PayloadIdentifier</key><string>${identifier}.${kind.toLowerCase()}</string>
      <key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
      <key>PayloadDisplayName</key><string>${e(label)}</string>
      <key>${kind}AccountDescription</key><string>${e(accountName)}</string>
      <key>${kind}HostName</key><string>${e(host)}</string>
      <key>${kind}Port</key><integer>${port}</integer>
      <key>${kind}UseSSL</key><true/>
      <key>${kind}Username</key><string>${e(username)}</string>
      <key>${kind}Password</key><string>${e(password)}</string>
      <key>${kind}PrincipalURL</key><string>${e(principalUrl)}</string>
    </dict>`;

  return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>PayloadType</key><string>Configuration</string>
  <key>PayloadVersion</key><integer>1</integer>
  <key>PayloadIdentifier</key><string>${identifier}</string>
  <key>PayloadUUID</key><string>${randomUUID().toUpperCase()}</string>
  <key>PayloadDisplayName</key><string>${e(accountName)} — Calendar &amp; Contacts</string>
  <key>PayloadDescription</key><string>Adds your Officer calendars and address book to this device.</string>
  <key>PayloadOrganization</key><string>Officer</string>
  <key>PayloadScope</key><string>User</string>
  <key>PayloadRemovalDisallowed</key><false/>
  <key>PayloadContent</key>
  <array>
${account('CalDAV', 'Officer Calendar')}
${account('CardDAV', 'Officer Contacts')}
  </array>
</dict>
</plist>
`;
}

// Signing is OPT-IN and off by default, because this machine has no TLS certificate on it — TLS
// terminates upstream. An unsigned profile installs exactly the same way; the only difference is that
// the install screen says "Not Signed" in red instead of naming the signer.
//
// Point these at a cert whose chain iOS already trusts (a public CA — the TLS cert of the reverse proxy
// works, iOS checks the trust chain and not the key usage) and profiles are signed from then on:
//   DAV_PROFILE_SIGN_CERT   leaf certificate, PEM
//   DAV_PROFILE_SIGN_KEY    its private key, PEM
//   DAV_PROFILE_SIGN_CHAIN  intermediates, PEM (iOS ships roots, not intermediates — without this the
//                           signature cannot be verified and shows as Not Verified)
//
// Signed at mint time, reading the cert from disk on every call, so a renewed certificate is picked up
// with no restart and no renewal hook — the failure mode the mobile team flagged cannot happen here.
// The remaining caveat is Apple's: a REPLACEMENT profile must be signed by the same identity as the one
// it replaces, so after a certificate rotation a device may refuse to replace an older profile until the
// old one is removed. Nothing detects that for you.
const signingConfig = () => {
  const cert = process.env.DAV_PROFILE_SIGN_CERT;
  const key = process.env.DAV_PROFILE_SIGN_KEY;
  if (!cert || !key) return null;
  return { cert, key, chain: process.env.DAV_PROFILE_SIGN_CHAIN };
};

let warnedUnsigned = false;

async function sign(plist: string): Promise<Uint8Array | null> {
  const config = signingConfig();
  if (!config) {
    if (!warnedUnsigned) {
      warnedUnsigned = true;
      console.warn(
        '[dav] serving UNSIGNED iOS profiles — iOS will show "Not Signed" in red. Set DAV_PROFILE_SIGN_CERT/KEY to sign.',
      );
    }
    return null;
  }

  // Piped through stdin/stdout rather than temp files: the plist holds a live password in plaintext and
  // there is no reason for it to touch the filesystem. `-outform der` and `-nodetach` are both
  // load-bearing — the default output is base64 S/MIME, which iOS will not parse, and without -nodetach
  // the file is a signature with no profile embedded in it.
  const args = ['smime', '-sign', '-signer', config.cert, '-inkey', config.key, '-outform', 'der', '-nodetach'];
  if (config.chain) args.push('-certfile', config.chain);
  args.push('-md', 'sha256');

  const proc = Bun.spawn(['openssl', ...args], {
    stdin: new TextEncoder().encode(plist),
    stdout: 'pipe',
    stderr: 'pipe',
  });
  const [out, err, code] = await Promise.all([
    new Response(proc.stdout).arrayBuffer(),
    new Response(proc.stderr).text(),
    proc.exited,
  ]);

  if (code !== 0 || out.byteLength === 0) {
    // Deliberately not fatal. An unsigned profile still installs and still works; failing the whole
    // provisioning request over a cosmetic signature would be the worse outcome.
    console.error(`[dav] profile signing failed (exit ${code}), serving unsigned: ${err.trim().slice(0, 200)}`);
    return null;
  }
  return new Uint8Array(out);
}

export type StoredProfile = { url: string; expiresAt: string; signed: boolean };

/** Build the profile and park it behind a one-shot token. Returns the URL Safari should be sent to. */
export async function stashIosProfile(params: ProfileParams, publicUrl: string): Promise<StoredProfile> {
  sweep();
  if (pending.size >= MAX_PENDING) throw new Error('too many pending profiles');

  const plist = buildPlist(params);
  const signed = await sign(plist);
  const body = signed ?? new TextEncoder().encode(plist);

  const token = randomBytes(32).toString('base64url');
  const expiresAt = Date.now() + TTL_MS;
  pending.set(token, { body, expiresAt });

  return {
    url: `${publicUrl.replace(/\/$/, '')}/dav/provision/${token}.mobileconfig`,
    expiresAt: new Date(expiresAt).toISOString(),
    signed: signed !== null,
  };
}

/** Take the profile for this token, if it is still valid. Consumes it — a second fetch gets nothing. */
export function claimIosProfile(token: string): Uint8Array | null {
  sweep();
  const entry = pending.get(token);
  if (!entry) return null;
  pending.delete(token);
  return entry.body;
}
