/**
 * Rippling Platform API client.
 *
 * Working / Not-working partition (Personal API Tokens, verified 2026-06-01)
 * ------------------------------------------------------------------------
 * Rippling exposes two distinct API surfaces under api.rippling.com:
 *
 *  - **Marketplace Platform API** — `/employees`, `/companies/current`,
 *    `/departments`, `/teams`, `/custom_fields`, `/levels`, `/work_locations`,
 *    `/leave_*`, `/company_activity`, etc.  Reachable only by **Custom Apps**
 *    provisioned through Rippling's Partner program.  Personal API Tokens
 *    (the kind any super-admin can self-mint at /developer/api-tokens) get
 *    403 on every one of these endpoints, regardless of which scopes the
 *    token claims to have. The page subtitle "A token can only access what
 *    its creator has permission to access" is misleading — even a fully-
 *    privileged super-admin's Personal Token can't reach this surface.
 *
 *  - **Personal API surface** — `/me`, `/time_entries`, `/shifts`. Plus the
 *    8 object-type scopes (Compensation, Department, Employment type, Legal
 *    entity, Level, Team, Title) which currently 403 anyway but are listed
 *    in the token's allowed scopes.
 *
 * The functions in this file are tagged below as **WORKS** or **403** to
 * reflect which surface they hit. The **403** functions remain exported so
 * existing callers don't break — they now throw a structured error pointing
 * the caller to the browser fallback (see skills/rippling/SKILL.md).
 *
 * To regain API access to the Marketplace surface, Exult would need to
 * register a Custom App via Rippling's Partner program. That's not self-
 * serve and is gated on Rippling business-development approval.
 */

const BASE_URL = "https://api.rippling.com/platform/api";

let token = process.env.RIPPLING_API_TOKEN ?? "";
let apiVersion = process.env.RIPPLING_API_VERSION ?? "2024-08-01";

export function setToken(t: string) { token = t; }

/**
 * Error message used by every function that hits a Marketplace-only endpoint.
 * Surfaced through the MCP tool wrapper so the calling agent sees a clean
 * message ("use the browser fallback") instead of a raw 403 stack trace.
 */
const MARKETPLACE_ENDPOINT_ERROR =
  "Rippling Personal API Tokens do not have access to this endpoint " +
  "(Marketplace Platform API surface — /employees, /departments, /teams, " +
  "/custom_fields, /levels, /work_locations, /leave_*, /companies/current, " +
  "/company_activity, etc.). Use the Rippling browser fallback — see " +
  ".claude/skills/rippling/SKILL.md → 'When to use the API vs Browser'. " +
  "If API access is required, Exult must register a Custom App via the " +
  "Rippling Partner program (not self-serve).";

