/**
 * Deal-property update service (issue #252)
 *
 * Centralised, whitelist-driven helper for the
 * `PATCH /api/portfolio/[portfolioId]/deal-properties` endpoint. Each
 * editable field on `hubspot_deal_data` is registered here once with:
 *
 *   - the role required to write it
 *   - a Zod parser that validates + coerces the inbound value
 *   - the matching HubSpot deal property name (used by the sync push)
 *   - the Drizzle column to update
 *
 * Slice 5 (this issue) ships the two PIBI date fields. Slices 6 and 7
 * (issues #255 / #256) plug the halted state and Domna survey fields into
 * the same registry without changing the route, the service entry point or
 * the per-field permission logic.
 */
import { z, ZodTypeAny } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/app/db/db";
import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table";
import { getHubSpotClient } from "@/app/lib/hubspot/client";

// -----------------------------------------------------------------------
// Field registry
// -----------------------------------------------------------------------

/**
 * Access tokens that gate a field. These are a flat union of the portfolio
 * role hierarchy ("read" | "write" | "admin" | "creator") plus any
 * orthogonal capability tokens (currently just "approver"). The service
 * checks the caller against this set, so a field can require either a
 * write-or-above role *or* a specific capability.
 */
export type DealPropertyRole =
  | "read"
  | "write"
  | "admin"
  | "creator"
  | "approver";

/** Roles that satisfy a "write or above" requirement. */
export const WRITE_OR_ABOVE_ROLES: ReadonlyArray<DealPropertyRole> = [
  "write",
  "admin",
  "creator",
];

/**
 * Roles allowed to edit fields gated on "approver capability". An approver
 * may not have a write role on the portfolio, but the capability is granted
 * orthogonally — see `portfolio_capabilities` table.
 */
export const APPROVER_ROLES: ReadonlyArray<DealPropertyRole> = ["approver"];

const isoDateSchema = z
  .union([z.string(), z.null()])
  .transform((v, ctx) => {
    if (v === null || v === "") return null;
    const d = new Date(v);
    if (Number.isNaN(d.getTime())) {
      ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid date" });
      return z.NEVER;
    }
    return d;
  });

/**
 * String-or-null schema — empty strings collapse to null so the UI can
 * "clear" a free-text field by sending an empty value, mirroring how the
 * date schema treats "" as null.
 */
const stringOrNullSchema = z
  .union([z.string(), z.null()])
  .transform((v) => (v === null || v === "" ? null : v));

type DateColumn = typeof hubspotDealData.pibiOrderDate;
type TextColumn = typeof hubspotDealData.propertyHaltedReason;

type ColumnFor<T> = T extends Date | null
  ? DateColumn
  : T extends string | null
    ? TextColumn
    : never;

interface DealPropertyFieldDef<TParsed = unknown> {
  /** Schema used to parse the incoming JSON value. */
  schema: ZodTypeAny;
  /** Allowed roles. Empty array means "no role can write" (i.e. read-only). */
  allowedRoles: ReadonlyArray<DealPropertyRole>;
  /** Property name on the HubSpot deal object. */
  hubspotProperty: string;
  /**
   * Drizzle column to update. Typed loosely because we mix date/text/bool
   * columns in the same registry; the per-field schema enforces shape.
   */
  dbColumn: unknown;
  /**
   * Optional renderer that turns the parsed value into the string HubSpot
   * expects (HubSpot stores dates as epoch-millisecond strings, booleans as
   * "true" / "false", text as-is). Defaults to the parsed value coerced via
   * String(...).
   */
  toHubspot?: (value: TParsed) => string;
}

// -- HubSpot value formatters ------------------------------------------------
const dateToHubspot = (d: Date | null): string =>
  d === null ? "" : String(d.getTime());

const stringToHubspot = (s: string | null): string => (s === null ? "" : s);

