/// <reference types="bun-types" />
/**
 * Curogram session manager.
 *
 * Curogram has NO API token. Auth is a cookie + XSRF (Angular convention):
 * the server sets session cookies on login, and every authenticated call
 * must echo the XSRF-TOKEN cookie value back in an X-XSRF-TOKEN header.
 *
 * This manager:
 *   1. Logs in via the GraphQL `Login` mutation on api-v2 using the
 *      CUROGRAM_AGENT_USERNAME / CUROGRAM_AGENT_PASSWORD service creds.
 *   2. Harvests Set-Cookie + XSRF-TOKEN into an in-memory session.
 *   3. Optionally pins the active practice tenant (multi-practice account).
 *   4. Auto-relogins when a call returns 401 (session expired).
 *
 * PHI / secrets discipline:
 *   - The password is read from env once and never logged.
 *   - Cookie / XSRF values are never logged.
 *   - Message bodies and patient identifiers are never logged here.
 *
 * If the agent account is MFA-gated, `Login` returns an MfaListSchema and
 * this manager throws a clear, actionable error (no OTP transport is wired
 * for the unattended server). The documented fallback is a CDP cookie
 * harvest (see .claude/skills/curogram/scripts/cdp_cookies.py) feeding
 * CUROGRAM_COOKIE / CUROGRAM_XSRF_TOKEN env, which this manager honors.
 */

export const CUROGRAM_HOSTS = {
  apiV2: "https://api-v2.curogram.com",
  patients: "https://patients.curogram.com",
} as const;

const LOGIN_MUTATION =
  "mutation Login($email: Email!, $password: String!, $source: LoginPage!) {\n" +
  "  login(email: $email, password: $password, source: $source) {\n" +
  "    ... on MfaListSchema { mfa { title send id } challenge { value expiresAt } }\n" +
  "    ... on ProviderTokenSchema { expiresAt accountId }\n" +
  "  }\n" +
  "}";

interface Session {
  /** Full Cookie header string to replay on each request. */
  cookie: string;
  /** Value of the XSRF-TOKEN cookie, echoed as X-XSRF-TOKEN. */
  xsrf: string;
  /** accountId returned by login, when available. */
  accountId?: string;
}

export interface CurogramAuthOptions {
  username?: string;
  password?: string;
  /** Optional pre-harvested cookie (CDP fallback). */
  cookie?: string;
  /** Optional pre-harvested XSRF token (CDP fallback). */
  xsrf?: string;
  /** Optional practice tenant id to pin after login (multi-practice). */
  practiceId?: string;
}

/** Parse the XSRF-TOKEN value out of a Cookie header string. */
function extractXsrf(cookieHeader: string): string | undefined {
  for (const part of cookieHeader.split(";")) {
    const eq = part.indexOf("=");
    if (eq === -1) continue;
    const name = part.slice(0, eq).trim();
    if (name === "XSRF-TOKEN") {
      return decodeURIComponent(part.slice(eq + 1).trim());
    }
  }
  return undefined;
}

/**
 * Collapse a Set-Cookie list into a Cookie header. Bun's Headers exposes
 * getSetCookie() per the Fetch standard. We keep only name=value (drop
 * attributes like Path/HttpOnly/Secure) since that's what a Cookie header
 * carries.
 */
function setCookiesToHeader(headers: Headers, prior?: string): string {
  const jar = new Map<string, string>();
  // Seed from prior session so partial Set-Cookie responses don't drop
  // cookies the server didn't re-issue.
  if (prior) {
    for (const part of prior.split(";")) {
      const eq = part.indexOf("=");
      if (eq === -1) continue;
      jar.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
    }
  }
  const setCookies =
    typeof headers.getSetCookie === "function" ? headers.getSetCookie() : [];
  for (const sc of setCookies) {
    const first = sc.split(";")[0];
    const eq = first.indexOf("=");
    if (eq === -1) continue;
    jar.set(first.slice(0, eq).trim(), first.slice(eq + 1).trim());
  }
  return Array.from(jar, ([k, v]) => `${k}=${v}`).join("; ");
}

export class CurogramAuth {
  private session: Session | null = null;
  private loginInFlight: Promise<void> | null = null;
  private reloginInFlight: Promise<Session> | null = null;
  private reloginAttempts = 0;
  private static readonly MAX_RELOGIN = 3;
  private readonly opts: CurogramAuthOptions;
  /**
   * True when the only credentials are pre-harvested CDP cookies (no
   * username/password). In that mode a 401 cannot be recovered by relogin —
   * relogin would just re-seat the same stale cookies and loop — so we surface
   * the 401 immediately with actionable guidance instead.
   */
  private readonly staticCdpSession: boolean;