async function api(path: string, opts: RequestInit = {}): Promise<any> {
  if (!token) throw new Error("RIPPLING_API_TOKEN not set");
  const url = path.startsWith("http") ? path : `${BASE_URL}${path}`;
  const res = await fetch(url, {
    ...opts,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      "X-API-Version": apiVersion,
      ...(opts.headers ?? {}),
    },
  });
  if (res.status === 429) {
    const retry = parseInt(res.headers.get("Retry-After") ?? "5", 10);
    await new Promise((r) => setTimeout(r, retry * 1000));
    return api(path, opts);
  }
  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Rippling ${res.status}: ${body}`);
  }
  return res.json();
}

// ---------------------------------------------------------------------------
// Marketplace-only endpoints (403 under a Personal API Token).
// These remain exported so existing imports/callers don't break, but each
// throws MARKETPLACE_ENDPOINT_ERROR so the MCP tool wrapper can surface a
// helpful "use the browser fallback" message instead of a raw 403.
// ---------------------------------------------------------------------------

/** 403 — Marketplace-only. Use browser fallback (`/people`). */
export async function getEmployees() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/people?filter=terminated`). */
export async function getEmployeesIncludeTerminated() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/people/<id>`). */
export async function getEmployee(_id: string) {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/settings/company`). */
export async function getCompany() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/admin/departments`). */
export async function getDepartments() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/admin/teams`). */
export async function getTeams() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/admin/custom-fields`). */
export async function getCustomFields() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/admin/levels`). */
export async function getLevels() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/admin/work-locations`). */
export async function getWorkLocations() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/time-off/balances/<id>`). */
export async function getLeaveBalances() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/time-off/requests`). */
export async function getLeaveRequests() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/** 403 — Marketplace-only. Use browser fallback (`/admin/audit-log`). */
export async function getCompanyActivity() {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

// ---------------------------------------------------------------------------
// Working endpoints (Personal API Token surface).
// ---------------------------------------------------------------------------

/** WORKS — returns the token owner's identity (id, workEmail, company). */
export async function getMe() {
  return api("/me");
}

/**
 * 403 — Marketplace-only. Use browser fallback (`/people/<id>/edit`,
 * drive the field form). The custom-fields path also goes through the
 * Marketplace admin UI.
 */
export async function updateEmployee(_id: string, _fields: Record<string, unknown>) {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/**
 * 403 — Marketplace-only. Use browser fallback (`/people` page,
 * type into omnisearch / `input[name='unity-searchbar-input']` and
 * scrape the dropdown).
 */
export async function searchEmployees(_query: Record<string, string>) {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

/**
 * 403 — Marketplace-only. Use browser fallback (`/admin/custom-fields/<id>`).
 */
export async function getCustomField(_id: string) {
  throw new Error(MARKETPLACE_ENDPOINT_ERROR);
}

// ---------------------------------------------------------------------------
// Time entries (shifts + timecards)
// ---------------------------------------------------------------------------
// Rippling models a "shift" / "timecard entry" as a `time_entry` with a
// status field: DRAFT | APPROVED | PAID | FINALIZED. Per-shift inputs live in
// the entry body (start_time, end_time, hours_worked, notes) plus optional
// `job_shifts` for multi-job tracking. Approving a timecard = PATCH status to
// APPROVED; rejecting = PATCH status to DRAFT with notes explaining the reject.

/**
 * Job shift inside a time entry. Each entry can carry one or more, e.g. a
 * worker who clocks in/out twice in a day, or a worker who splits a day
 * across multiple job codes.
 */
export interface JobShift {
  startTime: string;          // ISO 8601 — required by Rippling
  endTime?: string;           // ISO 8601 — required to finalize but optional on create
  hoursWorked?: number;       // optional decimal hours; computed if start+end set
  jobCodeId?: string;         // multi-job: pin a shift to a specific job code
  notes?: string;             // free-form note carried by Rippling
}

/**
 * Create-time-entry request body. Per Rippling Platform API the create
 * endpoint requires `role` (worker role id whose shift this is), `company`
 * (company id), and `jobShifts` (one or more JobShift). The token's
 * permission scope determines which roles you may create shifts for.
 */
export interface CreateTimeEntryRequest {
  role: string;
  company: string;
  jobShifts: JobShift[];
  // Optional pass-throughs documented by Rippling.
  breaks?: Array<{ startTime: string; endTime?: string; type?: string }>;
  comments?: string;
  tags?: string[];
  idempotencyKey?: string;
}

/**
 * Create a new time entry (shift / timecard entry) under the worker `role`.
 *
 * Note on token scopes: as of the codex token's rollout (2026-05-30) the
 * Rippling Platform API does NOT expose a `GET /time_entries` list endpoint
 * for write-only tokens — listing requires `time_entries:read` on the
 * caller's role. The codex token is intentionally write-only, so this MCP
 * does not ship a list_time_entries tool. To operate on an existing entry,
 * use get_time_entry / update_time_entry / approve_time_entry /
 * reject_time_entry with the ID returned here (or surfaced by some other
 * out-of-band system).
 */
/**
 * Light ISO 8601 datetime validation. We don't pull in a full date library —
 * this catches the common malformed cases (missing T separator, missing
 * timezone, wrong order) so callers get a clear error here instead of an
 * opaque 400 from Rippling several round-trips later. Anything Date.parse
 * accepts that ALSO has a T and at least one digit in each year/month/day
 * position counts.
 */
function isIso8601(s: unknown): boolean {
  if (typeof s !== "string" || s.length < 10) return false;
  // YYYY-MM-DD is accepted standalone (date-only). If a T-time component is
  // present, a timezone (Z or ±HH[:MM]) is REQUIRED — Rippling rejects naive
  // datetimes and a passing-here-failing-there error would be confusing.
  const pat = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2}))?$/;
  if (!pat.test(s)) return false;
  return !Number.isNaN(Date.parse(s));
}

export async function createTimeEntry(req: CreateTimeEntryRequest) {
  if (!req.role) throw new Error("createTimeEntry: 'role' is required");
  if (!req.company) throw new Error("createTimeEntry: 'company' is required");
  if (!Array.isArray(req.jobShifts) || req.jobShifts.length === 0) {
    throw new Error("createTimeEntry: at least one jobShift is required");
  }
  for (const [i, s] of req.jobShifts.entries()) {
    if (!s || typeof s !== "object" || Array.isArray(s)) {
      throw new Error(`createTimeEntry: jobShifts[${i}] must be an object`);
    }
    if (!s.startTime) {
      throw new Error(`createTimeEntry: jobShifts[${i}].startTime is required`);
    }
    if (!isIso8601(s.startTime)) {
      throw new Error(
        `createTimeEntry: jobShifts[${i}].startTime is not a valid ISO 8601 datetime (got ${JSON.stringify(s.startTime)})`,
      );
    }
    if (s.endTime !== undefined && !isIso8601(s.endTime)) {
      throw new Error(
        `createTimeEntry: jobShifts[${i}].endTime is not a valid ISO 8601 datetime (got ${JSON.stringify(s.endTime)})`,
      );
    }
  }
  if (req.breaks !== undefined) {
    if (!Array.isArray(req.breaks)) {
      throw new Error("createTimeEntry: 'breaks' must be an array if provided");
    }
    for (const [i, b] of req.breaks.entries()) {
      if (!b || typeof b !== "object") {
        throw new Error(`createTimeEntry: breaks[${i}] must be an object`);
      }
      if (!isIso8601(b.startTime)) {
        throw new Error(
          `createTimeEntry: breaks[${i}].startTime is not a valid ISO 8601 datetime`,
        );
      }
      if (b.endTime !== undefined && !isIso8601(b.endTime)) {
        throw new Error(
          `createTimeEntry: breaks[${i}].endTime is not a valid ISO 8601 datetime`,
        );
      }
    }
  }
  return api("/time_entries", {
    method: "POST",
    body: JSON.stringify(req),
  });
}

/**
 * Get a single time entry by ID.
 */
export async function getTimeEntry(id: string) {
  return api(`/time_entries/${id}`);
}

/**
 * Update a time entry's shift inputs (start_time, end_time, hours_worked,
 * notes, job_shifts, etc.). Pass only the fields you want changed.
 *
 * Common fields:
 *   start_time: ISO 8601 string
 *   end_time:   ISO 8601 string
 *   hours_worked: number
 *   notes: string
 *   job_shifts: [{ job_id, start_time, end_time, hours_worked, notes }]
 */
export async function updateTimeEntry(
  id: string,
  fields: Record<string, unknown>,
) {
  // Guard: status transitions must go through approveTimeEntry / rejectTimeEntry,
  // not updateTimeEntry. This preserves the approve/reject workflow audit trail
  // (approver_notes is required for reject) and prevents accidental promotions.
  if ("status" in fields) {
    throw new Error(
      "updateTimeEntry: 'status' is not editable here. Use approve_time_entry or reject_time_entry.",
    );
  }
  if ("approver_notes" in fields) {
    throw new Error(
      "updateTimeEntry: 'approver_notes' is set by approve_time_entry / reject_time_entry only.",
    );
  }
  return api(`/time_entries/${id}`, {
    method: "PATCH",
    body: JSON.stringify(fields),
  });
}

/**
 * Approve a timecard / shift. Convenience wrapper around updateTimeEntry that
 * sets status=APPROVED. Per Rippling Platform API, status transitions follow:
 *   DRAFT → APPROVED → PAID → FINALIZED
 * Approve is idempotent; re-approving an APPROVED entry is a no-op.
 */
export async function approveTimeEntry(id: string, approver_notes?: string) {
  const body: Record<string, unknown> = { status: "APPROVED" };
  if (approver_notes) body.approver_notes = approver_notes;
  return api(`/time_entries/${id}`, {
    method: "PATCH",
    body: JSON.stringify(body),
  });
}

/**
 * Reject a timecard / shift. Sets status back to DRAFT and records a
 * rejection reason in approver_notes. The employee can then edit and
 * resubmit. There is no separate REJECTED status in Rippling's model.
 */
export async function rejectTimeEntry(id: string, reason: string) {
  if (!reason || reason.trim() === "") {
    throw new Error("rejectTimeEntry: reason is required (used as approver_notes)");
  }
  return api(`/time_entries/${id}`, {
    method: "PATCH",
    body: JSON.stringify({
      status: "DRAFT",
      approver_notes: reason,
    }),
  });
}