// -- Registry ----------------------------------------------------------------
// Slice 5 ships PIBI dates only. Halted (#255) and Domna (#256) reuse the
// same registry by adding entries here — no other code path needs to change.
export const DEAL_PROPERTY_FIELDS = {
  pibi_order_date: {
    schema: isoDateSchema,
    allowedRoles: WRITE_OR_ABOVE_ROLES,
    hubspotProperty: "pibi_order_date",
    dbColumn: hubspotDealData.pibiOrderDate,
    toHubspot: dateToHubspot,
  } satisfies DealPropertyFieldDef<Date | null>,
  pibi_completed_date: {
    schema: isoDateSchema,
    allowedRoles: WRITE_OR_ABOVE_ROLES,
    hubspotProperty: "pibi_completed_date",
    dbColumn: hubspotDealData.pibiCompletedDate,
    toHubspot: dateToHubspot,
  } satisfies DealPropertyFieldDef<Date | null>,
  // -- Halted state (issue #255) -------------------------------------------
  // Approver capability gates these — write role alone is not sufficient.
  property_halted_date: {
    schema: isoDateSchema,
    allowedRoles: APPROVER_ROLES,
    hubspotProperty: "property_halted_date",
    dbColumn: hubspotDealData.propertyHaltedDate,
    toHubspot: dateToHubspot,
  } satisfies DealPropertyFieldDef<Date | null>,
  property_halted_reason: {
    schema: stringOrNullSchema,
    allowedRoles: APPROVER_ROLES,
    hubspotProperty: "property_halted_reason",
    dbColumn: hubspotDealData.propertyHaltedReason,
    toHubspot: stringToHubspot,
  } satisfies DealPropertyFieldDef<string | null>,
  // -- Domna survey (issue #256) -------------------------------------------
  // Approver capability gates these — write role alone is not sufficient.
  domna_survey_type: {
    schema: stringOrNullSchema,
    allowedRoles: APPROVER_ROLES,
    hubspotProperty: "osmosis_survey_required",
    dbColumn: hubspotDealData.domnaSurveyType,
    toHubspot: stringToHubspot,
  } satisfies DealPropertyFieldDef<string | null>,
  domna_survey_date: {
    schema: isoDateSchema,
    allowedRoles: APPROVER_ROLES,
    hubspotProperty: "osmosis_survey_date",
    dbColumn: hubspotDealData.domnaSurveyDate,
    toHubspot: dateToHubspot,
  } satisfies DealPropertyFieldDef<Date | null>,
} as const;

export type DealPropertyFieldKey = keyof typeof DEAL_PROPERTY_FIELDS;

export function isDealPropertyField(
  key: string,
): key is DealPropertyFieldKey {
  return Object.prototype.hasOwnProperty.call(DEAL_PROPERTY_FIELDS, key);
}

/**
 * Check whether the caller is allowed to write `field`, given their
 * portfolio role and any orthogonal capabilities (e.g. "approver"). The
 * field passes if any one of the caller's tokens is on the field's
 * allow-list.
 */
export function roleAllowedForField(
  field: DealPropertyFieldKey,
  role: string | null | undefined,
  capabilities: ReadonlyArray<string> = [],
): boolean {
  const allowed = DEAL_PROPERTY_FIELDS[field]
    .allowedRoles as ReadonlyArray<string>;
  if (role && allowed.includes(role)) return true;
  for (const cap of capabilities) {
    if (allowed.includes(cap)) return true;
  }
  return false;
}

// -----------------------------------------------------------------------
// Update orchestration
// -----------------------------------------------------------------------

export type DealPropertyResult =
  | { ok: true }
  | { ok: false; error: string };

export type DealPropertyUpdateOutcome = {
  /** Per-field results keyed by the same field name supplied by the caller. */
  results: Record<string, DealPropertyResult>;
  /** Overall HubSpot push outcome. `null` if no push attempted. */
  hubspotSync: "ok" | "failed" | "skipped";
  hubspotError?: string;
};

/**
 * Push the validated, whitelisted property bag to HubSpot, retrying on
 * `ECONNRESET`. Mirrors the retry pattern already used by
 * `syncContractorDocUploadToHubSpot` / `syncMeasureApprovalsToHubSpot`.
 */
export async function pushDealPropertiesToHubSpot(params: {
  hubspotDealId: string;
  properties: Record<string, string>;
}): Promise<{ ok: true } | { ok: false; error: string }> {
  const maxAttempts = 3;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const client = getHubSpotClient();
      await client.crm.deals.basicApi.update(params.hubspotDealId, {
        properties: params.properties,
      });
      return { ok: true };
    } catch (err) {
      const isReset =
        err instanceof Error &&
        "code" in err &&
        (err as NodeJS.ErrnoException).code === "ECONNRESET";
      if (isReset && attempt < maxAttempts) {
        await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
        continue;
      }
      const message =
        err instanceof Error ? err.message : "HubSpot sync failed";
      console.error("[HubSpot] pushDealPropertiesToHubSpot failed", {
        dealId: params.hubspotDealId,
        attempt,
        error: err,
      });
      return { ok: false, error: message };
    }
  }
  return { ok: false, error: "HubSpot sync failed" };
}

