// InvoiceShelf upstream config + the one function that talks to it.
//
// All knowledge of the InvoiceShelf instance — its URL, its API token and which company the token acts on
// behalf of — lives here, mirroring officer-transmission/officer-slskd: the platform API is a thin
// auth+forward proxy and holds NO InvoiceShelf credentials.
//
// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `invoiceshelf_accounts` (see
// databases/officer_db/src/invoiceshelf/queries.ts). It is deliberately no longer read from the environment:
// Bun auto-loads `.env` into every process started in the platform directory, so an `INVOICESHELF_TOKEN`
// there was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no
// code to use it and the largest attack surface in the system. Nothing in this file reads process.env.
//
// Three things about InvoiceShelf's API are load-bearing and easy to get wrong:
//
//   1. `Accept: application/json` is MANDATORY. Without it Laravel's Authenticate middleware answers an
//      unauthenticated request with a 302 to an HTML login page instead of a 401, so every error turns into
//      an unparseable redirect.
//   2. NEVER send Origin or Referer. InvoiceShelf's `statefulApi()` middleware treats a request carrying
//      either as a first-party browser call and switches from token auth to session+CSRF, which then fails
//      419. Bun's fetch adds neither on its own; the platform proxy in api/invoiceshelf/router.ts
//      deliberately forwards neither. Don't add them.
//   3. The `company` header (lowercase, a bare numeric id) scopes almost every route — and a wrong or
//      missing value does NOT error. It silently falls back to another company's data. That is why the
//      company is PINNED ON THE ACCOUNT ROW and chosen once when the account is added, rather than resolved
//      per request from whatever the token happens to see first.

import { getActiveInvoiceshelfCredentials } from 'officerdb';

/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
export type UpstreamTarget = { base: string; token: string; companyId: number | null };

/**
 * A stored account, which is where every real call goes.
 *
 * `id` and `label` ride along because the owner can have several accounts registered and only one selected:
 * a probe has to be recorded against the row it actually reached, and a log line saying which books answered
 * is the difference between "invoices is broken" and "you are looking at the other company".
 */
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };

/** Trailing slashes off, so `${base}/api/v1/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');

// A list view is a burst of requests and each one needs the token, so the row is cached rather than re-read
// per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql, which
// then takes effect within a minute instead of needing a restart.
const TTL_MS = 60_000;
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();

/**
 * The owner's SELECTED account, or null when /invoices has no account yet — the sidecar then answers 503,
 * and the UI turns that into the setup form rather than a screen of empty tables.
 */
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
  const hit = cache.get(userId);
  if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;

  const creds = await getActiveInvoiceshelfCredentials(userId);
  const cfg = creds
    ? {
        id: creds.id,
        label: creds.label,
        base: normalizeBase(creds.url),
        token: creds.token,
        companyId: creds.companyId,
      }
    : null;
  cache.set(userId, { cfg, at: Date.now() });
  return cfg;
}

/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
export function invalidateConfig(userId: number): void {
  cache.delete(userId);
  hashCache.clear();
}

export type Company = { id: number; name: string | null; unique_hash?: string | null };

/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */
export class UpstreamError extends Error {
  constructor(
    message: string,
    readonly status: number,
  ) {
    super(message);
  }
}

/**
 * Trade an email and password for a Sanctum token.
 *
 * InvoiceShelf 2.4.2 has no screen that issues personal access tokens — `POST /api/v1/auth/login` (the
 * controller upstream files under "Mobile") is the only way to get one, which meant connecting Officer
 * required a curl command. Doing it here instead is the whole point: the password is used for this one call
 * and never stored, and only the token it returns is persisted.
 *
 * Each call mints a NEW token upstream; the previous one is deliberately left alone, because Officer cannot
 * know whether the owner also uses it somewhere else. Old ones are revoked from InvoiceShelf, not from here.
 */
