// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
// officer-headscale sidecar returns under /api/headscale/_officer/* — deliberately NOT Headscale's own API
// shapes. The sidecar absorbs Headscale's quirks (uint64-as-string ids, zero-date sentinels, the version
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
// version. See src/servers/sidecar/headscale/routes.ts.

export const HEADSCALE_SECTIONS = [
  { id: 'servers', label: 'Servers' },
  { id: 'nodes', label: 'Nodes' },
  { id: 'users', label: 'Users' },
  { id: 'keys', label: 'Pre-auth keys' },
  { id: 'invites', label: 'Device invites' },
  { id: 'policy', label: 'Access policy' },
  { id: 'diagnostics', label: 'Diagnostics' },
  { id: 'console', label: 'Console' },
] as const;

export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];

/** Where /headscale lands, and where an unrecognised section redirects to. */
export const DEFAULT_HEADSCALE_SECTION: HeadscaleSectionId = 'servers';

export const isHeadscaleSection = (value: string | undefined): value is HeadscaleSectionId =>
  HEADSCALE_SECTIONS.some((s) => s.id === value);

/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
export const headscaleSectionPath = (id: HeadscaleSectionId) => `/headscale/${id}`;

/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
export type HeadscaleServer = {
  id: number;
  name: string;
  url: string;
  version: string | null;
  /**
   * Where the Console section SSHes. Null when unset. Not derived from `url` on purpose — it exists to reach
   * the machine when the control plane's own hostname has stopped answering.
   */
  sshHost: string | null;
  isActive: boolean;
  /** ISO string, or null when we have never successfully probed it. */
  lastSeenAt: string | null;
  createdAt: string;
};

/** Result of GET /_officer/servers/:id/health — reachable AND the stored key still works. */
export type HeadscaleHealth = {
  ok: boolean;
  version?: string;
  /** `'unknown'` for self-built servers reporting the literal 'dev'. */
  supported?: boolean | 'unknown';
  error?: string;
  ms: number;
};

/** Result of POST /_officer/ssh-test — can we open a shell there with the keys already on this box. */
export type HeadscaleSshTest = { ok: boolean; error?: string; ms: number };

/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
export const MIN_HEADSCALE_VERSION = '0.29';

// ── Access policy ─────────────────────────────────────────────────────────────────────────────────

/**
 * The tailnet's ACL document, in HuJSON (JSON with comments and trailing commas). Headscale serves it
 * whether it is stored in the database or read from a file — so this carries no "is it editable" flag,
 * because there is nothing on the server that reports one. Only an attempted save finds out.
 */
export type HeadscalePolicy = {
  policy: string;
  /** Null when Headscale has never recorded one, which includes every file-backed policy. */
  updatedAt: string | null;
};

/** Headscale refused the write outright — this server's policy is read-only over the API. */
export const POLICY_READ_ONLY = 'policy_read_only';
/** Headscale parsed the document and rejected it. The message is a syntax position or a bad reference. */
export const POLICY_REJECTED = 'policy_rejected';

// ── Companion API ─────────────────────────────────────────────────────────────────────────────────
// The Officer Companion is a service deployed next to a Headscale server that can see the container the
// admin API is served from: whether it is running, what it logged, and start/stop/restart. It is optional
// and per-server, so `available: false` is a first-class state rather than an error — the admin API on the
// same domain is independent and may still work. Contract: COMMS/HEADSCALE_COMPANION_API.md.

/** Never available for a companion that is missing — the reason says which flavour of missing. */
type Unavailable = { available: false; reason: string };

export type CompanionVerdict = 'ok' | 'degraded' | 'down' | 'unknown';

export type CompanionContainer = {
  status: string;
  running: boolean;
  exitCode: number;
  restartCount: number;
  startedAt: string;
  /** The RFC3339 zero date (`0001-…`) while the container is running. */
  finishedAt: string;
  /** Null when the image defines no healthcheck. */
  healthcheck: string | null;
};