export interface UpdateDealPropertiesInput {
  /** HubSpot deal id (the value stored on `hubspot_deal_data.deal_id`). */
  dealId: string;
  /** Caller-supplied { fieldName: rawValue } map. */
  fields: Record<string, unknown>;
  /** Role of the authenticated user making the request. */
  role: string;
  /**
   * Orthogonal capability tokens (e.g. `"approver"`). Used by fields whose
   * `allowedRoles` list a capability rather than a role tier. Optional so
   * existing call sites that only need role-based gating do not have to
   * supply it.
   */
  capabilities?: ReadonlyArray<string>;
  /**
   * Hooks injected by the route so the service can stay environment-free
   * for unit testing. Defaults are wired in `applyDealPropertyUpdate`.
   */
  deps?: {
    pushHubspot?: typeof pushDealPropertiesToHubSpot;
    updateDb?: (
      dealId: string,
      values: Record<string, unknown>,
    ) => Promise<void>;
  };
}

async function defaultUpdateDb(
  dealId: string,
  values: Record<string, unknown>,
): Promise<void> {
  if (Object.keys(values).length === 0) return;
  await db
    .update(hubspotDealData)
    .set(values)
    .where(eq(hubspotDealData.dealId, dealId));
}

/**
 * Validate caller-supplied fields, persist the accepted ones to the DB and
 * push the same set to HubSpot. Returns per-field success / error so the
 * route can surface partial failures back to the UI.
 */
export async function applyDealPropertyUpdate(
  input: UpdateDealPropertiesInput,
): Promise<DealPropertyUpdateOutcome> {
  const results: Record<string, DealPropertyResult> = {};
  const dbValues: Record<string, unknown> = {};
  const hubspotProperties: Record<string, string> = {};

  for (const [key, rawValue] of Object.entries(input.fields)) {
    if (!isDealPropertyField(key)) {
      results[key] = { ok: false, error: "Field not editable" };
      continue;
    }
    if (!roleAllowedForField(key, input.role, input.capabilities)) {
      results[key] = { ok: false, error: "Insufficient permissions" };
      continue;
    }
    const def = DEAL_PROPERTY_FIELDS[key];
    const parsed = def.schema.safeParse(rawValue);
    if (!parsed.success) {
      results[key] = {
        ok: false,
        error: parsed.error.issues[0]?.message ?? "Invalid value",
      };
      continue;
    }
    results[key] = { ok: true };
    // The Drizzle column type for date columns accepts Date | null.
    const columnName = (def.dbColumn as { name?: string }).name;
    if (columnName) {
      // Drizzle .set() accepts the JS column key (camelCase). Look it up by
      // walking the table object so we use the field name on the schema.
      const tableKey = findColumnKey(def.dbColumn);
      if (tableKey) dbValues[tableKey] = parsed.data;
    }
    const renderer = def.toHubspot as
      | ((value: unknown) => string)
      | undefined;
    hubspotProperties[def.hubspotProperty] = renderer
      ? renderer(parsed.data)
      : String(parsed.data ?? "");
  }

  const updateDb = input.deps?.updateDb ?? defaultUpdateDb;
  const pushHubspot = input.deps?.pushHubspot ?? pushDealPropertiesToHubSpot;

  // No accepted fields → return early, nothing to sync.
  if (Object.keys(dbValues).length === 0) {
    return { results, hubspotSync: "skipped" };
  }

  await updateDb(input.dealId, dbValues);

  const sync = await pushHubspot({
    hubspotDealId: input.dealId,
    properties: hubspotProperties,
  });

  if (sync.ok) return { results, hubspotSync: "ok" };
  return { results, hubspotSync: "failed", hubspotError: sync.error };
}

/**
 * Look up the JS-side property name on the Drizzle table for a given column
 * object so we can index `db.update().set({ [key]: value })`.
 */
function findColumnKey(column: unknown): string | null {
  for (const [key, value] of Object.entries(hubspotDealData)) {
    if (value === column) return key;
  }
  return null;
}
