import { notFound, permanentRedirect } from "next/navigation";
import type { Metadata } from "next";

import {
  preview,
  resolve,
  site,
  type PreviewResponse,
  type ResolveResponse,
} from "@/lib/api";
import { splitSegments } from "@/lib/url";
import { localeConfig, LOCALES, X_DEFAULT } from "@/lib/locales";
import { previewTicket, ticketMatches } from "@/lib/preview";
import { renderTemplate } from "@/components/templates";
import SiteChrome from "@/components/SiteChrome";
import PreviewBanner from "@/components/PreviewBanner";
import JsonLd from "@/components/JsonLd";
import { makeTranslator, type RenderContext } from "@/lib/i18n";

/**
 * Every public URL on the site arrives here.
 *
 * There is exactly one page route, matching the backend's single catch-all.
 * There is deliberately no /projects/[slug] beside /[slug]: the whole point of
 * the url_paths scheme is that a page, a project and an article can sit
 * directly beneath the domain root without colliding, and recreating
 * type-based route folders would throw that away and reintroduce the ordering
 * bugs it was built to prevent.
 */

type Params = { slug?: string[] };
type Search = Record<string, string | string[] | undefined>;

interface Props {
  params: Promise<Params>;
  searchParams: Promise<Search>;
}

/** Query keys that belong to the page rather than to our own plumbing. */
const PAGE_QUERY = ["type", "status", "style", "location", "sort", "page"];

function pageQuery(search: Search): Record<string, string> {
  const out: Record<string, string> = {};

  for (const key of PAGE_QUERY) {
    const value = search[key];
    if (typeof value === "string" && value !== "") {
      out[key] = value;
    }
  }

  return out;
}

/**
 * The preview for this exact URL, or null.
 *
 * Scoped to the previewed record's own path: draft mode stays on while the
 * editor clicks around, and rendering the previewed record on some other URL
 * would misrepresent that URL. Anything else falls through to `load()` and the
 * published page.
 */
async function loadPreview(params: Params): Promise<PreviewResponse | null> {
  const ticket = await previewTicket();

  if (ticket === null) {
    return null;
  }

  const { locale, path } = splitSegments(params.slug);

  if (!ticketMatches(ticket, locale, path)) {
    return null;
  }

  try {
    return await preview(locale, ticket.query);
  } catch {
    /*
     * An expired signature or a deleted record must not 500 the page. Falling
     * through to the published version is the honest answer, and the editor
     * simply clicks Preview again.
     */
    return null;
  }
}

async function load(params: Params, search: Search): Promise<ResolveResponse> {
  const { locale, path } = splitSegments(params.slug);
  const query = pageQuery(search);

  let payload: ResolveResponse | null;

  try {
    payload = await resolve(locale, path, query);
  } catch (error: any) {
    /*
     * 409 carries a redirect directive rather than a page.
     *
     * `?page=1` is a second address for a page that already has one — it
     * answered 200 beside the bare URL on the previous build, so every listing
     * had two addresses for its first page. The backend decides the target,
     * preserving every other query parameter: dropping the filters along with
     * the page number would redirect a filtered listing to the unfiltered one.
     */
    if (error?.status === 409 && error.body?.redirect?.to) {
      permanentRedirect(error.body.redirect.to);
    }
    throw error;
  }

  if (payload === null) {
    notFound();
  }

  return payload;
}

export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
  const resolvedParams = await params;
  const payload =
    (await loadPreview(resolvedParams)) ?? (await load(resolvedParams, await searchParams));
  const meta = payload.meta;

  /*
   * Straight assignment from the API. Nothing is derived here.
   *
   * If this file ever computes a canonical, fills in a missing alternate, or
   * defaults a title, the single-URL-formatter invariant is broken and the
   * lowercase/uppercase percent-encoding split that this architecture exists to
   * prevent is back.
   */
  const languages: Record<string, string> = {};

  // Absent locales are absent on purpose: content does not fall back between
  // locales, so a record with no Arabic title advertises no Arabic alternate.
  // Only emitted when a real counterpart exists, matching the Blade layer.
  if (Object.keys(meta.alternates).length > 1) {
    for (const [code, url] of Object.entries(meta.alternates)) {
      languages[LOCALES[code as keyof typeof LOCALES].hreflang] = url;
    }

    const xDefault = meta.alternates[meta.xDefault ?? X_DEFAULT];
    if (xDefault) {
      languages["x-default"] = xDefault;
    }
  }

  return {
    title: meta.title,
    description: meta.description ?? undefined,
    alternates: {
      canonical: meta.canonical || undefined,
      languages: Object.keys(languages).length > 0 ? languages : undefined,
    },
    robots: meta.robots ?? undefined,
    openGraph: {
      type: meta.og.type === "article" ? "article" : "website",
      title: meta.og.title ?? meta.title,
      description: meta.og.description ?? undefined,
      url: meta.canonical || undefined,
      images: meta.og.image ? [{ url: meta.og.image }] : undefined,
      publishedTime: meta.publishedTime ?? undefined,
      modifiedTime: meta.modifiedTime ?? undefined,
    },
    twitter: {
      card: meta.og.image ? "summary_large_image" : "summary",
      title: meta.og.title ?? meta.title,
      description: meta.og.description ?? undefined,
      images: meta.og.image ? [meta.og.image] : undefined,
    },
    // rel=prev/next are NOT set here. Next's Metadata API has no field for
    // them, and routing them through `other` emits <meta name="link:next">,
    // which is not the same tag and is not what the Blade site shipped. They
    // are rendered as real <link> elements in the component below.
  };
}

