// Officer-shaped views of Headscale's admin API objects, and the quirk handling that gets us there.
//
// Headscale's REST layer is a gRPC gateway marshalling protobuf, which leaks in three ways we normalize
// here so nothing downstream has to know:
//
//  1. Every uint64 is a JSON STRING. Ids stay strings end to end — never Number() them, that breaks
//     silently above 2^53 and Headscale's ids are database-assigned, not small by contract.
//  2. Unset timestamps are the protobuf zero value, serialized as '0001-01-01T00:00:00Z' rather than
//     omitted. Rendered naively that reads as the year 1 — it means "never", so it becomes null.
//  3. EmitUnpopulated means absent repeated fields arrive as [] and absent messages as null; there is no
//     way to distinguish "unset" from "empty", so every accessor tolerates both.

/** Protobuf's zero timestamp. Headscale sends this for "never expires", "never seen", and friends. */
const ZERO_TIME = '0001-01-01T00:00:00Z';

/** An upstream timestamp as an ISO string, or null when it is unset/the protobuf zero value. */
export function isoOrNull(raw: unknown): string | null {
  if (typeof raw !== 'string' || !raw || raw === ZERO_TIME) return null;
  const ms = Date.parse(raw);
  if (Number.isNaN(ms)) return null;
  // Some builds emit years far outside anything meaningful; treat pre-1971 as the sentinel too.
  return ms < 31_536_000_000 ? null : new Date(ms).toISOString();
}

const str = (raw: unknown): string => (typeof raw === 'string' ? raw : '');
const strArray = (raw: unknown): string[] =>
  Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];

export type UpstreamUser = Record<string, unknown>;
export type UpstreamNode = Record<string, unknown>;
export type UpstreamPreAuthKey = Record<string, unknown>;

export type OfficerUser = {
  id: string;
  name: string;
  displayName: string | null;
  email: string | null;
  /** The OIDC provider, when the user came from one. Null for CLI/API-created users. */
  provider: string | null;
  profilePicUrl: string | null;
  createdAt: string | null;
};

export function toUser(raw: UpstreamUser | null | undefined): OfficerUser | null {
  if (!raw || typeof raw !== 'object') return null;
  const id = str(raw.id);
  if (!id) return null;
  return {
    id,
    name: str(raw.name),
    displayName: str(raw.displayName) || null,
    email: str(raw.email) || null,
    provider: str(raw.provider) || null,
    profilePicUrl: str(raw.profilePicUrl) || null,
    createdAt: isoOrNull(raw.createdAt),
  };
}

export type OfficerNode = {
  id: string;
  /** The name Headscale actually uses in the tailnet — givenName when set, otherwise the reported hostname. */
  name: string;
  hostname: string;
  user: OfficerUser | null;
  ipAddresses: string[];
  online: boolean;
  lastSeen: string | null;
  /** When the node's key expires and it must re-authenticate. Null means it never expires. */
  expiry: string | null;
  createdAt: string | null;
  /** How the node joined: 'authkey' | 'cli' | 'oidc' | 'unknown'. */
  registerMethod: string;
  tags: string[];
  /** Routes the node advertises. */
  availableRoutes: string[];
  /** The subset the admin has approved — the writable one. */
  approvedRoutes: string[];
  /** Routes actually in effect (approved ∩ available, as Headscale computes it). */
  subnetRoutes: string[];
  /** True when the node advertises an exit node route. Purely derived, for the UI's badge. */
  isExitNode: boolean;
};

const EXIT_ROUTES = new Set(['0.0.0.0/0', '::/0']);

const REGISTER_METHODS: Record<string, string> = {
  REGISTER_METHOD_AUTH_KEY: 'authkey',
  REGISTER_METHOD_CLI: 'cli',
  REGISTER_METHOD_OIDC: 'oidc',
};