export async function mintToken(base: string, email: string, password: string): Promise<string> {
  const res = await fetch(`${base}/api/v1/auth/login`, {
    method: 'POST',
    // No Origin or Referer — see the header note. `device_name` names the token in InvoiceShelf's own list.
    headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
    body: JSON.stringify({ username: email, password, device_name: 'officer' }),
    redirect: 'manual',
  });
  if (!res.ok) throw new UpstreamError(`sign-in failed with ${res.status}`, res.status);
  const payload = (await res.json()) as { token?: unknown };
  if (typeof payload.token !== 'string' || !payload.token) {
    throw new UpstreamError('sign-in returned no token', res.status);
  }
  return payload.token;
}

/**
 * Every company this token can act for. Asked once when an account is added, so the owner picks explicitly
 * instead of inheriting whichever one the API happens to list first.
 *
 * This is also the FIRST call that actually exercises the token: `/app/version` is an unauthenticated route,
 * so it answers 200 to a wrong token, an empty one or a revoked one. A 401 here means the token, not the URL.
 */
export async function listCompanies(cfg: UpstreamTarget): Promise<Company[]> {
  const res = await fetch(`${cfg.base}/api/v1/companies`, {
    headers: { Authorization: `Bearer ${cfg.token}`, Accept: 'application/json' },
    redirect: 'manual',
  });
  if (!res.ok) throw new UpstreamError(`companies lookup failed with ${res.status}`, res.status);
  const payload = (await res.json()) as { data?: Array<{ id?: number; name?: string }> };
  return (payload.data ?? [])
    .filter((c): c is { id: number; name?: string } => typeof c.id === 'number')
    .map((c) => ({ id: c.id, name: c.name ?? null }));
}

// Keyed by account id, because two accounts can be two companies on one instance and the hash is per company.
const hashCache = new Map<number, Promise<string>>();

/**
 * The pinned company's `unique_hash`. The report PDFs live on web routes keyed by it
 * (`/reports/sales/customers/{hash}`), not by company id, so it has to be looked up and is worth caching.
 */
export async function resolveCompanyHash(cfg: UpstreamConfig): Promise<string> {
  const cached = hashCache.get(cfg.id);
  if (cached) return cached;

  const pending = (async () => {
    const res = await callUpstream(cfg, { path: '/api/v1/current-company' });
    if (!res.ok) throw new Error(`current-company lookup failed with ${res.status}`);
    const payload = (await res.json()) as { data?: { unique_hash?: string } };
    const hash = payload.data?.unique_hash;
    if (!hash) throw new Error('current-company returned no unique_hash');
    return hash;
  })().catch((err) => {
    // Clear on failure so the next request retries instead of latching a transient network error forever.
    hashCache.delete(cfg.id);
    throw err;
  });

  hashCache.set(cfg.id, pending);
  return pending;
}

type CallOptions = {
  /** Absolute path on the InvoiceShelf host, e.g. `/api/v1/invoices`. */
  path: string;
  method?: string;
  /** Raw search string including the leading `?`, or empty. */
  query?: string;
  body?: BodyInit | null;
  contentType?: string | null;
  /** Defaults to `application/json`; PDF routes ask for the bytes instead. */
  accept?: string;
  /** Set false for the handful of routes scoped to the user rather than a company. */
  withCompany?: boolean;
};

/** The single door to InvoiceShelf. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
  const headers: Record<string, string> = {
    Authorization: `Bearer ${cfg.token}`,
    Accept: opts.accept ?? 'application/json',
  };

  if (opts.withCompany !== false && cfg.companyId != null) headers.company = String(cfg.companyId);
  if (opts.contentType) headers['Content-Type'] = opts.contentType;

  const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
    method: opts.method ?? 'GET',
    headers,
    body: opts.body ?? undefined,
    // A 302 here means the token was rejected and Laravel is redirecting to the HTML login. Following it
    // would turn a clean 401 into a 200 full of HTML, so we surface the redirect instead.
    redirect: 'manual',
  });

  if (res.status === 401 || res.status === 419 || (res.status >= 300 && res.status < 400)) {
    console.warn(`[invoiceshelf] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (token rejected or stateful)`);
  }

  return res;
}
