/**
 * Translate service-reported errors back to the form field that produced them.
 *
 * The host validates and submits the *derived payload*, so errors come back
 * addressed by payload path (`trigger.schedule`) rather than by form field
 * (`schedule`). The reverse lookup is derived from the same builder that made
 * the payload; the mapping is lossy by nature, since one payload value can be
 * built from several fields.
 */

import type { SetupRequestBody } from "./types";

/** An error the service reported, addressed by payload path where it gave one. */
export interface ManifestServiceError {
  path: string;
  message: string;
}

export interface MappedManifestErrors {
  /** Keyed by form field name. */
  fieldErrors: Record<string, string>;
  /** Errors with no field to attach to; shown against the form as a whole. */
  formErrors: string[];
}

const EMPTY_MAPPED_ERRORS: MappedManifestErrors = {
  fieldErrors: {},
  formErrors: [],
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
 * Turn a service `loc` array into a path into the payload actually sent.
 *
 * A validation framework may address a value through segments that are not keys
 * of the request body — a discriminated union reports
 * `["body","trigger","cron","schedule"]` for a body whose trigger has no `cron`
 * key. Walking the payload and skipping segments that do not resolve reduces
 * that to `trigger.schedule` without knowing anything about the payload's
 * meaning.
 */
export function locToPayloadPath(
  loc: readonly (string | number)[],
  payload: unknown,
): string {
  const segments = loc[0] === "body" ? loc.slice(1) : loc;
  const parts: string[] = [];
  let current: unknown = payload;

  segments.forEach((segment) => {
    if (typeof segment === "number") {
      if (Array.isArray(current) && parts.length > 0) {
        parts[parts.length - 1] += `[${segment}]`;
        current = current[segment];
      }
      return;
    }
    if (isRecord(current) && segment in current) {
      parts.push(segment);
      current = current[segment];
    }
  });

  return parts.join(".");
}

/**
 * Read errors out of a service response body, whichever of the two shapes it
 * uses: a preflight result keyed by field, or a validation failure keyed by
 * `loc`.
 */
export function normalizeServiceErrors(
  body: unknown,
  payload: SetupRequestBody | null,
): ManifestServiceError[] {
  if (!isRecord(body)) return [];

  if (Array.isArray(body.errors)) {
    return body.errors.flatMap((entry) => {
      if (!isRecord(entry) || typeof entry.message !== "string") return [];
      return [
        {
          path: typeof entry.field === "string" ? entry.field : "",
          message: entry.message,
        },
      ];
    });
  }

  const { detail } = body;
  if (typeof detail === "string") {
    return [{ path: "", message: detail }];
  }
  if (Array.isArray(detail)) {
    return detail.flatMap((entry) => {
      if (!isRecord(entry) || typeof entry.msg !== "string") return [];
      const loc = Array.isArray(entry.loc)
        ? (entry.loc as (string | number)[])
        : [];
      return [{ path: locToPayloadPath(loc, payload), message: entry.msg }];
    });
  }

  return [];
}

/**
 * The fields behind a payload path.
 *
 * The map is derived from a payload where every list holds a single entry, so
 * a path addressing a later index names the same field the first one does.
 * Reading every index as the first is what lets an error about the second
 * repository of a multi-value field reach that field at all, rather than
 * falling through to a form-level message that points at nothing.
 */
function fieldsAt(
  path: string,
  errorMap: Record<string, string[]>,
): string[] | undefined {
  return errorMap[path] ?? errorMap[path.replace(/\[\d+\]/g, "[0]")];
}

/** Apply the derived payload-path map, falling back to a form-level error. */
export function mapServiceErrors(
  errors: readonly ManifestServiceError[],
  errorMap: Record<string, string[]>,
): MappedManifestErrors {
  if (errors.length === 0) return EMPTY_MAPPED_ERRORS;

  const fieldErrors: Record<string, string> = {};
  const formErrors: string[] = [];

  errors.forEach(({ path, message }) => {
    const fields = path ? fieldsAt(path, errorMap) : undefined;
    if (!fields?.length) {
      formErrors.push(message);
      return;
    }
    fields.forEach((field) => {
      // Keep the first error reported against a field; later ones would
      // silently replace a message the user has not read yet.
      if (!(field in fieldErrors)) fieldErrors[field] = message;
    });
  });

  return { fieldErrors, formErrors };
}
