import { unstable_rethrow } from "next/navigation";
import { z } from "zod";

import { LaravelCmsError } from "@/lib/laravel-cms/client";

/**
 * What every Server Action returns. Actions never throw to the client: a thrown
 * error in production reaches the browser as a generic message, which is no
 * use in a toast.
 */
export type ActionResult<T = undefined> =
  | ({ ok: true; message?: string } & (T extends undefined
      ? { data?: undefined }
      : { data: T }))
  | {
      ok: false;
      error: string;
      /** Field path (e.g. "slug", "faqs.2.question") to message. */
      fieldErrors?: Record<string, string>;
      /** Set when the record changed since the editor loaded it. */
      conflict?: boolean;
    };

/** An expected failure whose message is safe to show to the user. */
export class ActionError extends Error {
  constructor(
    message: string,
    readonly status = 400,
    readonly fieldErrors?: Record<string, string>,
    readonly conflict = false,
  ) {
    super(message);
    this.name = "ActionError";
  }
}

export function zodFieldErrors(error: z.ZodError) {
  const fieldErrors: Record<string, string> = {};
  for (const issue of error.issues) {
    const path = issue.path.join(".") || "form";
    fieldErrors[path] ??= issue.message;
  }
  return fieldErrors;
}

/**
 * Runs an action body and turns failures into an `ActionResult`. Next.js
 * redirects and not-found signals pass through untouched.
 */
export async function runAction<T>(
  body: () => Promise<ActionResult<T>>,
): Promise<ActionResult<T>> {
  try {
    return await body();
  } catch (error) {
    unstable_rethrow(error);

    if (error instanceof ActionError) {
      return {
        ok: false,
        error: error.message,
        fieldErrors: error.fieldErrors,
        conflict: error.conflict || undefined,
      };
    }
    if (error instanceof z.ZodError) {
      const fieldErrors = zodFieldErrors(error);
      const first = Object.values(fieldErrors)[0];
      return {
        ok: false,
        error: first ?? "Some fields need attention.",
        fieldErrors,
      };
    }
    if (error instanceof LaravelCmsError) {
      return fromCmsError(error);
    }
    if (isNetworkError(error)) {
      console.error("[action] CMS unreachable", error);
      return {
        ok: false,
        error: "Couldn't reach the CMS. Check your connection and try again.",
      };
    }

    console.error("[action]", error);
    return {
      ok: false,
      error: "Something went wrong. Please try again.",
    };
  }
}

/**
 * Turns a Laravel CMS error into an action result. Laravel's messages are
 * written for editors (docs/laravel-blog-api.md §8.1), so they are shown as-is.
 */
function fromCmsError(error: LaravelCmsError): ActionResult<never> {
  if (error.missingRoute) {
    return {
      ok: false,
      error: `The Laravel CMS doesn't support this yet (${error.endpoint ?? "endpoint missing"}).`,
    };
  }
  if (error.status === 401 || error.status === 419) {
    return { ok: false, error: "Your session has expired. Sign in again." };
  }
  if (error.status >= 500) {
    console.error(
      "[action] CMS error",
      error.endpoint,
      error.status,
      error.message,
    );
    return {
      ok: false,
      error: "The CMS couldn't complete this. Please try again.",
    };
  }

  // Laravel validation names fields after the request body ("values.slug").
  const fieldErrors: Record<string, string> = {};
  for (const [path, message] of Object.entries(error.fieldErrors)) {
    fieldErrors[path.replace(/^values\./, "")] = message;
  }
  const hasFieldErrors = Object.keys(fieldErrors).length > 0;
  return {
    ok: false,
    error:
      error.status === 403 && !error.message
        ? "You don't have permission to do that."
        : error.message ||
          Object.values(fieldErrors)[0] ||
          "The CMS refused this change.",
    fieldErrors: hasFieldErrors ? fieldErrors : undefined,
    conflict: error.status === 409 && !hasFieldErrors ? true : undefined,
  };
}

function isNetworkError(error: unknown) {
  return (
    error instanceof Error &&
    (error.name === "TimeoutError" ||
      error.name === "AbortError" ||
      (error.name === "TypeError" && /fetch failed/i.test(error.message)))
  );
}