export function toNode(raw: UpstreamNode): OfficerNode {
  const givenName = str(raw.givenName);
  const hostname = str(raw.name);
  const availableRoutes = strArray(raw.availableRoutes);
  return {
    id: str(raw.id),
    name: givenName || hostname,
    hostname,
    user: toUser(raw.user as UpstreamUser),
    ipAddresses: strArray(raw.ipAddresses),
    online: raw.online === true,
    lastSeen: isoOrNull(raw.lastSeen),
    expiry: isoOrNull(raw.expiry),
    createdAt: isoOrNull(raw.createdAt),
    registerMethod: REGISTER_METHODS[str(raw.registerMethod)] ?? 'unknown',
    tags: strArray(raw.tags),
    availableRoutes,
    approvedRoutes: strArray(raw.approvedRoutes),
    subnetRoutes: strArray(raw.subnetRoutes),
    isExitNode: availableRoutes.some((r) => EXIT_ROUTES.has(r)),
  };
}

export type OfficerPreAuthKey = {
  id: string;
  /**
   * The usable secret. Non-null ONLY on the creation response — the list path nulls it unconditionally,
   * so a secret can never reach the browser except at the moment it is created and must be shown once.
   */
  key: string | null;
  /** A never-usable label for identifying a key in a list, e.g. `hskey-auth-a1b2c3-***`. */
  keyDisplay: string;
  user: OfficerUser | null;
  reusable: boolean;
  ephemeral: boolean;
  used: boolean;
  expiration: string | null;
  createdAt: string | null;
  aclTags: string[];
  /** Derived lifecycle, so every surface agrees on what "spent" means. */
  status: 'active' | 'used' | 'expired';
};

/**
 * A display label that is never a usable secret.
 *
 * Headscale 0.28+ stores keys bcrypt-hashed and lists them already masked as `hskey-auth-<prefix>-***`.
 * But keys created BEFORE 0.28 are still plaintext in its database, and `PreAuthKey.Proto()` returns those
 * in full from the list endpoint "for backwards compatibility" — its own source carries a TODO about
 * hiding them. So a list response on a server with history in it really does contain live secrets. We mask
 * anything that isn't already masked rather than trusting the upstream to have done it.
 */
function displayLabel(key: string): string {
  if (!key) return '(no key)';
  if (key.endsWith('***')) return key;
  return `${key.slice(0, 6)}…-***`;
}

type ToPreAuthKeyOptions = {
  /**
   * True only on the creation response, where the secret is the entire point and exists nowhere else.
   * Everywhere else this is false and the secret is dropped before it can reach a cache or a browser.
   */
  reveal: boolean;
};

export function toPreAuthKey(raw: UpstreamPreAuthKey, { reveal }: ToPreAuthKeyOptions): OfficerPreAuthKey {
  const expiration = isoOrNull(raw.expiration);
  const reusable = raw.reusable === true;
  const used = raw.used === true;
  const key = str(raw.key);

  // A reusable key stays usable after a node has claimed it, so `used` alone doesn't mean spent.
  const expired = !!expiration && Date.parse(expiration) < Date.now();
  const status: OfficerPreAuthKey['status'] = expired ? 'expired' : used && !reusable ? 'used' : 'active';

  return {
    id: str(raw.id),
    key: reveal ? key || null : null,
    keyDisplay: displayLabel(key),
    user: toUser(raw.user as UpstreamUser),
    reusable,
    ephemeral: raw.ephemeral === true,
    used,
    expiration,
    createdAt: isoOrNull(raw.createdAt),
    aclTags: strArray(raw.aclTags),
    status,
  };
}

/** Read an array field out of a gateway response, tolerating the null/absent forms. */
export function arrayField(body: unknown, field: string): Record<string, unknown>[] {
  const value = (body as Record<string, unknown> | null)?.[field];
  return Array.isArray(value) ? (value.filter((v) => v && typeof v === 'object') as Record<string, unknown>[]) : [];
}