export type CompanionHealthBody = {
  verdict: CompanionVerdict;
  /** Whether Headscale's own HTTP is answering — the "is the control plane serving?" signal. */
  connected: boolean;
  container?: CompanionContainer;
  /** Human string for the probe outcome, e.g. `GET /health -> 200`, `unreachable`, `container not running`. */
  probe?: string;
  /** Only on `unknown`: docker has no container by that name. */
  reason?: string;
  /** Only when not ok — best-effort guesses read out of the logs. May be empty. */
  likelyCauses?: string[];
  healthcheckOutput?: string | null;
  recentLogs?: string[];
};

export type CompanionHealthResult = ({ available: true } & { health: CompanionHealthBody }) | Unavailable;
export type CompanionLogsResult = { available: true; lines: string[] } | Unavailable;

/** The three lifecycle verbs. `stop`/`start` are what the companion calls `disconnect`/`reconnect`. */
export type CompanionAction = 'restart' | 'stop' | 'start';

export type CompanionActionResult =
  | { available: true; ok: boolean; action?: string; result?: string; error?: string }
  | Unavailable;

// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
// Ids are strings because Headscale's are uint64 — never parse them to numbers.

export type HeadscaleUser = {
  id: string;
  name: string;
  displayName: string | null;
  email: string | null;
  provider: string | null;
  profilePicUrl: string | null;
  createdAt: string | null;
};

export type HeadscaleUserWithCounts = HeadscaleUser & { nodeCount: number; onlineCount: number };

export type HeadscaleNode = {
  id: string;
  name: string;
  hostname: string;
  user: HeadscaleUser | null;
  ipAddresses: string[];
  online: boolean;
  lastSeen: string | null;
  /** Null means the node's key never expires. */
  expiry: string | null;
  createdAt: string | null;
  registerMethod: string;
  tags: string[];
  /** What the node advertises. */
  availableRoutes: string[];
  /** What the admin has approved — the writable set. */
  approvedRoutes: string[];
  /** What is actually in effect. */
  subnetRoutes: string[];
  isExitNode: boolean;
};

export type HeadscalePreAuthKey = {
  id: string;
  /** Non-null ONLY on the creation response — the sidecar strips the secret from every list. */
  key: string | null;
  /** A never-usable label for telling keys apart in a list, e.g. `hskey-auth-a1b2c3-***`. */
  keyDisplay: string;
  user: HeadscaleUser | null;
  reusable: boolean;
  ephemeral: boolean;
  used: boolean;
  expiration: string | null;
  createdAt: string | null;
  aclTags: string[];
  status: 'active' | 'used' | 'expired';
};

/** The sidecar's 409 when no server is selected, distinguished from a genuine 404. */
export const NO_ACTIVE_SERVER = 'no_active_server';

// ── Device invites ────────────────────────────────────────────────────────────────────────────────
// An invite is a link the admin sends to whoever needs to join. The pre-auth key is minted when the link is
// claimed, not when it is created, so an unused invite never has a credential attached to it. Contract:
// COMMS/OFFSCALE_INVITE_ENROLLMENT.md; the records live on the server's companion, never in Officer.

export type InviteStatus = 'pending' | 'claimed' | 'expired' | 'revoked';

/** What the admin list returns. It carries no claim token and no key — by design, at every status. */
export type HeadscaleInvite = {
  id: string;
  user: string;
  note?: string | null;
  status: InviteStatus;
  ephemeral?: boolean;
  tags?: string[];
  createdAt?: string | null;
  expiresAt?: string | null;
  claimedAt?: string | null;
  claimedFromIp?: string | null;
};

/**
 * The create response — the ONLY time the link exists. Its fragment holds the claim token, so it is held in
 * component state, shown once, and never written to a cache, a query key or a log.
 */
export type HeadscaleInviteCreated = HeadscaleInvite & { url: string };

export type InviteCreateInput = {
  user: string;
  ttlSeconds: number;
  ephemeral: boolean;
  tags: string[];
  note: string;
};

export type InvitesListResult = { available: true; invites: HeadscaleInvite[] } | Unavailable;
export type InviteCreateResult = { available: true; invite: HeadscaleInviteCreated } | Unavailable;

/** Spec §4.1. The floor is Officer's: a sub-minute invite cannot be sent to anybody in time. */
export const INVITE_TTL_MIN_SECONDS = 60;
export const INVITE_TTL_DEFAULT_SECONDS = 900;
export const INVITE_TTL_MAX_SECONDS = 86_400;