  constructor(opts: CurogramAuthOptions) {
    this.opts = opts;
    this.staticCdpSession = Boolean(
      opts.cookie && opts.xsrf && !(opts.username && opts.password),
    );
  }

  /**
   * Ensure a live session exists. Concurrent callers share one login.
   */
  async ensureSession(): Promise<Session> {
    if (this.session) return this.session;
    if (!this.loginInFlight) {
      this.loginInFlight = this.login().finally(() => {
        this.loginInFlight = null;
      });
    }
    await this.loginInFlight;
    if (!this.session) {
      throw new Error("curogram-auth: login produced no session");
    }
    return this.session;
  }

  /**
   * Force a fresh login (used on 401). Concurrent 401 callers share a single
   * relogin cycle: only the first caller clears the session, increments the
   * attempt counter, and performs the login; the rest await the same promise.
   * This prevents N parallel 401s from each burning a relogin attempt.
   *
   * `staleCookie` is the cookie value the caller observed the 401 with — if a
   * relogin has already swapped in a newer session, we return that instead of
   * starting another cycle.
   */
  async relogin(staleCookie?: string): Promise<Session> {
    if (this.reloginInFlight) return this.reloginInFlight;
    // Another caller already refreshed the session since this 401 was seen.
    if (this.session && staleCookie && this.session.cookie !== staleCookie) {
      return this.session;
    }
    if (this.reloginAttempts >= CurogramAuth.MAX_RELOGIN) {
      throw new Error(
        "curogram-auth: max relogin attempts exceeded (persistent 401 — " +
          "check that the service account is enabled and credentials are valid).",
      );
    }
    this.reloginAttempts += 1;
    this.session = null;
    this.reloginInFlight = this.ensureSession().finally(() => {
      this.reloginInFlight = null;
    });
    return this.reloginInFlight;
  }

