#!/usr/bin/env bun
/// <reference types="bun-types" />
/**
 * Rippling HRIS/payroll MCP server.
 *
 * Env: RIPPLING_API_TOKEN (required), RIPPLING_API_VERSION (optional, default 2024-08-01).
 *
 * Transport (Phase C, 2026-05-23): Streamable HTTP via mcp-shared on
 * MCP_PORTS.rippling (18816). Requires MCP_BEARER_TOKEN env. The Tailscale
 * funnel at /rippling terminates TLS and proxies to 127.0.0.1:18816.
 */

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { serveMcpOverHttp, MCP_PORTS } from "../mcp-shared/index.ts";
import * as rippling from "./rippling-api.js";
import type { JobShift } from "./rippling-api.js";
import { loadConfig } from "./pay-mapping.js";
import { generatePayroll, toCSV, formatSummary, type VisitRecord } from "./payroll-sync.js";
import { writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from "path";

// Validate config on startup
try {
  loadConfig(join(import.meta.dir, "../../config/rippling-pay-mapping.json"));
  process.stderr.write("rippling-mcp: pay mapping config loaded\n");
} catch (e: any) {
  process.stderr.write(`rippling-mcp: WARNING - pay mapping config not found: ${e.message}\n`);
}

/**
 * Build a fresh MCP Server instance for a new session.
 *
 * mcp-shared invokes this once per inbound `initialize` so concurrent
 * clients (e.g. Claude Desktop + Codex) each get their own Server --
 * the SDK's Protocol.connect() refuses a second transport on the same
 * Server, so per-session instances are mandatory for multi-client use.
 *
 * All module-scoped state (rippling-api token cache, pay mapping
 * config) is shared naturally because handlers close over module
 * bindings; nothing here is per-Server.
 */
function buildServer(): Server {
  const server = new Server(
    { name: "rippling-mcp", version: "0.1.0" },
    { capabilities: { tools: {} } }
  );

  server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [
    // ---------------------------------------------------------------------
    // WORKING tools (Personal API Token surface): get_me, time_entries CRUD,
    // and the pure-compute payroll generators.
    // ---------------------------------------------------------------------
    {
      name: "get_me",
      description: "Return the token owner's identity from Rippling: { id, workEmail, company }. Use this to confirm the MCP is authenticated and to discover the company ID for create_time_entry calls.",
      inputSchema: { type: "object", properties: {} },
    },
    // ---------------------------------------------------------------------
    // BROWSER-ONLY tools (Marketplace Platform API surface — 403 under a
    // Personal API Token). These remain registered so existing prompts
    // / clients don't break, but every call returns a structured error
    // pointing the agent to the browser fallback at
    // .claude/skills/rippling/SKILL.md → "When to use the API vs Browser".
    //
    // The description prefix "[BROWSER-ONLY]" is the signal to the agent:
    // do not call this tool; navigate the Rippling web app instead.
    // ---------------------------------------------------------------------
    {
      name: "get_employees",
      description: "[BROWSER-ONLY — 403 under Personal API Token] List active Rippling employees. Use browser fallback: navigate /people. See .claude/skills/rippling/SKILL.md.",
      inputSchema: { type: "object", properties: {} },
    },
    {
      name: "get_employee",
      description: "[BROWSER-ONLY — 403 under Personal API Token] Get a specific employee by Rippling ID. Use browser fallback: navigate /people/<id>.",
      inputSchema: {
        type: "object",
        properties: { id: { type: "string", description: "Rippling employee ID" } },
        required: ["id"],
      },
    },
    {
      name: "get_company",
      description: "[BROWSER-ONLY — 403 under Personal API Token] Get current company info. Use browser fallback: navigate /settings/company.",
      inputSchema: { type: "object", properties: {} },
    },
    {
      name: "get_departments",
      description: "[BROWSER-ONLY — 403 under Personal API Token] List departments. Use browser fallback: navigate /admin/departments.",
      inputSchema: { type: "object", properties: {} },
    },
    {
      name: "get_teams",
      description: "[BROWSER-ONLY — 403 under Personal API Token] List teams. Use browser fallback: navigate /admin/teams.",
      inputSchema: { type: "object", properties: {} },
    },
    {
      name: "get_custom_fields",
      description: "[BROWSER-ONLY — 403 under Personal API Token] List custom fields. Use browser fallback: navigate /admin/custom-fields.",
      inputSchema: { type: "object", properties: {} },
    },
    {
      name: "get_levels",
      description: "[BROWSER-ONLY — 403 under Personal API Token] List company position levels. Use browser fallback: navigate /admin/levels.",
      inputSchema: { type: "object", properties: {} },
    },
    {
      name: "get_work_locations",
      description: "[BROWSER-ONLY — 403 under Personal API Token] List work locations. Use browser fallback: navigate /admin/work-locations.",
      inputSchema: { type: "object", properties: {} },
    },
    {
      name: "get_leave_requests",
      description: "[BROWSER-ONLY — 403 under Personal API Token] List leave/time-off requests. Use browser fallback: navigate /time-off/requests.",
      inputSchema: { type: "object", properties: {} },
    },
    {
      name: "update_employee",
      description: "[BROWSER-ONLY — 403 under Personal API Token] Update an employee's fields. Use browser fallback: navigate /people/<id>/edit and drive the form.",
      inputSchema: {
        type: "object",
        properties: {
          id: { type: "string", description: "Rippling employee ID" },
          fields: {
            type: "object",
            description: "Fields to update. For custom fields use: { custom_fields: { '<field_id>': '<value>' } }. For standard fields, use top-level keys.",
          },
        },
        required: ["id", "fields"],
      },
    },
    {
      name: "update_employee_custom_fields",
      description: "[BROWSER-ONLY — 403 under Personal API Token] Bulk-update custom fields on an employee. Use browser fallback: navigate /people/<id>/edit and drive the form.",
      inputSchema: {
        type: "object",
        properties: {
          employee_id: { type: "string", description: "Rippling employee ID" },
          field_values: {
            type: "object",
            description: "Map of custom field labels (e.g. 'Rendering NPI (Type 1)') to their values. Labels are resolved to field IDs from the field inventory.",
          },
          field_inventory_path: {
            type: "string",
            description: "Path to created-fields.json with ripplingFieldId mappings. Defaults to the standard location.",
          },
        },
        required: ["employee_id", "field_values"],
      },
    },
    {
      name: "search_employees",
      description: "[BROWSER-ONLY — 403 under Personal API Token] Search employees by query parameters. Use browser fallback: navigate /people, focus input[name='unity-searchbar-input'], type the name, scrape the dropdown (Profile link is /profile/<roleId>).",
      inputSchema: {
        type: "object",
        properties: {
          query: {
            type: "object",
            description: "Key-value pairs for query params (e.g. { work_email: 'user@company.com' })",
          },
        },
        required: ["query"],
      },
    },
    {
      name: "run_payroll_sync",
      description: "Pull AMD visits for a date range, classify by appointment type, calculate tiers, and generate a Rippling payroll CSV. Returns summary with per-provider breakdown.",
      inputSchema: {
        type: "object",
        properties: {
          start_date: { type: "string", description: "Pay period start (YYYY-MM-DD)" },
          end_date: { type: "string", description: "Pay period end (YYYY-MM-DD)" },
          visits_json: { type: "string", description: "JSON array of visit records from AMD (each with id, date, columnheading, appointmentType, appointmentTypeId, apptstatus, duration, patient_name)" },
        },
        required: ["start_date", "end_date", "visits_json"],
      },
    },
    {
      name: "stage_payroll",
      description: "Generate payroll CSV from visit data and save to /tmp/payroll/ for review before uploading.",
      inputSchema: {
        type: "object",
        properties: {
          start_date: { type: "string", description: "Pay period start (YYYY-MM-DD)" },
          end_date: { type: "string", description: "Pay period end (YYYY-MM-DD)" },
          visits_json: { type: "string", description: "JSON array of AMD visit records" },
        },
        required: ["start_date", "end_date", "visits_json"],
      },
    },
    // -----------------------------------------------------------------------
    // Time entries (shifts + timecards)
    // -----------------------------------------------------------------------
    // NOTE: there is no `list_time_entries` tool. The Rippling Platform API
    // requires `time_entries:read` scope on the caller's role to list, and
    // the codex token this MCP is provisioned with is intentionally
    // write-only. To operate on an existing entry, surface its ID from
    // another source (Rippling UI, run_payroll_sync output, or the create
    // tool's response) and then use get/update/approve/reject_time_entry.
    {
      name: "create_time_entry",
      description: "Create a new time entry (shift / timecard entry) for a worker. Requires role (worker role ID), company (company ID), and jobShifts (one or more). Returns the new entry with its ID for downstream get/update/approve calls. Token-scope friendly — works with write-only tokens that cannot list existing entries.",
      inputSchema: {
        type: "object",
        properties: {
          role: { type: "string", description: "Rippling role ID of the worker this shift belongs to" },
          company: { type: "string", description: "Rippling company ID" },
          jobShifts: {
            type: "array",
            description: "One or more job shifts on this entry. At least one is required; each shift records a contiguous period of work.",
            items: {
              type: "object",
              properties: {
                startTime: { type: "string", description: "Required. ISO 8601 datetime with timezone (e.g. '2026-05-30T09:00:00-05:00' or '2026-05-30T14:00:00Z'). When the shift began." },
                endTime: { type: "string", description: "Optional ISO 8601 datetime. When the shift ended. Required to finalize the entry, but can be omitted for an open / in-progress shift." },
                hoursWorked: { type: "number", description: "Optional decimal hours. If startTime + endTime are both set, Rippling computes this; passing both lets you record breaks/overrides." },
                jobCodeId: { type: "string", description: "Optional. Rippling job code ID to pin this shift to a specific job for multi-job tracking." },
                notes: { type: "string", description: "Optional free-form note recorded with the shift." },
              },
              required: ["startTime"],
            },
          },
          breaks: {
            type: "array",
            description: "Optional unpaid/paid breaks taken during the entry.",
            items: {
              type: "object",
              properties: {
                startTime: { type: "string", description: "Required ISO 8601 datetime when the break began." },
                endTime: { type: "string", description: "Optional ISO 8601 datetime when the break ended." },
                type: { type: "string", description: "Optional break type label (e.g. 'meal', 'rest'). Configured per Rippling tenant." },
              },
              required: ["startTime"],
            },
          },
          comments: { type: "string", description: "Optional free-form comment on the entry" },
          tags: { type: "array", description: "Optional tag list", items: { type: "string" } },
          idempotencyKey: { type: "string", description: "Optional key — Rippling dedupes retried creates by this value" },
        },
        required: ["role", "company", "jobShifts"],
      },
    },
    {
      name: "get_time_entry",
      description: "Get one time entry (shift) by ID. Returns full detail including start_time, end_time, hours_worked, notes, job_shifts, status, and approver_notes.",
      inputSchema: {
        type: "object",
        properties: {
          id: { type: "string", description: "Time entry ID" },
        },
        required: ["id"],
      },
    },
    {
      name: "update_time_entry",
      description: "Update the per-shift inputs on a time entry: start_time, end_time, hours_worked, notes, job_shifts. Pass only the fields you want changed. Does NOT change status — use approve_time_entry or reject_time_entry for that.",
      inputSchema: {
        type: "object",
        properties: {
          id: { type: "string", description: "Time entry ID" },
          fields: {
            type: "object",
            description: "Fields to PATCH. Examples: { start_time: '2026-05-30T09:00:00Z', end_time: '2026-05-30T17:00:00Z', notes: 'updated' }. For multi-job shifts pass job_shifts: [{job_id, start_time, end_time, hours_worked}].",
          },
        },
        required: ["id", "fields"],
      },
    },
    {
      name: "approve_time_entry",
      description: "Approve a timecard / shift. PATCHes status to APPROVED. Optional approver_notes is logged in Rippling's audit trail. Idempotent — re-approving an already-APPROVED entry is a no-op.",
      inputSchema: {
        type: "object",
        properties: {
          id: { type: "string", description: "Time entry ID" },
          approver_notes: { type: "string", description: "Optional notes recorded with the approval" },
        },
        required: ["id"],
      },
    },
    {
      name: "reject_time_entry",
      description: "Reject a timecard / shift. Sends status back to DRAFT with a required rejection reason. The employee can then correct and resubmit. Rippling has no separate REJECTED status.",
      inputSchema: {
        type: "object",
        properties: {
          id: { type: "string", description: "Time entry ID" },
          reason: { type: "string", description: "Required: reason for rejection, recorded as approver_notes" },
        },
        required: ["id", "reason"],
      },
    },
  ],
}));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args } = req.params;
  try {
    switch (name) {
      case "get_me":
        return ok(await rippling.getMe());
      case "get_employees":
        return ok(await rippling.getEmployees());
      case "get_employee":
        return ok(await rippling.getEmployee(args!.id as string));
      case "get_company":
        return ok(await rippling.getCompany());
      case "get_departments":
        return ok(await rippling.getDepartments());
      case "get_teams":
        return ok(await rippling.getTeams());
      case "get_custom_fields":
        return ok(await rippling.getCustomFields());
      case "get_levels":
        return ok(await rippling.getLevels());
      case "get_work_locations":
        return ok(await rippling.getWorkLocations());
      case "get_leave_requests":
        return ok(await rippling.getLeaveRequests());

      case "update_employee":
        return ok(await rippling.updateEmployee(
          args!.id as string,
          args!.fields as Record<string, unknown>,
        ));

      case "update_employee_custom_fields":
        // Marketplace-only endpoint. Short-circuit before any file I/O so a
        // fresh checkout (which has no created-fields.json inventory) sees
        // the structured MARKETPLACE_ENDPOINT_ERROR instead of ENOENT.
        // Args other than employee_id/field_values are ignored — the call
        // can't succeed against a Personal Token regardless.
        return ok(await rippling.updateEmployee(
          args!.employee_id as string,
          { custom_fields: args!.field_values as Record<string, string> },
        ));

      case "search_employees":
        return ok(await rippling.searchEmployees(
          args!.query as Record<string, string>,
        ));

      case "run_payroll_sync": {
        const visits: VisitRecord[] = JSON.parse(args!.visits_json as string);
        const cfg = loadConfig(join(import.meta.dir, "../../config/rippling-pay-mapping.json"));
        const summary = generatePayroll(visits, args!.start_date as string, args!.end_date as string);
        return ok({ summary: formatSummary(summary), data: summary });
      }

      case "stage_payroll": {
        const visits: VisitRecord[] = JSON.parse(args!.visits_json as string);
        const summary = generatePayroll(visits, args!.start_date as string, args!.end_date as string);
        const csv = toCSV(summary.lines);
        const dir = "/tmp/payroll";
        if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
        const filename = `payroll-${args!.end_date}-${Date.now()}.csv`;
        const filepath = join(dir, filename);
        writeFileSync(filepath, csv);
        return ok({
          summary: formatSummary(summary),
          csv_path: filepath,
          csv_preview: csv,
          data: summary,
        });
      }

      // ---- Time entries (shifts + timecards) ----
      // Style: destructure required args at the top of each case so the
      // null-assertion style is consistent within this block. Required args
      // throw a typed runtime error; optional args remain `args?.x` cast.
      case "create_time_entry": {
        const role = requireString(args, "role");
        const company = requireString(args, "company");
        const jobShifts = (args ?? {}).jobShifts as JobShift[] | undefined;
        if (!Array.isArray(jobShifts) || jobShifts.length === 0) {
          throw new Error("Missing required array argument: jobShifts");
        }
        const breaks = (args ?? {}).breaks as Array<{ startTime: string; endTime?: string; type?: string }> | undefined;
        const comments = (args ?? {}).comments as string | undefined;
        const tags = (args ?? {}).tags as string[] | undefined;
        const idempotencyKey = (args ?? {}).idempotencyKey as string | undefined;
        return ok(await rippling.createTimeEntry({
          role, company, jobShifts, breaks, comments, tags, idempotencyKey,
        }));
      }

      case "get_time_entry": {
        const id = requireString(args, "id");
        return ok(await rippling.getTimeEntry(id));
      }

      case "update_time_entry": {
        const id = requireString(args, "id");
        const fields = requireObject(args, "fields");
        return ok(await rippling.updateTimeEntry(id, fields));
      }

      case "approve_time_entry": {
        const id = requireString(args, "id");
        const approver_notes = (args ?? {}).approver_notes as string | undefined;
        return ok(await rippling.approveTimeEntry(id, approver_notes));
      }

      case "reject_time_entry": {
        const id = requireString(args, "id");
        const reason = requireString(args, "reason");
        return ok(await rippling.rejectTimeEntry(id, reason));
      }

      default:
        return err(`Unknown tool: ${name}`);
    }
  } catch (e: any) {
    return err(e.message);
  }
});

  return server;
}