/**
 * Pagination links.
 *
 * React 19 hoists <link> into <head>, so rendering them here produces the same
 * markup the Blade layout did. The URLs are the paginator's, re-addressed by
 * the backend at the public listing path — not at the API endpoint.
 */
function PaginationLinks({ prev, next }: { prev: string | null; next: string | null }) {
  return (
    <>
      {prev ? <link rel="prev" href={prev} /> : null}
      {next ? <link rel="next" href={next} /> : null}
    </>
  );
}

export default async function ContentPage({ params, searchParams }: Props) {
  const resolvedParams = await params;
  const search = await searchParams;

  const { locale, path } = splitSegments(resolvedParams.slug);
  const draft = await loadPreview(resolvedParams);
  const payload = draft ?? (await load(resolvedParams, search));
  const chrome = await site(locale);

  const config = localeConfig(locale);

  /*
   * Everything a section needs beyond its own props, assembled once and passed
   * explicitly down the tree. Not a module-level store — that would be shared
   * across concurrent requests on the server and could serve one visitor's
   * locale to another — and not a React context, which Server Components cannot
   * read.
   */
  const ctx: RenderContext = {
    t: makeTranslator(chrome?.translations),
    locale,
    dir: config.dir,
    pageUrls: chrome?.pageUrls ?? {},
    // Present only on the home page, where the hero renders the catalogue's
    // filter inventory.
    filterOptions: payload.data?.filterOptions,
  };

  return (
    <SiteChrome
      site={chrome}
      locale={locale}
      path={path}
      dir={config.dir}
      dataPage={dataPageFor(payload)}
      meta={payload.meta}
      t={ctx.t}
    >
      {/*
        The graph is rendered from the pre-encoded string the API ships, not
        re-serialised from the array. JSON.stringify would reorder keys and
        re-escape unicode — semantically identical, but it makes the parity diff
        noisy for no gain, and SchemaGenerator stays the single authority.
      */}
      <JsonLd json={payload.meta.schemaJson} />

      <PaginationLinks prev={payload.meta.prevUrl} next={payload.meta.nextUrl} />

      {renderTemplate(payload, ctx)}

      {draft ? (
        <PreviewBanner
          dir={config.dir}
          stale={draft.previewStale}
          label={draft.previewStale ? draft.previewLabels.stale : draft.previewLabels.banner}
          exitLabel={draft.previewLabels.exit}
          exitHref={`/api/preview/exit?to=${encodeURIComponent(currentHref(draft.locale, draft.path))}`}
        />
      ) : null}
    </SiteChrome>
  );
}

/**
 * The URL of the page being previewed, for the "exit preview" link.
 *
 * Encoded per segment: paths are stored raw UTF-8 and are usually Arabic, and
 * the exit route puts this straight into a `Location` header, which is a
 * ByteString and throws on anything above 255.
 *
 * This is the one place the frontend assembles a URL, and it is not a public
 * one — it never reaches a canonical, a sitemap or an hreflang set.
 */
function currentHref(locale: string, path: string): string {
  const prefix = locale === "ar" ? "" : `/${locale}`;
  const encoded = path.split("/").map(encodeURIComponent).join("/");

  return path === "" ? prefix || "/" : `${prefix}/${encoded}`;
}

/**
 * The `data-page` value for <body>.
 *
 * 29 rules in the stylesheet are scoped to this attribute. A missing or changed
 * value is a visual regression that the CSS itself cannot express, so it is
 * derived from the same template key the backend already decided.
 */
function dataPageFor(payload: ResolveResponse): string {
  switch (payload.template) {
    case "home":
      return "home";
    case "projects":
      return "projects";
    case "posts-index":
      return payload.data?.postType === "news" ? "news" : "articles";
    case "media":
      return "media";
    case "project":
    case "project-full":
      return "project";
    case "post":
      return payload.data?.post?.type === "news" ? "news" : "article";
    case "service":
      return "service";
    default:
      return payload.data?.page?.key ?? "page";
  }
}