  private async login(): Promise<void> {
    // CDP fallback: a pre-harvested cookie/xsrf short-circuits programmatic
    // login entirely. Useful when the account is MFA-gated.
    if (this.opts.cookie && this.opts.xsrf) {
      this.session = { cookie: this.opts.cookie, xsrf: this.opts.xsrf };
      if (this.opts.practiceId) await this.pinPractice(this.opts.practiceId);
      return;
    }

    const { username, password } = this.opts;
    if (!username || !password) {
      throw new Error(
        "curogram-auth: no credentials. Set CUROGRAM_AGENT_USERNAME + " +
          "CUROGRAM_AGENT_PASSWORD, or provide CUROGRAM_COOKIE + " +
          "CUROGRAM_XSRF_TOKEN (CDP harvest fallback).",
      );
    }

    const res = await fetch(`${CUROGRAM_HOSTS.apiV2}/graphql`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
        "X-Curogram-Frontend": "web",
      },
      body: JSON.stringify({
        operationName: "Login",
        query: LOGIN_MUTATION,
        variables: { email: username, password, source: "PROVIDER" },
      }),
    });

    if (!res.ok) {
      throw new Error(`curogram-auth: login HTTP ${res.status}`);
    }

    const cookie = setCookiesToHeader(res.headers);
    const body = (await res.json()) as {
      data?: { login?: Record<string, unknown> };
      errors?: Array<{ message?: string }>;
    };

    if (body.errors && body.errors.length > 0) {
      // Do not echo server message verbatim if it could contain creds;
      // Curogram login errors are generic ("Invalid credentials"), so a
      // short surfaced reason is safe and useful.
      throw new Error(
        `curogram-auth: login rejected (${body.errors[0]?.message ?? "unknown"})`,
      );
    }

    const login = body.data?.login;
    if (!login) {
      throw new Error("curogram-auth: login returned no payload");
    }

    // MFA-gated: the response is an MfaListSchema with a non-empty `mfa`
    // array. Match on the array shape (not just presence of the key) so a
    // success payload that happens to carry `mfa: null` can't false-positive.
    if (Array.isArray(login.mfa) && login.mfa.length > 0) {
      throw new Error(
        "curogram-auth: account is MFA-gated; unattended login cannot " +
          "complete OTP. Provide a CDP-harvested session via " +
          "CUROGRAM_COOKIE + CUROGRAM_XSRF_TOKEN env, or disable MFA on " +
          "the service account.",
      );
    }

    const xsrf = extractXsrf(cookie);
    if (!cookie || !xsrf) {
      throw new Error(
        "curogram-auth: login succeeded but no session cookie / XSRF-TOKEN " +
          "was set on the response.",
      );
    }

    this.session = {
      cookie,
      xsrf,
      accountId:
        typeof login.accountId === "string" ? login.accountId : undefined,
    };

    if (this.opts.practiceId) {
      await this.pinPractice(this.opts.practiceId);
    }
  }

  /**
   * Pin the active practice tenant for this session. Fail-closed: if the
   * operator configured CUROGRAM_PRACTICE_ID, a failed pin must clear the
   * session and throw — otherwise the session would silently operate against
   * the account's default practice and risk reading/sending PHI for the wrong
   * tenant on a multi-practice account.
   */
  private async pinPractice(practiceId: string): Promise<void> {
    if (!this.session) return;
    let res: Response;
    try {
      res = await fetch(
        `${CUROGRAM_HOSTS.apiV2}/authenticate/practice/${encodeURIComponent(practiceId)}`,
        { method: "PUT", headers: this.authHeaders(this.session) },
      );
    } catch (e) {
      this.session = null;
      throw new Error(
        `curogram-auth: failed to pin practice ${practiceId} (${e instanceof Error ? e.message : String(e)}). Refusing to proceed against the default tenant.`,
      );
    }
    if (!res.ok) {
      this.session = null;
      throw new Error(
        `curogram-auth: failed to pin practice ${practiceId} (HTTP ${res.status}). Refusing to proceed against the default tenant.`,
      );
    }
    // Merge any refreshed cookies issued by the pin.
    const merged = setCookiesToHeader(res.headers, this.session.cookie);
    if (merged) {
      const xsrf = extractXsrf(merged) ?? this.session.xsrf;
      this.session = { ...this.session, cookie: merged, xsrf };
    }
  }

  /** The 5 required headers for any authenticated Curogram call. */
  private authHeaders(session: Session): Record<string, string> {
    return {
      "X-Curogram-Frontend": "web",
      "X-XSRF-TOKEN": session.xsrf,
      Cookie: session.cookie,
      "Content-Type": "application/json",
      Accept: "application/json",
    };
  }

  /** accountId from the active session, if known. */
  async accountId(): Promise<string | undefined> {
    const s = await this.ensureSession();
    return s.accountId;
  }

  /**
   * Authenticated fetch with one automatic relogin on 401. `init.headers`
   * is merged on top of the 5 required Curogram headers.
   */
  async fetch(url: string, init: RequestInit = {}): Promise<Response> {
    const session = await this.ensureSession();
    const doFetch = (s: Session): Promise<Response> =>
      fetch(url, {
        ...init,
        headers: { ...this.authHeaders(s), ...(init.headers ?? {}) },
      });

    let res = await doFetch(session);
    if (res.status === 401) {
      // Static CDP cookies can't be refreshed by relogin — fail fast rather
      // than spin on the same expired session.
      if (this.staticCdpSession) {
        throw new Error(
          "curogram-auth: 401 with static CDP cookies. The harvested " +
            "CUROGRAM_COOKIE / CUROGRAM_XSRF_TOKEN are likely expired; " +
            "re-harvest a fresh session.",
        );
      }
      // relogin() dedups concurrent 401s into one cycle and owns the attempt
      // counter, so parallel callers don't each burn an attempt.
      const fresh = await this.relogin(session.cookie);
      res = await doFetch(fresh);
    }
    // Reset the counter only once a request actually succeeds, so a burst of
    // 401s across calls still trips the cap.
    if (res.status !== 401) {
      this.reloginAttempts = 0;
    }
    return res;
  }

  /**
   * Authenticated GraphQL POST against a Curogram GraphQL host (api-v2 or
   * patients microservice). Throws on GraphQL errors. Never logs variables
   * (they may carry patient ids).
   */
  async graphql<T = unknown>(
    host: string,
    operationName: string,
    query: string,
    variables: Record<string, unknown>,
  ): Promise<T> {
    const res = await this.fetch(`${host}/graphql`, {
      method: "POST",
      body: JSON.stringify({ operationName, query, variables }),
    });
    if (!res.ok) {
      throw new Error(`curogram ${operationName}: HTTP ${res.status}`);
    }
    const body = (await res.json()) as {
      data?: T;
      errors?: Array<{ message?: string }>;
    };
    if (body.errors && body.errors.length > 0) {
      throw new Error(
        `curogram ${operationName}: ${body.errors[0]?.message ?? "graphql error"}`,
      );
    }
    return body.data as T;
  }
}

/** Build a CurogramAuth from process env. */
export function authFromEnv(): CurogramAuth {
  return new CurogramAuth({
    username: process.env.CUROGRAM_AGENT_USERNAME,
    password: process.env.CUROGRAM_AGENT_PASSWORD,
    cookie: process.env.CUROGRAM_COOKIE,
    xsrf: process.env.CUROGRAM_XSRF_TOKEN,
    practiceId: process.env.CUROGRAM_PRACTICE_ID,
  });
}