function ok(data: any) {
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

function err(msg: string) {
  return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}

// Argument helpers — surface a clean error message when an LLM call omits a
// required arg, instead of leaning on `args!.x` non-null assertions that
// throw a TypeError at the property access.
function requireString(
  args: Record<string, unknown> | undefined,
  key: string,
): string {
  const v = args?.[key];
  if (typeof v !== "string" || v.length === 0) {
    throw new Error(`Missing required string argument: ${key}`);
  }
  return v;
}

function requireObject(
  args: Record<string, unknown> | undefined,
  key: string,
): Record<string, unknown> {
  const v = args?.[key];
  if (v === null || typeof v !== "object" || Array.isArray(v)) {
    throw new Error(`Missing required object argument: ${key}`);
  }
  return v as Record<string, unknown>;
}

const token = process.env.MCP_BEARER_TOKEN;
if (!token) {
  process.stderr.write("rippling-mcp: MCP_BEARER_TOKEN required\n");
  process.exit(1);
}

// serveMcpOverHttp is synchronous: it boots Bun.serve and returns the
// handle. We still wrap in try/catch so a bind failure (e.g. EADDRINUSE)
// produces a clean fatal log instead of an unhandled exception.
try {
  serveMcpOverHttp({
    serverFactory: buildServer,
    port: MCP_PORTS.rippling,
    token,
  });
} catch (err) {
  const msg = err instanceof Error ? err.message : String(err);
  process.stderr.write(`rippling-mcp: fatal startup error: ${msg}\n`);
  process.exit(1);
}

process.stderr.write(
  `rippling-mcp: server started on http :${MCP_PORTS.rippling} (v0.1.0)\n`,
);
