import { NavigationResponse, MenuItem, MegaMenuCategory, SubMenuItem, FooterColumnItem } from "@/types/navigation";
import { HomePageResponse, HeroBannerSlide } from "@/types/home";
import { getFallbackNavigationData } from "@/data/fallbackNavigation";
import { getFallbackHomeData } from "@/data/fallbackHomeData";

export interface JoomlaArticle {
  id: number;
  title: string;
  alias: string;
  introtext: string;
  fulltext?: string;
  metadesc?: string;
  metakey?: string;
  created?: string;
  modified?: string;
  category_title?: string;
  /** Real `JM_content.hits` view counter — exposed on the search endpoint, used by `/blog/[alias]`'s view count. */
  hits?: number;
  /** Real `images.image_intro` (CMS featured image), if the article has one set. */
  imageUrl?: string | null;
  /**
   * Present only on articles under the Services category tree (a custom field
   * Joomla admins set per-article) — the real, authoritative signal for
   * whether the live site wraps this page in the trust-badge/rating/order-form
   * banner (see AGENTS.md "Services-section page chrome").
   */
  isServicePage: boolean;
  /**
   * The CMS "Page Layout" custom field's real value (`service.php`'s
   * `$Page_layout_type`) — e.g. "Old", "New", "Full_Page", "Country_page".
   * `service.php` defaults this to `'Old'` when the field is empty, and
   * collapses `'City_page'` into `'Old'` immediately (`$isCityPage = 1;
   * $Page_layout_type = "Old";`) — both replicated in `extractPageLayout`.
   * Only `'Old'` currently changes rendering in this app (see
   * `app/[...slug]/page.tsx`); the other values still get the shared
   * `$dynamic_form` banner chrome this app already builds.
   */
  pageLayout: string;
  /**
   * The banner subtitle under the H1 on Services pages — the article's
   * `alternative_readmore` attrib. Not exposed by the REST API on either the
   * search or single-article endpoint, so it comes from the bridge.
   */
  bannerSubtitle?: string;
  bannerFormTitle?: string;
  bannerFormSubtitle?: string;
  university?: string;
  unitNo?: string;
  academicLevel?: string;
  paperType?: string;
  courseCode?: string;
  organisationSelected?: string;
  questionHtml?: string;
  pageType?: string;
}

/**
 * Reads the CMS "Page Layout" custom field's actual text value. The REST
 * API serializes it inconsistently like the `page-layout` presence check
 * elsewhere in this file — `{"1": "New"}` for one value, `["Old"]` for
 * multiple — so this reads whichever value is present rather than assuming
 * a shape. Mirrors `service.php`'s own defaulting: empty → `'Old'`,
 * `'City_page'` → `'Old'` (the live template renders City_page pages with
 * the Old branch, just with a relaxed "Other Services" country filter this
 * app doesn't replicate since it isn't visible markup).
 */
const PAGE_LAYOUT_MAP: Record<string, string> = {
  "0": "Old",
  "1": "New",
  "2": "Full_Page",
  "3": "Country_page",
  "4": "Country_detail_page",
  "5": "City_page",
  "6": "Country_detail_page_new",
  "7": "pay_someone_page_design",
};

function extractPageLayout(field: string[] | Record<string, string> | null | undefined): string {
  const raw = field ? Object.values(field)[0] : undefined;
  const trimmed = raw?.trim();
  if (!trimmed) return "Old";
  const mapped = PAGE_LAYOUT_MAP[trimmed] || trimmed;
  if (mapped === "City_page") return "Old";
  return mapped;
}

/** From the category-listing bridge's `type=blog` response — see `fetchFromBridge`. */
export interface BlogPost {
  id: number;
  alias: string;
  title: string;
  excerpt: string;
  createdAt: string;
  imageUrl: string | null;
  /** Real per-article view counter (`JM_content.hits`), incremented by Joomla on every front-end view. */
  hits: number;
}

/**
 * From the bridge's `type=samples` response. `wordCount`/`pageCount` and
 * `downloads` are all real — confirmed by reading the live template
 * directly (`samplelanding.php`): `pageCount` is
 * `round(str_word_count(introtext) / 250)`, and "Downloads" there is
 * literally the article's own `hits` column (Joomla's built-in view
 * counter), not a separate downloads-tracking table — this database has
 * none, and neither does the live site's own query.
 */
export interface SampleArticle {
  id: number;
  alias: string;
  title: string;
  excerpt: string;
  createdAt: string;
  imageUrl: string | null;
  wordCount: number;
  pageCount: number;
  downloads: number;
}

/** From the category-listing bridge's `type=sample-landing` response. */
export interface SampleLandingArticle {
  id: number;
  alias: string;
  title: string;
  excerpt: string;
  /** Real subject category alias, for building `/free-samples/{subjectAlias}/{alias}`. */
  subjectAlias: string;
  wordCount: number;
  pageCount: number;
  downloads: number;
}

/**
 * The live site's "sample landing" pages — a distinct article layout
 * (`attribs.article_layout: "GAH:samplelanding"`, set per-article in the
 * CMS but not exposed by the REST API at all, confirmed by reading the
 * article row directly) that `/case-study-examples`, `/essay-examples`
 * etc. use instead of the plain article view. Which URL gets this layout,
 * and which `filter` id selects its real sample list, is itself hardcoded
 * in the live template (`samplelanding.php`'s `if ($url == '...')` chain)
 * — there's no CMS-exposed source for that mapping either, so this table
 * is copied verbatim from it, the same approach `ALIAS_BANNER_TEXT` in
 * `app/[...slug]/page.tsx` already takes for the Services banner titles.
 * `ctaText` is `null` for `business-plan-examples`, which genuinely has no
 * CTA button in the live template.
 */
export const SAMPLE_LANDING_PAGES: Record<string, { filter: number; service: string; ctaText: string | null }> = {
  "case-study-examples": { filter: 6, service: "case-study", ctaText: "Get Case Study Help" },
  "essay-examples": { filter: 9, service: "essay", ctaText: "Get Essay Help" },
  "business-plan-examples": { filter: 5, service: "business", ctaText: null },
  "coursework-examples": { filter: 8, service: "coursework", ctaText: "Get Coursework Help" },
  "dissertation-examples": { filter: 3, service: "dissertation", ctaText: "Get Dissertation Help" },
  "thesis-examples": { filter: 4, service: "thesis", ctaText: "Get Thesis Help" },
};

/** From the category-listing bridge's `type=writers` response — see `fetchFromBridge`. */
export interface WriterProfile {
  id: number;
  alias: string;
  name: string;
  position: string;
  bio: string;
  imageUrl: string | null;
  /** Real "City, Country" text — stored under a `country` column despite the name (see the bridge). */
  location: string;
  /** Real academic qualification (e.g. "Ph.D. in Accounting") — stored under a `fax` column despite the name (see the bridge). */
  degree: string;
  /** Real average from `JM_writers_review`; `null` when the writer has no reviews yet, not 0. */
  rating: number | null;
  reviewCount: number;
}

export interface WriterChatReview {
  id: number;
  subject: string;
  rating: number;
  customerFeedback: string;
  writerComment: string;
}

export interface WriterDetailProfile {
  id: number;
  alias: string;
  name: string;
  position: string;
  shortBio: string;
  bio: string;
  imageUrl: string | null;
  location: string;
  degree: string;
  rating: number;
  ordersFinished: number;
  ordersInProgress: number;
  subjects: string[];
  chatReviews: WriterChatReview[];
}

/** From the category-listing bridge's `type=reviews` response — real customer reviews (see `fetchFromBridge`). */
export interface RawReview {
  id: number;
  writerName: string;
  rating: number;
  subject: string;
  comment: string;
  date: string;
}

/**
 * From the category-listing bridge's `type=service-reviews` response — the
 * Services-page "What Our Happy Customers Have to Say?" strip
 * (`ContentHelper::reviews_dynamic()`'s real GAH/`Old`-layout branch). A
 * different real source from `RawReview` above (`JM_contact_details` under
 * catid 173, tagged per service-page alias), not the home page's
 * `JM_writers_review` pool.
 */
export interface ServiceReview {
  id: number;
  name: string;
  position: string;
  country: string;
  rating: number;
  comment: string;
  imageUrl: string | null;
  /**
   * Real live-site quirk, reproduced verbatim: `subject` is a contact
   * field (`sortname2`) that's actually storing the string "enable" for
   * most rows, not a real subject — the live site genuinely displays
   * "enable : 8 Pages Deadline: 4 days" as-is. `pages`/`deadlineDays` are
   * `ceil(word count of comment / 5)`-derived, not real order data either.
   */
  subject: string;
  pages: number;
  deadlineDays: number;
}

/**
 * A row of `JM_assignment_web` — the CMS's real per-service ordering limits.
 * `maxDays` is the furthest deadline orderable for that service (Essay 20,
 * Poster 30, everything else 180); `hoursSlice` marks services that can be
 * ordered in hours. `price` is set for only 3 of the 12 rows.
 */
export interface OrderServiceConfig {
  id: number;
  name: string;
  price: number | null;
  hoursSlice: number;
  maxDays: number;
}

/** A sidebar "Best Deals" promo — real rows from the central `coupons` table. */
export interface BestDeal {
  code: string;
  /** CMS-authored HTML, e.g. `<p class="deal-p">10% OFF</p><span>on Purchase of $1000</span>`. */
  titleHtml: string;
}

/**
 * The full `/offers` listing page's coupon cards — same `coupons` table as
 * `BestDeal`, but every matching row plus the fields that page (not the
 * sidebar) renders: the discount badge and the "Read More" panel's full
 * description.
 */
export interface OfferListItem {
  id: number;
  name: string;
  code: string;
  discount: number;
  /** Matches `offers.php`'s own switch: `1` → "%", `3` → "A", anything else → "$". */
  discountType: number;
  /** Raw CMS HTML. */
  description: string;
  /** Raw CMS HTML, e.g. `<p class="deal-p">10% OFF</p><span>on Purchase of $1000</span>`. */
  displayTitle: string;
}

/**
 * `/offers`' seasonal top banner + promo strip — the single active
 * (`status=1`) row in the central `crm_offers` table for this brand
 * (`offers.php`'s `ContentHelper::eventsLoad()`). Image paths are relative
 * to the brand's template folder, same convention as `HomeBannerOffer`.
 */
export interface OfferEvent {
  offerCode: string;
  offerName: string;
  offerImage: string;
  offerMobileImage: string;
  offerStripImage: string;
  offerStripMobileImage: string;
}

/** An "Other Services" sidebar link — the article's `related-services` field. */
export interface RelatedService {
  id: number;
  alias: string;
  title: string;
}

/**
 * The homepage hero's seasonal banner (next to the calculator) — the
 * currently active per-brand row in the central `crm_offers` table
 * (`type=home-banner` on the bridge). `homeBannerImage`/`homeBannerMobile`
 * are paths relative to the brand's template folder, e.g.
 * `images/dynamic-offers/christmas-offer/home_banner_image.png`.
 */
export interface HomeBannerOffer {
  offerCode: string;
  offerName: string;
  homeBannerImage: string;
  homeBannerMobile: string | null;
  orderUrl: string;
}

/**
 * Site-wide pricing, read from the same places the live site reads it:
 * `static-price` / `discount` are Joomla custom fields on the site's own
 * contact record; the rates are the central `currency_values` table.
 */
export interface PricingConfig {
  staticPrice: number | null;
  cashbackPercent: number | null;
  discountPercent: number | null;
  currencyRates: Array<{ country: string; currency: string; value: number; symbol: string }>;
}

/** A real "Free Samples" subject subcategory (e.g. Accounting, Business, Finance), with its real published sample count. */
export interface SampleCategory {
  id: string;
  title: string;
  alias: string;
  count: number;
}

/** Attribute shape from `/content/categories` — a working, properly paginated endpoint (unlike `/content/articles`). */
interface RawCategory {
  id: string;
  parent_id: string;
  level: string;
  title: string;
  alias: string;
  count_published?: string | number;
}

/** Raw shape of a single `menus/site/items` entry's `attributes` object. */
interface RawMenuItem {
  id: string;
  title: string;
  alias: string;
  parent_id: string;
  level: string;
  type: string;
  link: string;
  published: string;
  home: string;
  /** `params.menu_show === 0` means Joomla itself hides this item from rendered menus (login/password/system routes) even though it's a valid, published record. */
  params?: { menu_show?: number };
}

interface MenuItemsPage {
  data: { attributes: RawMenuItem }[];
  links?: { next?: string };
  meta?: { "total-pages"?: number };
}

/** Attribute shape returned for a single Joomla content article. Fields beyond the ones we read are omitted. */
interface RawArticleAttributes {
  id: string | number;
  title?: string;
  alias?: string;
  text?: string;
  introtext?: string;
  fulltext?: string;
  metadesc?: string;
  metakey?: string;
  metadata?: { metadesc?: string };
  "faq-html"?: string;
  /**
   * Custom field, only populated on Services-tree articles. The Native REST
   * API serializes this inconsistently — a single value comes back as
   * `{"1": "New"}`, multiple values as `["Old"]` — so treat it as opaque and
   * only check whether it has any entries at all.
   */
  "page-layout"?: string[] | Record<string, string> | null;
  "banner-form-title"?: string | null;
  "banner-form-subtitle"?: string | null;
  univercity?: string | null;
  "academic-paper"?: string[] | Record<string, string> | string | null;
  "unit-no"?: string | null;
  "course-code"?: string | null;
  "academic-level"?: string[] | Record<string, string> | string | null;
  "organisation-selected"?: string | null;
  question?: string | null;
  "page-type"?: string[] | Record<string, string> | string | null;
  created?: string;
  hits?: string | number;
  images?: { image_intro?: string };
}

function extractCustomFieldValue(field: string[] | Record<string, string> | string | null | undefined): string {
  if (!field) return "";
  if (typeof field === "string") return field.trim();
  const values = Object.values(field);
  return values[0]?.trim() || "";
}

interface SingleArticlePage {
  data?: { attributes: RawArticleAttributes };
}

interface ArticleSearchPage {
  data?: { id: string; attributes: RawArticleAttributes }[];
}

/**
 * Joomla's Banners component (`com_banners`), reused here as the source for
 * the "Page strips" promo blocks referenced by `STRIPn` tokens in article
 * content. The list endpoint uses a sparse fieldset — it has `name` and
 * `category_title` but not `description` — so resolving a strip is a
 * two-step lookup: list for the id, then the single-resource endpoint for
 * the actual HTML.
 */
interface BannerListPage {
  data?: { id: string; attributes: { name?: string; category_title?: string } }[];
}

interface SingleBannerPage {
  data?: { attributes: { description?: string } };
}

const ARTICLE_LINK_RE = /view=article(?:&|$)/;
const ARTICLE_ID_RE = /[?&]id=(\d+)/;
const DUPLICATE_ROOT_ALIAS_RE = /-\d+$/;
const PAGE_STRIPS_CATEGORY = "Page strips";

class JoomlaService {
  private get apiBaseUrl(): string {
    return process.env.JOOMLA_API_BASE_URL || "http://localhost/websites/api/index.php/v1";
  }

  private get apiToken(): string {
    return process.env.JOOMLA_API_TOKEN || "";
  }

  /**
   * This Joomla install serves several brands (GAH, IAH, ...) from the same API.
   * Which brand's database/content a request sees is selected entirely by the
   * `webHost` cookie (mirrors how the Joomla frontend itself switches sites).
   */
  /**
   * The Joomla site root (e.g. `http://localhost/websites/`), derived from the
   * API base so it follows the same env var. CMS-authored HTML links assets
   * relative to this root (`templates/GAH/images/...`), which would otherwise
   * resolve against the Next.js origin and 404.
   */
  private get siteBaseUrl(): string {
    return this.apiBaseUrl.replace(/api\/index\.php\/v\d+\/?$/, "");
  }

  /**
   * The template directory the CMS resolves `image_path` against — Joomla's
   * `URI::base() . 'templates/' . $app->getTemplate() . '/'`. The folder name
   * isn't the brand code (IAH's is `instantassignmenthelp`), so it's an env
   * var rather than derived from the `webHost` cookie.
   */
  private get templateBaseUrl(): string {
    const base = this.siteBaseUrl.endsWith("/") ? this.siteBaseUrl : `${this.siteBaseUrl}/`;
    return `${base}templates/${process.env.JOOMLA_TEMPLATE || "GAH"}/`;
  }

  /**
   * The Joomla install's own URL path (e.g. `/websites` on this dev
   * instance — production has none). CMS article bodies sometimes hardcode
   * internal links rooted at this path (`href="/websites/ai-humanizer"`,
   * found in the free-tool pages' `.tools-navbar` block) rather than
   * relative to the Joomla root the way asset paths are — those need the
   * prefix stripped entirely, not rewritten to an absolute URL, so they
   * resolve as routes *within this Next.js app* (`/ai-humanizer`) instead
   * of a dead `/websites/...` path that doesn't exist here. Same derivation
   * `app/api/search/route.ts` already uses for the same reason.
   */
  private get siteBasePath(): string {
    try {
      return new URL(this.siteBaseUrl).pathname.replace(/\/$/, "");
    } catch {
      return "";
    }
  }

  /**
   * Resolves the asset references CMS bodies carry:
   * - the literal `image_path` token, which the PHP templates substitute with
   *   `str_replace('image_path', $image_path, $result)` — left alone it makes
   *   `image_pathimages/foo.jpg`, a broken request against our own origin;
   * - `images_path`/`root_url`, the same pair of tokens
   *   `resolveHomeArticleTokens` already resolves for the home article —
   *   free-tool pages (e.g. `ai-humanizer`'s "Related Blogs" section) use
   *   them too (`images_pathimages/paraphrase-blog2.jpg`,
   *   `root_urlblog/others/...`), so this generic path needs the same fix,
   *   not just the home-article-only one;
   * - `templates/...` URLs written relative to the Joomla root;
   * - internal `href`s hardcoded against the Joomla install's own path (see
   *   `siteBasePath`), stripped down to an app-relative route.
   */
  private rewriteCmsAssetUrls(html: string): string {
    const base = this.siteBaseUrl.endsWith("/") ? this.siteBaseUrl : `${this.siteBaseUrl}/`;
    const gahImagesUrl = `${this.templateBaseUrl}images`;
    let result = html
      .replace(/images_pathimages\//g, `${gahImagesUrl}/`)
      .replace(/images_path(?:\/|images\/)?/g, `${gahImagesUrl}/`)
      .replace(/image_path/g, this.templateBaseUrl)
      .replace(/root_url/g, "/")
      .replace(
        /(\s(?:src|href)=")(?!https?:|\/\/|\/|#|mailto:|tel:|data:)(templates\/)/gi,
        `$1${base}$2`
      )
      // Some raw CMS blocks (e.g. `solve-my-assignment`'s `.new-offer-strip`
      // promo image) hardcode the asset path with a leading slash
      // (`src="/templates/GAH/images/..."`), which the rule above skips —
      // it only rewrites the no-leading-slash form, on purpose, so it
      // doesn't touch genuine root-relative links into this app itself
      // (e.g. `href="/free-samples"`). A leading-slash `/templates/...`
      // is never a real route in this app, only ever a raw Joomla asset
      // path, so it's always safe to rewrite onto the Joomla origin too.
      .replace(/(\s(?:src|href)=")\/(templates\/)/gi, `$1${base}$2`)
      .replace(/â€™/g, "’")
      .replace(/â€“/g, "–")
      .replace(/â€”/g, "—")
      .replace(/â€œ/g, "“")
      .replace(/â€\x9d/g, "”")
      .replace(/â€\x9c/g, "“")
      .replace(/â€/g, "”")
      // A lone U+00A0 (non-breaking space) in the CMS source, transcoded as
      // UTF-8 bytes 0xC2 0xA0 and misread as Latin-1, renders as the literal
      // two characters "Â "  — found in `/case-study-help`'s review-count
      // strip ("5087Â  Client Reviews"), real source has no such character.
      // Same mojibake family as the â€™-style fixes above, just the specific
      // byte pair for a bare NBSP rather than a smart quote/dash.
      .replace(/Â(?=\s|&nbsp;)/g, "");

    // Some raw CMS `images_path` tokens already carry a redundant
    // `<template>/images/` suffix of their own (e.g.
    // `images_pathGAH/images/striprating.png`), which the `images_path`
    // replacement above turns into a literal doubled path segment
    // (`templates/GAH/images/GAH/images/striprating.png` — confirmed on
    // `/case-study-help`'s review-strip image). Rather than guess every raw
    // token shape, this collapses that exact doubling after the fact, using
    // the real configured template name so it works for every brand, not
    // just GAH.
    const templateName = process.env.JOOMLA_TEMPLATE || "GAH";
    const escapedTemplate = templateName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    result = result.replace(
      new RegExp(`(templates/${escapedTemplate}/images/)${escapedTemplate}/images/`, "g"),
      "$1"
    );

    const basePath = this.siteBasePath;
    if (basePath) {
      const escaped = basePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
      result = result.replace(new RegExp(`(\\shref=")${escaped}(?=/|")`, "gi"), "$1");
    }

    return result;
  }

  private get siteCookieValue(): string {
    return process.env.JOOMLA_SITE_COOKIE || "GAH";
  }

  /**
   * The bridge's `type=blog` (and similar) responses carry raw HTML-entity
   * text straight from `JM_content` (e.g. `&ldquo;Responsibility&rdquo;`).
   * React renders plain strings literally rather than decoding entities, so
   * without this the entity codes show up as visible text instead of the
   * real quote marks the CMS content actually has.
   */
  private decodeHtmlEntities(text: string): string {
    const named: Record<string, string> = {
      amp: "&",
      lt: "<",
      gt: ">",
      quot: '"',
      apos: "'",
      nbsp: " ",
      ldquo: "“",
      rdquo: "”",
      lsquo: "‘",
      rsquo: "’",
      mdash: "—",
      ndash: "–",
      hellip: "…",
    };
    return text
      .replace(/&#(\d+);/g, (_m, code) => String.fromCharCode(Number(code)))
      .replace(/&#x([0-9a-fA-F]+);/g, (_m, code) => String.fromCharCode(parseInt(code, 16)))
      .replace(/&([a-zA-Z]+);/g, (m, name) => named[name] ?? m);
  }

  async fetchFromJoomla<T>(endpoint: string, revalidateSeconds: number = 60): Promise<T | null> {
    try {
      const isAbsolute = endpoint.startsWith("http://") || endpoint.startsWith("https://");
      const targetUrl = isAbsolute
        ? endpoint
        : `${this.apiBaseUrl}${endpoint.startsWith("/") ? "" : "/"}${endpoint}`;

      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 8000);

      const response = await fetch(targetUrl, {
        headers: {
          // Joomla's Native Web Services API rejects a plain "application/json"
          // Accept header with 406 Not Acceptable; it only matches its own
          // JSON:API media type.
          cache: "force-cache",
          Accept: "application/vnd.api+json",
          Authorization: `Bearer ${this.apiToken}`,
          Cookie: `webHost=${this.siteCookieValue}`,
        },
        signal: controller.signal,
        next: { revalidate: revalidateSeconds },
      });

      clearTimeout(timeoutId);

      if (response.ok) {
        return (await response.json()) as T;
      }
    } catch (error) {
      console.warn(`[JoomlaService] Failed fetching ${endpoint}:`, error);
    }
    return null;
  }

  /**
   * The REST API can't filter articles/contacts by category (see AGENTS.md
   * "Category listing bridge"), so Blog and Writers listings go through a
   * small read-only PHP endpoint on the Joomla server instead
   * (`api-bridge/category-listing.php`), guarded by the same Bearer token.
   */
  private async fetchFromBridge<T>(query: string): Promise<T | null> {
    const bridgeUrl = process.env.JOOMLA_BRIDGE_URL || "http://localhost/websites/api-bridge";
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 8000);

      const response = await fetch(`${bridgeUrl}/category-listing.php?${query}`, {
        headers: {
          Authorization: `Bearer ${this.apiToken}`,
          Cookie: `webHost=${this.siteCookieValue}`,
        },
        signal: controller.signal,
        next: { revalidate: 120 },
      });

      clearTimeout(timeoutId);
      if (response.ok) return (await response.json()) as T;
    } catch (error) {
      console.warn(`[JoomlaService] Failed fetching bridge ${query}:`, error);
    }
    return null;
  }

  async getBlogPosts(
    limit = 12,
    offset = 0,
    sort: "created" | "hits" = "created"
  ): Promise<{ data: BlogPost[]; total: number }> {
    const res = await this.fetchFromBridge<{ data: BlogPost[]; total: number }>(
      `type=blog&limit=${limit}&offset=${offset}&sort=${sort}`
    );
    if (!res) return { data: [], total: 0 };
    // `JM_content` carries raw HTML entities (e.g. `&ldquo;`) — decoded here
    // once, centrally, rather than in every caller (home page tabs, /blog
    // listing), since React renders plain strings literally.
    return {
      ...res,
      data: res.data.map((post) => ({
        ...post,
        title: this.decodeHtmlEntities(post.title),
        excerpt: this.decodeHtmlEntities(post.excerpt),
      })),
    };
  }

  /**
   * `service.php`'s "Read Our Latest {X} Blogs" strip on Old-layout Services
   * pages, scoped to a real blog sub-category (parent_id 190) matched by
   * the article's alias — ported verbatim from the `$URL`/`$bid` if/elseif
   * chain at the top of `service.php` (lines 36–66), same priority order.
   *
   * One branch of that chain (`$this->item->catid == '206'` → Programming,
   * bid 304) can't be replicated: the REST API's search endpoint doesn't
   * expose `catid` at all (confirmed against the live API response), and
   * this app has no other cheap way to get it. Every other condition here
   * is the real alias substring check from that file.
   */
  private resolveServiceBlogCategory(alias: string): { url: string; catid: number; categorySlug: string } {
    if (alias.includes("dissertation")) return { url: "Dissertation", catid: 194, categorySlug: "dissertation-help" };
    if (alias.includes("homework")) return { url: "Homework", catid: 302, categorySlug: "homework-help" };
    if (alias.includes("coursework")) return { url: "Coursework", catid: 307, categorySlug: "coursework-help" };
    if (alias.includes("thesis")) return { url: "Thesis", catid: 306, categorySlug: "thesis-help" };
    if (alias.includes("paper")) return { url: "Paper", catid: 305, categorySlug: "research-paper" };
    if (alias.includes("essay")) return { url: "Essay", catid: 301, categorySlug: "essay-writing" };
    if (alias.includes("case-study")) return { url: "Case Study", catid: 193, categorySlug: "case-study" };
    // `strpos($alias,'assignment')` and the final `else` both land here.
    return { url: "Assignment", catid: 192, categorySlug: "assignment-services" };
  }

  /**
   * Real posts for the "Read Our Latest {X} Blogs" strip — see
   * `resolveServiceBlogCategory`. Returns the section heading's `{X}` text
   * alongside the posts, and hrefs use the real 2-segment live URL shape
   * (`/blog/{category-slug}/{alias}`), confirmed against the live site
   * rather than the flat `/blog/{alias}` this app's main listing uses.
   */
  async getServiceBlogPosts(articleAlias: string, limit = 6): Promise<{ url: string; posts: (BlogPost & { href: string })[] }> {
    const { url, catid, categorySlug } = this.resolveServiceBlogCategory(articleAlias);
    const res = await this.fetchFromBridge<{ data: BlogPost[] }>(`type=blog&catid=${catid}&limit=${limit}`);
    const posts = (res?.data ?? []).map((post) => ({
      ...post,
      title: this.decodeHtmlEntities(post.title),
      excerpt: this.decodeHtmlEntities(post.excerpt),
      href: `/blog/${categorySlug}/${post.alias}`,
    }));
    return { url, posts };
  }

  async getWriters(limit = 12, offset = 0): Promise<{ data: WriterProfile[]; total: number }> {
    const res = await this.fetchFromBridge<{ data: WriterProfile[]; total: number }>(
      `type=writers&limit=${limit}&offset=${offset}`
    );
    return res || { data: [], total: 0 };
  }

  async getWriterByAlias(alias: string): Promise<WriterProfile | null> {
    const { data: writers } = await this.getWriters(500, 0);
    return writers.find((w) => w.alias.toLowerCase() === alias.toLowerCase()) || null;
  }

  async getWriterDetailByAlias(alias: string): Promise<WriterDetailProfile | null> {
    const res = await this.fetchFromBridge<WriterDetailProfile>(
      `type=writer-detail&alias=${encodeURIComponent(alias)}`
    );
    return res || null;
  }

  async getReviews(limit = 12, offset = 0): Promise<{ data: RawReview[]; total: number }> {
    const res = await this.fetchFromBridge<{ data: RawReview[]; total: number }>(
      `type=reviews&limit=${limit}&offset=${offset}`
    );
    return res || { data: [], total: 0 };
  }

  /** Real testimonials tagged to this specific service-page alias — see `ServiceReview`. */
  async getServiceReviews(articleAlias: string): Promise<ServiceReview[]> {
    const res = await this.fetchFromBridge<{ data: ServiceReview[] }>(
      `type=service-reviews&alias=${encodeURIComponent(articleAlias)}`
    );
    return res?.data ?? [];
  }

  /**
   * Unlike `/content/articles`, `/content/categories` actually supports real
   * pagination and returns a genuine `count_published` per category — no
   * bridge needed here. The live "Free Samples" page is a browser over the
   * "Samples" category's real subject subcategories (Accounting, Business,
   * Finance, ...), each with a real sample count, not a single article (the
   * article previously shown at `/free-samples` was an unrelated alias
   * coincidence — see AGENTS.md).
   */
  async getSampleCategories(rootAlias = "free-samples"): Promise<SampleCategory[]> {
    const res = await this.fetchFromJoomla<{ data: { attributes: RawCategory }[] }>(
      "/content/categories?page[limit]=500",
      300
    );
    const categories = res?.data?.map((entry) => entry.attributes) || [];
    const rootCategory = categories.find((c) => c.alias === rootAlias && c.level === "1");
    if (!rootCategory) return [];

    return categories
      .filter((c) => c.parent_id === rootCategory.id && Number(c.count_published) > 0)
      .map((c) => ({
        id: c.id,
        title: c.title,
        alias: c.alias,
        count: Number(c.count_published),
      }))
      .sort((a, b) => a.title.localeCompare(b.title));
  }

  /**
   * Real sample assignments within one subject (e.g. `/free-samples/accounting`).
   * Same broken-filter problem as Blog, so this goes through the bridge too —
   * the PHP side validates `categoryAlias` against the real Samples subtree
   * before querying, so this can't be pointed at an unrelated category.
   */
  async getSampleArticles(
    categoryAlias: string,
    limit = 12,
    offset = 0
  ): Promise<{ data: SampleArticle[]; total: number; categoryTitle: string | null }> {
    const res = await this.fetchFromBridge<{ data: SampleArticle[]; total: number; categoryTitle?: string }>(
      `type=samples&category=${encodeURIComponent(categoryAlias)}&limit=${limit}&offset=${offset}`
    );
    return { data: res?.data || [], total: res?.total || 0, categoryTitle: res?.categoryTitle || null };
  }

  /**
   * Real subject subcategories (Accounting, Business, ...) under either
   * "Questions" (category 314, the default) or "Answers" (category 188,
   * `rootAlias: "answers"`) — same `/content/categories` approach as
   * `getSampleCategories`, just rooted at a different top-level alias. Both
   * are real, separate category trees (confirmed in `JM_categories`: each
   * has its own real subject children, e.g. `answers/accounting` is a
   * different category from `questions/accounting`) — not the same data
   * shown under two URLs. Neither root category has published articles of
   * its own (see AGENTS.md §8); real content lives one level down, in these
   * subject subcategories.
   */
  async getQuestionCategories(rootAlias: "questions" | "answers" = "questions"): Promise<SampleCategory[]> {
    const res = await this.fetchFromJoomla<{ data: { attributes: RawCategory }[] }>(
      "/content/categories?page[limit]=500",
      300
    );
    const categories = res?.data?.map((entry) => entry.attributes) || [];
    const root = categories.find((c) => c.alias === rootAlias && c.level === "1");
    if (!root) return [];

    return categories
      .filter((c) => c.parent_id === root.id && Number(c.count_published) > 0)
      .map((c) => ({
        id: c.id,
        title: c.title,
        alias: c.alias,
        count: Number(c.count_published),
      }))
      .sort((a, b) => a.title.localeCompare(b.title));
  }

  /**
   * Real articles within one subject subcategory of "Questions" or
   * "Answers" (e.g. `/questions/accounting` or `/answers/accounting` — two
   * different real categories, see `getQuestionCategories`) — via the
   * bridge's `type=questions` handler (`root=answers` switches its root),
   * same shape as `getSampleArticles`/`SampleArticle` (`downloads`/`hits`,
   * `pageCount` from word count, etc. — see that interface's comment).
   */
  async getQuestionArticles(
    subjectAlias: string,
    limit = 12,
    offset = 0,
    rootAlias: "questions" | "answers" = "questions"
  ): Promise<{ data: SampleArticle[]; total: number; categoryTitle: string | null }> {
    const res = await this.fetchFromBridge<{ data: SampleArticle[]; total: number; categoryTitle?: string }>(
      `type=questions&root=${rootAlias}&category=${encodeURIComponent(subjectAlias)}&limit=${limit}&offset=${offset}`
    );
    return { data: res?.data || [], total: res?.total || 0, categoryTitle: res?.categoryTitle || null };
  }

  /**
   * The live `/questions` (or `/answers`) index's "Latest Question &
   * Answers" feed — most recent articles across every real subject under
   * that root, not one subcategory (bridge's `type=questions` with no
   * `category` param).
   */
  async getLatestQuestions(limit = 4, rootAlias: "questions" | "answers" = "questions"): Promise<SampleArticle[]> {
    const res = await this.fetchFromBridge<{ data: SampleArticle[] }>(
      `type=questions&root=${rootAlias}&limit=${limit}&offset=0`
    );
    return res?.data || [];
  }

  /**
   * Which real subtree (Samples, Questions, or Answers) an article's own
   * `catid` actually lives under, via the bridge's `type=article-location`
   * (direct DB lookup — the REST API exposes neither `catid` nor a category
   * path on an article). Needed because the CMS `page-type` custom field
   * used elsewhere as a type signal is editorial metadata, not category
   * membership, and can be (and sometimes is) wrong — e.g. a real Questions
   * article mislabeled `page-type: Samples`. Used only for articles reached
   * by a bare/non-prefixed alias, where there's no URL segment to trust
   * instead; prefixed URLs (`/free-samples/...`, `/questions/...`,
   * `/answers/...`) already know their own subject and don't need this.
   */
  async getArticleLocation(
    alias: string
  ): Promise<{ rootType: "samples" | "questions" | "answers" | null; subjectAlias: string | null }> {
    const res = await this.fetchFromBridge<{
      rootType: "samples" | "questions" | "answers" | null;
      subjectAlias: string | null;
    }>(`type=article-location&alias=${encodeURIComponent(alias)}`);
    return res || { rootType: null, subjectAlias: null };
  }

  /**
   * Real sample articles for one of the "sample landing" pages
   * (`/case-study-examples`, `/essay-examples`, ...) — mirrors
   * `samplelanding.php`'s own query: articles tagged with the "sample
   * type" custom field (`field_id=12`) matching this page's hardcoded
   * `filter` id, scoped to the real Samples subtree. See
   * `SAMPLE_LANDING_PAGES` for where `filter` comes from.
   */
  async getSampleLandingArticles(filter: number, offset = 0): Promise<SampleLandingArticle[]> {
    const res = await this.fetchFromBridge<{ data: SampleLandingArticle[] }>(
      `type=sample-landing&filter=${filter}&offset=${offset}`
    );
    return res?.data || [];
  }

  /**
   * Fetches every page of `/menus/site/items`. The GAH menu tree has ~3000
   * entries (multiple historical site trees coexist in one menutype), well
   * past a single page.
   *
   * Used to walk `links.next` sequentially, one page `await`ed before the
   * next was requested — fine when Joomla sits on the same machine (near-
   * zero latency), but measured at ~2.3s/page against a real deployed API
   * (beta), making the full ~15-page walk take 35+ seconds of pure
   * wall-clock time. Long enough to hit a hosting-platform execution
   * timeout and get killed mid-walk — confirmed: beta's built nav tree was
   * missing 10 of its 11 top-level categories, exactly what a truncated
   * walk produces. The first response already carries `meta['total-pages']`,
   * which makes every remaining page's URL predictable (`page[offset]`
   * increments by `pageSize`), so they're fetched in parallel instead —
   * total wall-clock time drops from `pageCount × latency` to roughly
   * `1 × latency`.
   */
  private async fetchAllMenuItems(): Promise<RawMenuItem[]> {
    const pageSize = 200;
    const items: RawMenuItem[] = [];

    const collect = (page: MenuItemsPage | null) => {
      if (!page?.data) return;
      for (const entry of page.data) {
        const attr = entry.attributes;
        if (attr?.title && attr.published === "1") {
          items.push(attr);
        }
      }
    };

    const firstPage = await this.fetchFromJoomla<MenuItemsPage>(
      `/menus/site/items?page[limit]=${pageSize}`,
      60
    );
    if (!firstPage?.data) return items;
    collect(firstPage);

    const totalPages = firstPage.meta?.["total-pages"] ?? 1;
    if (totalPages > 1) {
      const remainingPages = await Promise.all(
        Array.from({ length: totalPages - 1 }, (_, i) =>
          this.fetchFromJoomla<MenuItemsPage>(
            `/menus/site/items?page[limit]=${pageSize}&page[offset]=${(i + 1) * pageSize}`,
            60
          )
        )
      );
      remainingPages.forEach(collect);
    }

    return items;
  }

  /**
   * Groups the flat menu list by parent and returns only the "live" top-level
   * items. Older duplicate site trees left over in the same menutype reuse the
   * same titles with a numeric alias suffix (`home-4`, `services-9`, ...) —
   * the unsuffixed alias is always the one the live site actually renders.
   */
  private groupMenuTree(items: RawMenuItem[]) {
    const byParent = new Map<string, RawMenuItem[]>();
    for (const item of items) {
      const parentId = String(item.parent_id || "1");
      if (!byParent.has(parentId)) byParent.set(parentId, []);
      byParent.get(parentId)!.push(item);
    }

    const roots = (byParent.get("1") || []).filter(
      (item) => !DUPLICATE_ROOT_ALIAS_RE.test(item.alias || "")
    );

    return { byParent, roots };
  }

  private toHref(alias: string): string {
    const clean = String(alias || "").trim();
    if (!clean || clean === "root" || clean === "home" || clean === "index") return "/";
    return `/${clean}`;
  }

  /** True only for menu items the [...slug] route can actually resolve (single articles or custom URLs). */
  private isResolvableLink(item: RawMenuItem): boolean {
    if (item.type === "url") return true;
    return item.type === "component" && ARTICLE_LINK_RE.test(item.link || "");
  }

  /** Joomla itself hides account/system routes (login, password reset, 404, ...) from rendered menus via this flag, even though they're valid published items. */
  private isVisibleInMenu(item: RawMenuItem): boolean {
    return item.params?.menu_show !== 0;
  }

  private toLeafLink(item: RawMenuItem): SubMenuItem {
    const href = item.type === "url" ? this.toHref(item.link) : this.toHref(item.alias);
    return { label: item.title, href };
  }

  /** Flattens working links out of a subtree, skipping non-article Joomla views (search, sitemap, category listings) and menu-hidden items. */
  /**
   * Some branches (e.g. "Assignment By Countries") nest the same page under
   * several regional subcategories, so a naive flatten produces duplicate
   * hrefs within one dropdown — `seenHrefs` is threaded through the
   * recursion so the `limit` cutoff counts unique links, not raw nodes.
   */
  private collectResolvableLeaves(
    parentId: string,
    byParent: Map<string, RawMenuItem[]>,
    depthLeft: number,
    limit: number,
    seenHrefs: Set<string> = new Set()
  ): SubMenuItem[] {
    if (depthLeft <= 0) return [];
    const leaves: SubMenuItem[] = [];

    for (const child of byParent.get(parentId) || []) {
      if (leaves.length >= limit) break;
      if (!this.isVisibleInMenu(child)) continue;

      // A node can be a real page in its own right (e.g. "Management", which
      // has its own article) *and* have children — the live site's mega menu
      // shows both: the subject itself alongside its own task pages (see
      // AGENTS.md). So this adds the node when it resolves, then still
      // descends into its children rather than picking one or the other.
      if (this.isResolvableLink(child)) {
        const leaf = this.toLeafLink(child);
        if (!seenHrefs.has(leaf.href)) {
          seenHrefs.add(leaf.href);
          leaves.push(leaf);
        }
      }

      if (leaves.length >= limit) break;
      if ((byParent.get(child.id) || []).length > 0) {
        leaves.push(
          ...this.collectResolvableLeaves(child.id, byParent, depthLeft - 1, limit - leaves.length, seenHrefs)
        );
      }
    }

    return leaves;
  }

  /**
   * One mega-menu category's direct children (e.g. under "Assignment
   * Services": "Academic Writing Service", "Management", "Mathematics",
   * ...), each carrying its own real children as `.children` — confirmed
   * against the live site's actual markup (`.sub-submenu` flyout per
   * subject, e.g. hovering "Management" opens a third column with "Change
   * Management Assignment", "Conflict Management Assignment Help", ...).
   * Earlier this flattened those grandchildren into one single list
   * alongside the subject names themselves (to match the live category
   * count badges, since the API had no other way to verify the real
   * count) — this keeps that same real leaf set, just nested to match how
   * the live site actually presents it instead of flattening it away.
   */
  private collectCategoryItems(
    categoryId: string,
    byParent: Map<string, RawMenuItem[]>,
    limit: number
  ): SubMenuItem[] {
    const items: SubMenuItem[] = [];
    const seenHrefs = new Set<string>();

    for (const child of byParent.get(categoryId) || []) {
      if (items.length >= limit) break;
      if (!this.isVisibleInMenu(child)) continue;

      const grandchildren = this.collectResolvableLeaves(child.id, byParent, 3, 200);
      const isLink = this.isResolvableLink(child);

      if (!isLink && grandchildren.length === 0) continue;

      const leaf: SubMenuItem = isLink ? this.toLeafLink(child) : { label: child.title, href: "#" };
      if (seenHrefs.has(leaf.href) && leaf.href !== "#") continue;
      seenHrefs.add(leaf.href);

      if (grandchildren.length > 0) leaf.children = grandchildren;
      items.push(leaf);
    }

    return items;
  }

  private buildTopLevelItem(item: RawMenuItem, byParent: Map<string, RawMenuItem[]>): MenuItem {
    const children = (byParent.get(item.id) || []).filter((child) => this.isVisibleInMenu(child));
    const href = this.isResolvableLink(item) ? this.toHref(item.alias) : "#";

    if (children.length === 0) {
      return { id: item.id, label: item.title, href, hasSubmenu: false };
    }

    const hasGrandchildren = children.some((child) => (byParent.get(child.id) || []).length > 0);

    if (hasGrandchildren) {
      const categories: MegaMenuCategory[] = children
        .map((category) => {
          const categoryHref = this.isResolvableLink(category) ? this.toHref(category.alias) : undefined;
          return {
            categoryTitle: category.title,
            categoryHref,
            items: this.collectCategoryItems(category.id, byParent, 60),
          };
        })
        .filter((category) => category.items.length > 0);

      if (categories.length > 0) {
        return { id: item.id, label: item.title, href, hasSubmenu: true, isMegaMenu: true, categories };
      }
    }

    const subItems = children.filter((child) => this.isResolvableLink(child)).map((child) => this.toLeafLink(child));

    if (subItems.length === 0) {
      return { id: item.id, label: item.title, href, hasSubmenu: false };
    }

    return { id: item.id, label: item.title, href, hasSubmenu: true, subItems };
  }

  private buildFooterColumn(
    id: string,
    title: string,
    links: FooterColumnItem[]
  ): { id: string; title: string; links: FooterColumnItem[] } {
    return { id, title, links };
  }

  /** Finds the first menu item with a given alias anywhere in the tree (used to locate well-known sections like "Company" or "Policies"). */
  private findByAlias(items: RawMenuItem[], alias: string): RawMenuItem | undefined {
    return items.find((item) => item.alias === alias);
  }


  /**
   * "Free Resources" on the live site groups two real branches — "Tools"
   * (paraphraser, plagiarism checker, grammar checker, ...) and
   * "Referencing" (MLA, APA, Harvard, ...) — that are each real, working
   * content but individually flagged `menu_show: 0`, so the generic walk in
   * `getNavigation` never surfaces them. This reassembles the same grouping
   * from the same real nodes rather than fabricating the list.
   */
  // Walking the Tools branch pulls in internal pages that aren't nav
  // destinations (`*-result`, `essay-typer-editor`, a `testing-…` page), so
  // the curated list and order from the live template is used instead. A
  // menu item's own alias usually doubles as its route, but not always: the
  // "Thesis Statement Generator" item is aliased `thesis-statement-generator`
  // but targets the `thesis-tool` article, which is what `[...slug]` resolves
  // by — `routeAlias` covers that case. Each entry is resolved against the
  // real menu tree and dropped if it isn't there, so there are never dead
  // links and no assumption that another brand's database has the same tool.
  private resolveToolItems(rawItems: RawMenuItem[]): SubMenuItem[] {
    const resolve = (label: string, candidates: string[], routeAlias?: string): SubMenuItem | null => {
      for (const alias of candidates) {
        const node = this.findByAlias(rawItems, alias);
        if (node && this.isResolvableLink(node)) return { label, href: this.toHref(routeAlias ?? alias) };
      }
      return null;
    };

    return [
      resolve("Free Paraphrasing Tool", ["paraphrasing-tool"]),
      resolve("Plagiarism Checker", ["plagiarism-checker"]),
      resolve("Grammar Checker Tool", ["grammar-checker"]),
      resolve("AI Humanizer", ["ai-humanizer"]),
      resolve("Essay Typer Tool", ["essay-typer"]),
      resolve("Dissertation Outline Generator", ["dissertation-outline-generator"]),
      resolve("Thesis Statement Generator", ["thesis-statement-generator"], "thesis-tool"),
      resolve("Summary Generator", ["summary-generator"]),
      resolve("AI Essay Writer", ["ai-essay-writer"]),
      resolve("AI Homework Helper", ["ai-homework-helper"]),
    ].filter((item): item is SubMenuItem => item !== null);
  }

  private buildFreeResourcesItem(rawItems: RawMenuItem[]): MenuItem | null {
    const toolsNode = this.findByAlias(rawItems, "tools");
    const referencingNode = this.findByAlias(rawItems, "referencing");

    const toolItems = this.resolveToolItems(rawItems);

    const resolve = (label: string, candidates: string[], routeAlias?: string): SubMenuItem | null => {
      for (const alias of candidates) {
        const node = this.findByAlias(rawItems, alias);
        if (node && this.isResolvableLink(node)) return { label, href: this.toHref(routeAlias ?? alias) };
      }
      return null;
    };

    const referencingItems = [
      resolve("APA Referencing", ["apa-referencing-generator", "apa"]),
      resolve("MLA Referencing", ["mla-referencing-generator", "mla"]),
      resolve("Harvard Referencing", ["harvard-referencing-generator", "harvard"]),
      resolve("AMA Referencing", ["ama-referencing-generator", "ama"]),
      resolve("Chicago Referencing", ["chicago-referencing-generator", "chicago"]),
      resolve("Oxford Referencing", ["oxford-referencing-generator", "oxford", "oxford-reference"]),
      resolve("Vancouver Referencing", ["vancouver-referencing-generator", "vancouver"]),
    ].filter((item): item is SubMenuItem => item !== null);

    const categories: MegaMenuCategory[] = [];
    if (toolItems.length > 0) {
      categories.push({
        categoryTitle: "Academic Tools",
        categoryHref: toolsNode ? this.toHref(toolsNode.alias) : undefined,
        items: toolItems,
      });
    }
    if (referencingItems.length > 0) {
      categories.push({
        categoryTitle: "Referencing",
        categoryHref: referencingNode ? this.toHref(referencingNode.alias) : undefined,
        items: referencingItems,
      });
    }
    if (categories.length === 0) return null;

    return {
      id: "free-resources",
      label: "Free Resources",
      href: "#",
      hasSubmenu: true,
      categories,
    };
  }

  /**
   * Fetches Navigation (header menu + footer link columns) live from Joomla.
   * Falls back to `getFallbackNavigationData()` only if the API is unreachable.
   */
  async getNavigation(): Promise<NavigationResponse> {
    const fallback = getFallbackNavigationData();

    try {
      const rawItems = await this.fetchAllMenuItems();
      if (rawItems.length === 0) return fallback;

      const { byParent, roots } = this.groupMenuTree(rawItems);

      // The live `mod_menu` override only emits a top-level item when it has
      // children (`if ($item->deeper && $item->parent)`) — its `else` branch
      // is commented out, so childless level-1 items (Pricing, Offers,
      // Answers, "pay someone to do my assignment", ...) render nothing at
      // all. "Company" is emitted as a container with no visible label
      // (`<li class="menu-list company">` with no `<a>`), so it isn't a tab
      // either. Everything the navbar actually shows besides Services is
      // hardcoded in that template and added as `extras` below.
      const HIDDEN_TOP_LEVEL_ALIASES = new Set(["company"]);

      const mainNav = roots
        .filter((item) => this.isVisibleInMenu(item) && (item.type === "heading" || this.isResolvableLink(item)))
        .filter((item) => !HIDDEN_TOP_LEVEL_ALIASES.has(item.alias))
        .map((item) => this.buildTopLevelItem(item, byParent))
        .filter((item) => item.hasSubmenu);

      if (mainNav.length === 0) return fallback;

      // "Samples" and "Free Resources" are real sections on the live site but
      // aren't picked up by the generic walk above: the "Samples" menu item
      // points at a category view (unresolvable), yet a genuine article
      // happens to share its alias; "Tools"/"Referencing" are real, working
      // branches that Joomla itself hides from menus (`menu_show: 0`) even
      // though the live template still surfaces them under a hand-built
      // "Free Resources" grouping. Both are inserted right after "Services"
      // to match the live site's ordering.
      const servicesIndex = mainNav.findIndex((item) => item.isMegaMenu);
      const insertAt = servicesIndex >= 0 ? servicesIndex + 1 : 0;
      const extras: MenuItem[] = [];

      // Same set, labels and order as the hardcoded block in the live
      // `mod_menu` override: Samples, Questions, Blog, Writers, then the
      // "Free Resources" dropdown. Blog and Writers are category/contact
      // listings the REST API can't serve (see AGENTS.md "Category listing
      // bridge"); Samples and Questions each resolve to a real article that
      // shares the alias.
      extras.push({ id: "samples", label: "Samples", href: "/free-samples", hasSubmenu: false });
      extras.push({ id: "questions", label: "Questions", href: "/questions", hasSubmenu: false });
      extras.push({ id: "blog", label: "Blog", href: "/blog", hasSubmenu: false });
      extras.push({ id: "writers", label: "Writers", href: "/writers", hasSubmenu: false });

      const freeResources = this.buildFreeResourcesItem(rawItems);
      if (freeResources) extras.push(freeResources);

      if (extras.length > 0) mainNav.splice(insertAt, 0, ...extras);

      const servicesItem = mainNav.find((item) => item.isMegaMenu);
      const topSearches = servicesItem?.categories?.[0]?.items.slice(0, 8) || [];

      // "Get to know us" and "Country" are hardcoded directly in the live
      // footer module's own HTML (`JM_modules` id 111, position `user1`),
      // not built from the menu API tree — see `fallbackNavigation.ts`'s
      // comments there. Only "Top Assignment Searches" is genuinely
      // menu-derived (a `top_assign_services_html` token the live PHP fills
      // from the same live services list used for the mega menu).
      const ratingInfo = await this.getFooterRatingInfo(fallback.footer.ratingInfo);

      return {
        header: {
          ...fallback.header,
          mainNav,
          popularSearches:
            topSearches.length > 0
              ? topSearches.map((item) => ({ label: item.label, href: item.href }))
              : fallback.header.popularSearches,
        },
        footer: {
          ...fallback.footer,
          columns: {
            ...fallback.footer.columns,
            topSearches: this.buildFooterColumn(
              "top-searches",
              "Top Assignment Searches",
              topSearches.length > 0 ? topSearches : fallback.footer.columns.topSearches.links
            ),
          },
          ratingInfo,
        },
      };
    } catch (error) {
      console.warn("[JoomlaService] Failed to build navigation from Joomla menu items:", error);
      return fallback;
    }
  }

  /**
   * Real aggregate for the footer's "Assignment Help Rated X/5 based on Y
   * Reviews" line (the live PHP substitutes `dynamic_rating`/`dynamic_review`
   * tokens here). `getReviews()`'s `total` is an exact `COUNT(*)` from the
   * bridge; the average is computed from a real sample (its own `limit`,
   * capped by the bridge) rather than the full table, since there's no
   * dedicated aggregate endpoint — still a real computed number, not the
   * fallback's static estimate.
   */
  private async getFooterRatingInfo(
    fallbackRating: { rating: string; reviewsCount: string; reviewsUrl: string }
  ): Promise<{ rating: string; reviewsCount: string; reviewsUrl: string }> {
    // Prefer the real `tot-reviews`/`tot-ratings` custom fields (see
    // `getSiteStats()`) — the exact same figures the live site's own
    // "Samples listing sidebar" module (`mod_custom` id 118) and footer
    // display, not an approximation. Only falls back to averaging a
    // sample of real reviews if those fields are ever unavailable.
    const stats = await this.getSiteStats();
    if (stats.reviewsCount !== null && stats.rating !== null) {
      return {
        rating: `${stats.rating.toFixed(1)}/5`,
        reviewsCount: String(stats.reviewsCount),
        reviewsUrl: fallbackRating.reviewsUrl,
      };
    }

    try {
      const { data: reviews, total } = await this.getReviews(200, 0);
      if (reviews.length === 0 || total === 0) return fallbackRating;
      const avg = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
      return {
        rating: `${avg.toFixed(1)}/5`,
        reviewsCount: String(total),
        reviewsUrl: fallbackRating.reviewsUrl,
      };
    } catch {
      return fallbackRating;
    }
  }

  /**
   * Real per-brand custom fields on the site's own contact record
   * (`item_id=1`, matched by name — `tot-reviews`/`tot-writers`/
   * `tot-ratings` — since the live template's own hardcoded field ids
   * differ per brand database, same reasoning as `pricing`'s
   * `static-price`/`cashback-percent`/`discount`). These back the real
   * "4500+ Experts Writer" / "Rated 4.8/5" figures shown in the samples
   * sidebar module and elsewhere — not fabricated marketing copy.
   */
  async getSiteStats(): Promise<{ reviewsCount: number | null; writersCount: number | null; rating: number | null }> {
    const res = await this.fetchFromBridge<{ reviewsCount: number | null; writersCount: number | null; rating: number | null }>(
      "type=site-stats"
    );
    return res || { reviewsCount: null, writersCount: null, rating: null };
  }

  /**
   * Raw stored content of the real "Samples listing sidebar" module
   * (`#__modules` id 118, `mod_custom`). Joomla's Native Web Services API has
   * no `/modules` endpoint at all (verified: it 404s), so this goes through
   * the bridge like the other module/table reads. Returned unparsed so the
   * page can substitute the template's own placeholder tokens
   * (`experts_count`, `category_sample_details`, `dynamic_rating`,
   * `dynamic_review`, `dynamic_writer_section`, `order_now_url`, `root_url`,
   * `image_path`) with real values — meaning any wording/structure edit made
   * to this module in Joomla Admin shows up here on the next fetch instead
   * of being retyped into the component.
   */
  async getSamplesSidebarHtml(): Promise<string | null> {
    const res = await this.fetchFromBridge<{ content: string | null }>("type=samples-sidebar");
    return res?.content ?? null;
  }

  /** Resolves the numeric article id the live "Home" menu item points to, instead of assuming a fixed id. */
  private async resolveHomeArticleId(): Promise<number | null> {
    const rawItems = await this.fetchAllMenuItems();
    const { roots } = this.groupMenuTree(rawItems);
    const homeItem = roots.find((item) => item.home === "1") || this.findByAlias(roots, "home");
    const match = homeItem?.link ? ARTICLE_ID_RE.exec(homeItem.link) : null;
    return match ? Number(match[1]) : null;
  }

  /**
   * Fetches the home page's CMS content. The article id behind "Home" differs
   * per brand/site (each has its own Joomla database), so it's resolved from
   * the live menu instead of being hardcoded.
   */
  async getHomePage(): Promise<HomePageResponse> {
    const fallback = getFallbackHomeData();

    const homeArticleId = await this.resolveHomeArticleId();
    const article = homeArticleId
      ? await this.getArticleById(homeArticleId)
      : await this.getArticleBySlug("index");

    if (!article) return fallback;

    const articleHtml = [article.introtext, article.fulltext].filter(Boolean).join("\n");
    const result: HomePageResponse = { ...fallback, articleTitle: article.title || fallback.articleTitle };

    if (articleHtml) {
      const bannerSink: { bannerHtml?: string; bannerSlides?: HeroBannerSlide[] } = {};
      const resolvedHtml = this.rewriteCmsAssetUrls(
        await this.resolveStripPlaceholders(
          await this.resolveHomeArticleTokens(articleHtml, bannerSink)
        )
      );
      result.articleContentHtml = resolvedHtml;
      if (bannerSink.bannerHtml) {
        result.heroBannerHtml = this.rewriteCmsAssetUrls(bannerSink.bannerHtml);
      }
      if (bannerSink.bannerSlides) {
        result.heroBannerSlides = bannerSink.bannerSlides;
      }
    }

    // Fetch writers for the home page carousel
    try {
      const { data: writersData } = await this.getWriters(10);
      if (writersData && writersData.length > 0) {
        result.writers = writersData.map((w) => ({
          id: w.id,
          // Real alias, so the carousel's "View Profile" can link to the
          // writer's actual `/writers/{alias}` page instead of having
          // nowhere to go.
          alias: w.alias,
          name: w.name,
          rating: w.rating || 5.0,
          // Real review count scaled the same way as `displayOnlyCount`'s
          // callers elsewhere, when there are reviews to scale from.
          // Writers with none fall back to a seeded pseudo-random number
          // (stable per writer id, not `Math.random()` — a real random
          // call here would make this an undisclosed fabricated figure
          // that reshuffles on every request).
          completedProjects: w.reviewCount
            ? w.reviewCount * 150 + 400
            : Math.floor(1200 + (Math.abs(Math.sin(w.id)) * 10000 % 1) * 1000),
          qualification: w.position || "Academic Expert",
          bio: w.bio || `Experienced academic writer with expertise in university level research and assignment support.`,
          location: w.location || "UK",
          avatarUrl: w.imageUrl || "/images/defaultWritter.png",
        }));
      }
    } catch {
      // Keep default writers fallback if fetch fails
    }

    // Fetch blog posts for Recent and Most Viewed tabs — "Most Viewed" is
    // now genuinely sorted by real view counts (`JM_content.hits`), not just
    // the next 6 newest posts under a misleading label.
    try {
      const [recentRes, mostViewedRes] = await Promise.all([
        this.getBlogPosts(6, 0, "created"),
        this.getBlogPosts(6, 0, "hits"),
      ]);
      result.blogData = {
        recent: recentRes.data,
        mostViewed: mostViewedRes.data.length > 0 ? mostViewedRes.data : recentRes.data,
      };
    } catch {
      // Keep default fallback
    }

    // Real customer reviews (see AGENTS.md "Category listing bridge") —
    // rendered only when present, never backfilled with invented names.
    const { data: reviews } = await this.getReviews(6, 0);
    if (reviews.length > 0) {
      result.testimonialsSection = {
        ...fallback.testimonialsSection,
        testimonials: reviews.map((r) => ({
          id: String(r.id),
          writerName: r.writerName,
          rating: r.rating,
          comment: r.comment,
          subject: r.subject,
        })),
      };
    }

    return result;
  }

  private async resolveHomeArticleTokens(
    html: string,
    sink?: { bannerHtml?: string; bannerSlides?: HeroBannerSlide[] }
  ): Promise<string> {
    const siteBaseUrl = this.siteBaseUrl.replace(/\/$/, "");
    const gahTemplateUrl = `${siteBaseUrl}/templates/GAH`;
    const gahImagesUrl = `${gahTemplateUrl}/images`;
    const gahVideosUrl = `${gahTemplateUrl}/videos`;

    // The home article's `.videopart` slide references a CMS token
    // (`images_pathvideos/solve-my-assignment-new`) whose filename doesn't
    // exist as-is on disk — the real file under `templates/GAH/videos/` is
    // missing the "-new" suffix (`solve-my-assignment1.webm`/`.m4v`, verified
    // to exist there). The same dropped-suffix pattern holds for the "Avail
    // the Best" section's four video blocks (`how-it-works-new` →
    // `how-it-works`, `home-4-new` → `home-4`, etc.), which is why this is
    // treated as a real (if imperfectly named) asset rather than fabricated —
    // it's the one file that plausibly matches, not an invented substitute.
    const HERO_VIDEO_FILE_MAP: Record<string, string> = {
      "solve-my-assignment-new": "solve-my-assignment1",
      "how-it-works-new": "how-it-works",
      "home-4-new": "home-4",
      "home-1-new": "home-1",
      "home-2-new": "home-2",
    };
    const resolveVideoUrl = (rawKey: string): string => {
      const key = rawKey.trim().replace(/^\/+/, "");
      const mapped = HERO_VIDEO_FILE_MAP[key] || key;
      return `${gahVideosUrl}/${mapped}.webm`;
    };

    let result = html;

    // Replace old static writers section or writers_data token with modern carousel placeholder
    result = result.replace(/<section[^>]*class="[^"]*writers-section[^"]*"[^>]*>[\s\S]*?<\/section>/gi, "<!-- __WRITERS_CAROUSEL__ -->");
    result = result.replace(/writers_data/g, "<!-- __WRITERS_CAROUSEL__ -->");

    // Replace old static blog section with modern interactive tab component placeholder
    result = result.replace(/<section[^>]*class="[^"]*home-blog-section[^"]*"[^>]*>[\s\S]*?<\/section>/gi, "<!-- __BLOG_TABS__ -->");

    // Replace old static how it works section with interactive step accordion component placeholder
    result = result.replace(/<section[^>]*class="[^"]*sample-and-work[^"]*"[^>]*>[\s\S]*?<\/section>/gi, "<!-- __HOW_IT_WORKS__ -->");

    // Replace old static home services section with modern interactive slider component placeholder
    result = result.replace(/<section[^>]*class="[^"]*home-services-new[^"]*"[^>]*>[\s\S]*?<\/section>/gi, "<!-- __SERVICES_SLIDER__ -->");

    // 1. Home banner and token asset paths
    // Video tokens (`.videopart`'s `src="image_pathvideos/..."`) must resolve
    // BEFORE the generic `image_path`/`images_path` fallbacks below — those
    // would otherwise strip only the "image_path" prefix and leave the result
    // pointing at `.../images/videos/...`, a folder that doesn't exist (real
    // videos live directly under `templates/GAH/videos/`).
    result = result.replace(/images?_pathvideos\/([a-zA-Z0-9_-]+)/g, (_m, key) => resolveVideoUrl(key));
    // `image_pathhomebanner` resolves to whichever seasonal campaign is
    // currently active in `crm_offers` (e.g. the Christmas banner) rather
    // than a fixed generic file — falls back to the old static asset only
    // when no offer row is active for this brand. Only this method's home
    // article call ever has this token (regular articles' FAQ path calls it
    // too, but never contains it), so the extra DB round trip is skipped
    // unless the token is actually present.
    if (/image_pathhomebanner/.test(result)) {
      const activeOffer = await this.getActiveHomeBannerOffer();
      const homeBannerUrl = activeOffer
        ? `${gahTemplateUrl}/${activeOffer.homeBannerImage.replace(/^\/+/, "")}`
        : `${gahImagesUrl}/home-banner-slider1.jpg`;
      result = result.replace(/image_pathhomebanner/g, homeBannerUrl);
    }
    result = result.replace(/image_pathimages\//g, `${gahImagesUrl}/`);
    result = result.replace(/images_pathimages\//g, `${gahImagesUrl}/`);
    result = result.replace(/images_path(?:\/|images\/)?/g, `${gahImagesUrl}/`);
    result = result.replace(/image_path(?:\/|images\/)?/g, `${gahImagesUrl}/`);

    // 2. Base links and offers
    result = result.replace(/order_now_url/g, "https://customerpanel.globalassignmenthelp.com/order");
    result = result.replace(/root_url/g, "/");
    result = result.replace(/\?code=offercode/g, "?code=EXTRA25");
    result = result.replace(/offercode/g, "EXTRA25");

    // 2b. Pull the real `.slider-section` block apart: `.loading-home-banner`
    // + `.slider-banner` (the live seasonal banner image/slides) go into
    // `sink.bannerHtml` so app/page.tsx can render them as a flex sibling of
    // the real <ServiceOrderForm>, matching `.slider-section`'s actual DOM
    // shape (see homenew.scss) instead of splitting the raw article HTML
    // string around the calculator token, which orphaned the banner and the
    // form into two unrelated dangerouslySetInnerHTML subtrees.
    const sliderSectionRe =
      /<section[^>]*class="[^"]*\bslider-section\b[^"]*"[^>]*>([\s\S]*?)<div[^>]*class="[^"]*\bside-bar\b[^"]*"[^>]*>[\s\S]*?<\/section>/i;
    const sliderSectionMatch = result.match(sliderSectionRe);
    if (sliderSectionMatch) {
      const bannerHtml = sliderSectionMatch[1].trim();
      if (sink) {
        sink.bannerHtml = bannerHtml;
        sink.bannerSlides = this.parseHeroBannerSlides(bannerHtml);
      }
      result = result.replace(sliderSectionRe, "<!-- __HOME_CALCULATOR__ -->");
    }

    // 3. Dynamic strip banner token
    const stripBannerHtml = `<a href="https://customerpanel.globalassignmenthelp.com/order" class="strip-box"><img class="desktop-image" src="${gahImagesUrl}/home_strip_banner_new.jpg" alt="Assignment Help" height="100%" width="100%"/></a>`;
    result = result.replace(/dynamic_home_strip/g, stripBannerHtml);

    // 4. Form tag placeholder
    const formHtml = `<form method="post" action="https://customerpanel.globalassignmenthelp.com/order" id="bannerform" class="home-cal-form">
  <div class="form-box">
    <div class="box select_services">
      <select id="ass_group_list" aria-label="assignment" name="group_id">
        <option value="">Select Service</option>
        <option value="1" selected>Assignment Help</option>
        <option value="2">Coursework Help</option>
        <option value="3">Dissertation Help</option>
        <option value="4">Essay Writing</option>
        <option value="5">Homework Help</option>
      </select>
    </div>
    <div class="box subject_list" id="get_subject">
      <select id="assignment_subject" name="subjectId" aria-label="subject">
        <option value="">Select Subject</option>
        <option value="1">General Academic</option>
        <option value="2">Management & Business</option>
        <option value="3">Law & Criminology</option>
        <option value="4">Nursing & Health</option>
        <option value="5">Engineering & IT</option>
      </select>
    </div>
    <div class="box select-bx select-bx-half page_words" id="pages_count_parent">
      <select id="pages_count" name="pages" aria-label="pages">
        <option value="1">1 Page / 250 Words</option>
        <option value="2">2 Pages / 500 Words</option>
        <option value="3">3 Pages / 750 Words</option>
        <option value="4">4 Pages / 1000 Words</option>
        <option value="5">5 Pages / 1250 Words</option>
      </select>
    </div>
    <div class="box select-bx select-bx-half select-bx-last" id="urgency_drop">
      <select id="urgency" name="urgency" aria-label="urgency">
        <option value="15" selected>15 Days</option>
        <option value="10">10 Days</option>
        <option value="7">7 Days</option>
        <option value="5">5 Days</option>
        <option value="3">3 Days</option>
        <option value="1">1 Day / 24 Hours</option>
      </select>
    </div>
    <div class="amount-banner">
      <div class="amnt-left">
        <span class="off">25% Off</span>
        <s class="real-price basePrice">INR 873.27</s>
      </div>
      <p class="symbole">INR <span class="value offerPrice">654.95</span></p>
    </div>
  </div>
  <button type="submit" class="con_btn button">Order Now</button>
</form>`;
    result = result.replace(/Dynamic_form_tag/g, formHtml);

    // 5. Writers data token
    if (result.includes("writers_data")) {
      const writersRes = await this.getWriters(8);
      let writersHtml = "";
      if (writersRes?.data && writersRes.data.length > 0) {
        writersHtml = writersRes.data
          .map(
            (w) => `<div class="writerbx">
  <img class="lazy" src="${gahImagesUrl}/writer21.png" alt="${w.name}" />
  <div class="info">
    <h4 class="title">${w.name}</h4>
    <p class="position">${w.position || w.location || "Academic Expert"}</p>
    <div class="rating-review">${"★".repeat(w.rating ? Math.round(w.rating) : 5)}</div>
    <a class="hireme" href="https://customerpanel.globalassignmenthelp.com/order">Hire Me</a>
  </div>
</div>`
          )
          .join("\n");
      }
      result = result.replace(/writers_data/g, writersHtml);
    }

    // 6. Latest blog tokens
    if (result.includes("latestblog") || result.includes("latestbloguk")) {
      const blogRes = await this.getBlogPosts(6);
      let blogHtml = "";
      if (blogRes?.data && blogRes.data.length > 0) {
        blogHtml = blogRes.data
          .map(
            (b) => `<li>
  <a href="/blog/${b.alias}">
    <div class="left-part">
      <p class="blog-title">${b.title}</p>
      <p class="blog-detail">${b.excerpt}</p>
    </div>
    <figure class="right-part" data-wf-figure="1">
      <img alt="${b.title}" height="100%" width="100%" class="loaded" src="${gahImagesUrl}/no-image.jpg">
    </figure>
  </a>
</li>`
          )
          .join("\n");
      }
      result = result.replace(/latestbloguk/g, blogHtml);
      result = result.replace(/latestblog/g, blogHtml);
    }

    // 7. Replace data-src with src so images load without lazyload JS library
    result = result.replace(/data-src=/gi, "src=");

    // 8. Fix bare image src attributes that are relative filenames without directory prefix
    result = result.replace(
      /(<img[^>]+(?:src)=["\x27])(?!https?:|\/\/|\/|#|data:)([^"\x27]+)(["\x27])/gi,
      (match, prefix, imgPath, suffix) => {
        const cleanPath = imgPath.startsWith("images/") ? imgPath.slice(7) : imgPath;
        return `${prefix}${gahImagesUrl}/${cleanPath}${suffix}`;
      }
    );

    // Clean up any double /images/images/ paths
    result = result.replace(/\/images\/images\//g, "/images/");

    return result;
  }

  /**
   * Parses the live `.slider-banner`'s four real `<a>` tabs (order banner,
   * "how we solve your assignment" video, grammar-checker promo, plain order
   * banner) into structured slides. Regex, not an HTML parser, matching the
   * rest of this file's approach (see `app/api/subjects/route.ts`) — scoped
   * to `<a>` blocks only, safe here since the surrounding banner markup has
   * no other anchor tags (verified against the live article's raw HTML).
   *
   * Runs after the `images?_pathvideos/...` substitution above, so the
   * video `<a>`'s `src` attribute is already a fully-resolved, real
   * `templates/GAH/videos/*.webm` URL — used as-is, not re-resolved.
   */
  private parseHeroBannerSlides(bannerHtml: string): HeroBannerSlide[] {
    const slides: HeroBannerSlide[] = [];
    for (const block of bannerHtml.match(/<a\b[^>]*>[\s\S]*?<\/a>/g) || []) {
      const openTag = block.match(/^<a\b[^>]*>/)?.[0] || "";
      const href = openTag.match(/\shref="([^"]*)"/)?.[1];
      const isVideo = /\bvideopart\b/.test(openTag);
      const videoUrl = isVideo ? openTag.match(/\ssrc="([^"]*)"/)?.[1] : undefined;
      const imgTag = block.match(/<img\b[^>]*>/)?.[0] || "";
      const imageUrl = imgTag.match(/\s(?:data-src|src)="([^"]*)"/)?.[1];
      const alt = imgTag.match(/\salt="([^"]*)"/)?.[1] || "";
      if (!href || !imageUrl) continue;

      slides.push({ href, imageUrl, alt, isVideo, videoUrl });
    }
    return slides;
  }

  private looksLikeUnresolvedTemplate(html: string): boolean {
    return false;
  }

  private async getArticleById(id: number): Promise<JoomlaArticle | null> {
    const res = await this.fetchFromJoomla<SingleArticlePage>(`/content/articles/${id}`, 60);
    const attr = res?.data?.attributes;
    if (!attr) return null;

    return {
      id: Number(attr.id || id),
      title: attr.title || "",
      alias: attr.alias || "",
      introtext: attr.text || attr.introtext || "",
      fulltext: attr.fulltext || "",
      metadesc: attr.metadesc || "",
      metakey: attr.metakey || "",
      // The single-article-by-id endpoint doesn't return custom fields (unlike the search endpoint below), so this path can't detect Services pages.
      isServicePage: false,
      pageLayout: "Old",
    };
  }

  /**
   * Fetch any Article / Page dynamically via Joomla Native Core REST API by slug or alias.
   */
  async getArticleBySlug(slug: string): Promise<JoomlaArticle | null> {
    const targetSlug = slug === "" ? "index" : slug;

    const searchRes = await this.fetchFromJoomla<ArticleSearchPage>(
      `/content/articles?filter[search]=${encodeURIComponent(targetSlug)}`,
      60
    );

    if (searchRes?.data && searchRes.data.length > 0) {
      const matches = searchRes.data.filter(
        (item) => item.attributes?.alias?.toLowerCase() === targetSlug.toLowerCase()
      );

      if (matches.length > 1) {
        matches.sort((a, b) => {
          const layoutA = extractPageLayout(a.attributes?.["page-layout"]);
          const layoutB = extractPageLayout(b.attributes?.["page-layout"]);
          const isOldA = layoutA === "Old" ? 1 : 0;
          const isOldB = layoutB === "Old" ? 1 : 0;
          if (isOldA !== isOldB) return isOldA - isOldB;

          const dateA = a.attributes?.created ? new Date(a.attributes.created).getTime() : 0;
          const dateB = b.attributes?.created ? new Date(b.attributes.created).getTime() : 0;
          return dateB - dateA;
        });
      }

      const match = matches[0] || searchRes.data[0];

      if (match?.attributes) {
        const attr = match.attributes;
        const textContent = attr.text || attr.introtext || "";
        const faqHtml = attr["faq-html"] ? `\n${attr["faq-html"]}` : "";
        const isServicePage = Object.keys(attr["page-layout"] || {}).length > 0;
        const pageLayout = extractPageLayout(attr["page-layout"]);
        const alias = attr.alias || targetSlug;

        return {
          id: Number(attr.id || match.id),
          title: attr.title || targetSlug,
          alias,
          introtext: this.rewriteCmsAssetUrls(
            await this.resolveStripPlaceholders(
              alias === "affiliate"
                ? this.resolveAffiliateTokens(await this.resolveHomeArticleTokens(textContent + faqHtml))
                : await this.resolveHomeArticleTokens(textContent + faqHtml)
            )
          ),
          fulltext: attr.fulltext || "",
          metadesc: attr.metadesc || attr.metadata?.metadesc || "",
          metakey: attr.metakey || "",
          created: attr.created || undefined,
          hits: attr.hits !== undefined ? Number(attr.hits) : undefined,
          imageUrl: attr.images?.image_intro ? this.rewriteCmsAssetUrls(attr.images.image_intro) : null,
          isServicePage,
          pageLayout,
          university: attr.univercity || undefined,
          unitNo: attr["unit-no"] || undefined,
          academicLevel: extractCustomFieldValue(attr["academic-level"]) || undefined,
          paperType: extractCustomFieldValue(attr["academic-paper"]) || undefined,
          courseCode: attr["course-code"] || undefined,
          organisationSelected: attr["organisation-selected"] || undefined,
          questionHtml: attr.question ? this.rewriteCmsAssetUrls(attr.question) : undefined,
          pageType: extractCustomFieldValue(attr["page-type"]) || undefined,
          // `/our-services` isn't flagged a Services page but renders the same
          // banner via the `our_service_price_calculate_Form` token, so it
          // needs the subtitle too. Sample-landing pages (`/case-study-examples`,
          // ...) have their own real `alternative_readmore` subtitle, same
          // bridge field, just a different trigger than `isServicePage`.
          bannerSubtitle:
            isServicePage || textContent.includes("our_service_price_calculate_Form") || alias in SAMPLE_LANDING_PAGES
              ? await this.getArticleBannerSubtitle(alias)
              : undefined,
          bannerFormTitle: attr["banner-form-title"] || undefined,
          bannerFormSubtitle: attr["banner-form-subtitle"] || undefined,
        };
      }
    }

    if (!isNaN(Number(targetSlug))) {
      return this.getArticleById(Number(targetSlug));
    }

    return null;
  }

  /** Site-wide pricing — see `PricingConfig`. */
  async getPricingConfig(): Promise<PricingConfig | null> {
    return await this.fetchFromBridge<PricingConfig>("type=pricing");
  }

  /** Sidebar "Best Deals" — the central `coupons` table, as `ModuleHelper::Offers()` reads it. */
  async getBestDeals(): Promise<BestDeal[]> {
    const res = await this.fetchFromBridge<{ data: BestDeal[] }>("type=offers");
    return res?.data || [];
  }

  /**
   * The active seasonal offer for this brand (`crm_offers`), used for the
   * homepage hero banner image. `null` when no offer row is currently
   * flagged active — callers fall back to the generic banner asset.
   */
  async getActiveHomeBannerOffer(): Promise<HomeBannerOffer | null> {
    const res = await this.fetchFromBridge<{ data: HomeBannerOffer | null }>("type=home-banner");
    return res?.data ?? null;
  }

  /** `/offers` page's full coupon list — see `OfferListItem`. */
  async getAllOffers(): Promise<OfferListItem[]> {
    const res = await this.fetchFromBridge<{ data: OfferListItem[] }>("type=offers-full");
    return res?.data || [];
  }

  /** `/offers` page's seasonal banner/strip images — see `OfferEvent`. */
  async getOfferEvent(): Promise<OfferEvent | null> {
    const res = await this.fetchFromBridge<{ data: OfferEvent | null }>("type=offer-event");
    return res?.data ?? null;
  }

  /**
   * Sidebar "Other Services" — the article's `related-services` custom field
   * (field id 28). The REST API exposes the raw ids but not their aliases, so
   * this resolves them in one query rather than one call per id.
   */
  async getRelatedServices(articleId: number): Promise<RelatedService[]> {
    const res = await this.fetchFromBridge<{ data: RelatedService[] }>(
      `type=related-services&id=${articleId}`
    );
    return res?.data || [];
  }

  /**
   * The CMS's real ordering limits per service (`JM_assignment_web`), used to
   * build the order form's deadline options instead of an invented ladder.
   */
  async getOrderServiceConfig(): Promise<OrderServiceConfig[]> {
    const res = await this.fetchFromBridge<{ data: OrderServiceConfig[] }>("type=order-config");
    return res?.data || [];
  }

  /**
   * Reads one article's banner subtitle (`attribs.alternative_readmore`) via
   * the bridge. Only called for Services pages, which are the only ones that
   * render it, so ordinary article requests don't pay for an extra round trip.
   */
  private async getArticleBannerSubtitle(alias: string): Promise<string | undefined> {
    const res = await this.fetchFromBridge<{ bannerSubtitle?: string }>(
      `type=article-meta&alias=${encodeURIComponent(alias)}`
    );
    return res?.bannerSubtitle || undefined;
  }

  /**
   * Article content authored through the legacy page builder embeds literal
   * `STRIPn` tokens (e.g. `<p>STRIP1</p>`) meant to be swapped for a promo
   * block at render time. Joomla resolves these against banners in its
   * "Page strips" category (`com_banners`) — matched by the banner's `name`,
   * e.g. a banner literally named "STRIP1" — so this mirrors that by name
   * lookup rather than hardcoding specific tokens.
   *
   * Two-step lookup because the banners list endpoint uses a sparse
   * fieldset (name + category, no description) — only the single-resource
   * endpoint returns the actual HTML — so only banners actually referenced
   * by a token in this article are fetched individually.
   */
  private async resolveStripPlaceholders(html: string): Promise<string> {
    if (!html) return html;

    const bannerIds = await this.getPageStripBannerIds();
    if (bannerIds.size === 0) return html;

    // Matching is driven by the banner names that actually exist in the
    // category rather than by guessing at a `STRIP\w*` shape: real names
    // include `NEWSTRIP_ONE`, `AI_STRIP` and `whatsapp-scanner-strip`, none of
    // which that pattern caught, so they used to render as literal text.
    // Longest first so a name that prefixes another can't win.
    const pattern = [...bannerIds.keys()]
      .sort((a, b) => b.length - a.length)
      .map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
      .join("|");
    const wrappedRe = new RegExp(`<p>\\s*(${pattern})\\s*<\\/p>`, "gi");
    // Bare (non-`<p>`-wrapped) tokens — real cases like `AI_STRIP` and
    // `whatsapp-scanner-strip` sit as plain text, not inside a `<p>`. This
    // must only ever be run against text *between* tags, never the whole
    // HTML string: a real banner is literally named `STRIP1`, and running it
    // case-insensitively over everything also matched the unrelated CSS
    // class `strip1` inside `class="container strip1"` (the
    // `summary-generator` article's own markup), splicing that banner's full
    // HTML into the middle of the attribute and corrupting the tag.
    const bareRe = new RegExp(`(?<![\\w-])(${pattern})(?![\\w-])`, "gi");

    const replaceInTextOnly = (input: string, replacer: (token: string) => string | undefined): string =>
      input
        .split(/(<[^>]*>)/g)
        .map((segment) =>
          segment.startsWith("<")
            ? segment
            : segment.replace(bareRe, (fullMatch, token: string) => replacer(token.trim().toUpperCase()) ?? fullMatch)
        )
        .join("");

    const tokens = new Set<string>();
    for (const match of html.matchAll(wrappedRe)) tokens.add(match[1].trim().toUpperCase());
    replaceInTextOnly(html, (token) => {
      tokens.add(token);
      return undefined;
    });
    if (tokens.size === 0) return html;

    const replacements = new Map<string, string>();

    await Promise.all(
      [...tokens].map(async (token) => {
        const bannerId = bannerIds.get(token);
        if (!bannerId) return;
        const res = await this.fetchFromJoomla<SingleBannerPage>(`/banners/${bannerId}`, 300);
        const description = res?.data?.attributes?.description;
        if (description) replacements.set(token, this.rewriteCmsAssetUrls(description));
      })
    );

    if (replacements.size === 0) return html;

    const withWrappedResolved = html.replace(wrappedRe, (fullMatch, token: string) => {
      return replacements.get(token.trim().toUpperCase()) ?? fullMatch;
    });
    return replaceInTextOnly(withWrappedResolved, (token) => replacements.get(token));
  }

  /**
   * `/affiliate` renders through its own template override
   * (`templates/GAH/html/com_content/article/affiliate.php`), not the
   * generic article layout, and that template does two literal
   * `str_replace()`s on the article body before echoing it that nothing in
   * the REST API's `text` field resolves on its own:
   *
   * - `symbolvalue` → a currency sign, `$` if the visitor's `country` cookie
   *   reads `US`, `£` otherwise. There's no real per-visitor geo-IP cookie
   *   here, so this always takes that same `£` fallback branch — the
   *   template's own default for everyone it can't identify as US, not a
   *   guess.
   * - `mailbox` → a fixed HTML block (the referral "Quick Invite" /
   *   email-or-WhatsApp group-invite widget), copied verbatim from
   *   `affiliate.php` (its `$mailbox` variable), with the one PHP
   *   interpolation (`$image_path . 'images/affiliate/...'`) resolved to
   *   this app's real `/images/affiliate/affiliate-group-invite.gif`.
   */
  private resolveAffiliateTokens(html: string): string {
    if (!html) return html;
    const mailbox = `<div class="single-invite">
<div class="someData" id="first"><input type="text" placeholder="User Email" class="emailinput" /> <span class="affiliate-btn affiliatedesktop-btn"> Quick Invite <svg width="20px" height="20px" id="registercheckloader" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 100 100" preserveaspectratio="xMidYMid" class="lds-rolling lm_loader" style="background: none;display: none;vertical-align: sub;"> <circle cx="50" cy="50" fill="none" stroke="#fff" stroke-width="10" r="35" stroke-dasharray="164.93361431346415 56.97787143782138" transform="rotate(287.866 50 50)"> <animatetransform attributename="transform" type="rotate" calcmode="linear" values="0 50 50;360 50 50" keytimes="0;1" dur="1s" begin="0s" repeatcount="indefinite"></animatetransform> </circle> </svg> </span></div>
<div class="someData" id="second">
<div class="country-bx"><span class="countryList"><select class="countrycode" name="country"><option>Country</option><option>United Kingdom +44</option><option>United States +1</option><option>Canada +1</option><option>Australia +61</option></select></span> <input type="tel" placeholder="Mobile No." value="" name="phone" class="inputno" maxlength="10" /></div>
<span class="affiliate-btn affiliatedesktop-btn"> Quick Invite <svg width="20px" height="20px" id="registercheckloader" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 100 100" preserveaspectratio="xMidYMid" class="lds-rolling lm_loader" style="background: none;display: none;vertical-align: sub;"> <circle cx="50" cy="50" fill="none" stroke="#fff" stroke-width="10" r="35" stroke-dasharray="164.93361431346415 56.97787143782138" transform="rotate(287.866 50 50)"> <animatetransform attributename="transform" type="rotate" calcmode="linear" values="0 50 50;360 50 50" keytimes="0;1" dur="1s" begin="0s" repeatcount="indefinite"></animatetransform> </circle> </svg> </span></div>
<div class="parentClass"><label> <input type="radio" value="" name="anything" class="radioCls" id="yes" checked="checked" /><span class="radiobutton">By E-mail</span> </label> <label> <input type="radio" value="" name="anything" class="radioCls" id="no" /><span class="radiobutton">By WhatsApp</span> </label></div>
<span class="affiliate-btn affiliatemobile-btn"> Quick Invite <svg width="20px" height="20px" id="registercheckloader" xmlns="http://www.w3.org/2000/svg" viewbox="0 0 100 100" preserveaspectratio="xMidYMid" class="lds-rolling lm_loader" style="background: none;display: none;vertical-align: sub;"> <circle cx="50" cy="50" fill="none" stroke="#fff" stroke-width="10" r="35" stroke-dasharray="164.93361431346415 56.97787143782138" transform="rotate(287.866 50 50)"> <animatetransform attributename="transform" type="rotate" calcmode="linear" values="0 50 50;360 50 50" keytimes="0;1" dur="1s" begin="0s" repeatcount="indefinite"></animatetransform> </circle> </svg> </span></div>
<div class="hr">OR</div>
<div class="group-invite">
<div class="bydeafult-group"><img src="/images/affiliate/affiliate-group-invite.gif" loading="lazy" />
<div class="group-invite-btn"><span class="affiliate-btn emailbtn"> <svg width="24" height="24" viewbox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M21.6199 4.14453H2.38017C1.06768 4.14453 0 5.21216 0 6.5247V17.4756C0 18.7881 1.06768 19.8558 2.38017 19.8558H21.6198C22.9323 19.8558 24 18.7881 24 17.4756V6.5247C24 5.21216 22.9324 4.14453 21.6199 4.14453ZM22.4133 6.5247V17.4756C22.4133 17.6008 22.378 17.7151 22.3261 17.8204L16.7169 12.2109L22.4117 6.51614C22.4117 6.5193 22.4133 6.52159 22.4133 6.5247ZM1.58678 17.4756V6.5247C1.58678 6.52159 1.58839 6.51935 1.58839 6.51619L7.28311 12.2109L1.67359 17.8204C1.62204 17.715 1.58678 17.6008 1.58678 17.4756ZM12.2449 14.4393C12.114 14.5701 11.8861 14.5701 11.7552 14.4393L3.04765 5.73131H20.9528L12.2449 14.4393ZM8.40501 13.3329L10.6333 15.5612C10.9986 15.9264 11.484 16.1275 12 16.1275C12.516 16.1275 13.0015 15.9264 13.3668 15.5612L15.5951 13.3329L20.5308 18.269H3.46881L8.40501 13.3329Z" fill="white"></path> </svg> Group Invite by E-mail </span> <span class="affiliate-btn whatsappbtn"> <svg width="24" height="24" viewbox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <g clip-path="url(#clip0_1767_7)"> <path d="M17.5069 14.3062L17.4979 14.3812C15.2989 13.2852 15.0689 13.1392 14.7849 13.5652C14.5879 13.8602 14.0139 14.5292 13.8409 14.7272C13.6659 14.9222 13.4919 14.9372 13.1949 14.8022C12.8949 14.6522 11.9319 14.3372 10.7919 13.3172C9.90389 12.5222 9.30789 11.5472 9.13189 11.2472C8.83889 10.7412 9.45189 10.6692 10.0099 9.61319C10.1099 9.40319 10.0589 9.23819 9.98489 9.08919C9.90989 8.93919 9.31289 7.46919 9.06289 6.88319C8.82289 6.29919 8.57589 6.37319 8.39089 6.37319C7.81489 6.32319 7.39389 6.33119 7.02289 6.71719C5.40889 8.49119 5.81589 10.3212 7.19689 12.2672C9.91089 15.8192 11.3569 16.4732 14.0009 17.3812C14.7149 17.6082 15.3659 17.5762 15.8809 17.5022C16.4549 17.4112 17.6479 16.7812 17.8969 16.0762C18.1519 15.3712 18.1519 14.7862 18.0769 14.6512C18.0029 14.5162 17.8069 14.4412 17.5069 14.3062Z" fill="white"></path> <path d="M20.52 3.44943C12.831 -3.98357 0.106 1.40743 0.101 11.8934C0.101 13.9894 0.65 16.0334 1.696 17.8384L0 24.0004L6.335 22.3484C14.24 26.6184 23.996 20.9484 24 11.8994C24 8.72343 22.76 5.73443 20.505 3.48843L20.52 3.44943ZM22.002 11.8664C21.996 19.4994 13.617 24.2664 6.99 20.3704L6.63 20.1564L2.88 21.1314L3.885 17.4864L3.646 17.1114C-0.478 10.5464 4.26 1.96643 12.072 1.96643C14.726 1.96643 17.217 3.00143 19.093 4.87643C20.968 6.73543 22.002 9.22643 22.002 11.8664Z" fill="white"></path> </g> <defs> <clippath id="clip0_1767_7"> <rect width="24" height="24" fill="white"></rect> </clippath> </defs> </svg> Group Invite by WhatsApp </span></div>
</div>
<div class="mailwhatsapp-group mailbox" style="display:none;">
<div class="mailwhatsapp-heading"><label>Invite your friends by email</label>
<div class="selected-member">0 <span>Member Selected</span></div>
</div>
<form class="mailwhatsappbox mailform">
<div class="addlistemailwhatsapp">
<div class="addemail"><input type="email" placeholder="User Email" class="emailinput" id="new_email" /> <button class="affiliate-btn add-email" type="button">ADD MORE</button></div>
<div class="emaillist"></div>
</div>
<div class="messagebox"><label>Message</label> <textarea rows="3" readonly="readonly">Stuck with your assignment ! Tight deadlines, unclear tasks, clashing assignments are giving you sleepless night? Get professional help. https://a6c65.app.goo.gl/qMMns</textarea> <span class="affiliate-btn sendemail">Send Email</span></div>
</form></div>
<div class="mailwhatsapp-group whatsapbox" style="display:none;">
<div class="mailwhatsapp-heading"><label>Invite your friends by whatsapp</label>
<div class="selected-member">0 <span>Member Selected</span></div>
</div>
<form class="mailwhatsappbox whatsapform">
<div class="addlistemailwhatsapp">
<div class="addemail">
<div class="country-bx"><span class="countryList"><select class="countrycode" name="country"><option>Country</option><option>United Kingdom +44</option><option>United States +1</option><option>Canada +1</option><option>Australia +61</option></select></span><input type="tel" placeholder="Mobile No." value="" name="phone" class="inputno" maxlength="10" /></div>
<button class="affiliate-btn add-whatsapp" type="button">ADD MORE</button></div>
<div class="emaillist"></div>
</div>
<div class="messagebox"><label>Message</label> <textarea rows="3" readonly="readonly">Stuck with your assignment ! Tight deadlines, unclear tasks, clashing assignments are giving you sleepless night? Get professional help. https://a6c65.app.goo.gl/qMMns</textarea> <span class="affiliate-btn sendemail">Send Email</span></div>
</form></div>
</div>`;
    const withTokensResolved = html.replace(/\bmailbox\b/, mailbox).replace(/symbolvalue/g, "£");
    // This page's raw CMS body embeds several multi-MB GIFs (e.g. the
    // ~10MB `.affiliate-strip` promo animation) that sit well below the
    // fold — none of this app's CMS `<img>` tags carry `loading="lazy"`
    // (joomlaService resolves the CMS's own lazy-load `data-src` tokens
    // into a plain `src` immediately elsewhere, since there's no lazyload
    // JS library here — see `rewriteCmsAssetUrls`), so without this they'd
    // all download eagerly on page load regardless of position. Scoped to
    // just this article, and only `.gif`/`.png` sources — the article also
    // has a few layout-structural `.svg`s (e.g. `how-work-affiliate.svg`,
    // which sets its container's height for absolutely-positioned step
    // badges to sit against); lazy-loading those collapsed the container
    // until scrolled into view, since raw CMS `<img>` tags carry no
    // width/height to reserve space with. SVGs are small anyway, so the
    // real payoff (the multi-MB rasters) isn't lost by excluding them.
    return withTokensResolved.replace(
      /<img(?![^>]*\bloading=)([^>]*\bsrc="[^"]*\.(?:gif|png)")/gi,
      '<img loading="lazy"$1'
    );
  }

  private async getPageStripBannerIds(): Promise<Map<string, string>> {
    const res = await this.fetchFromJoomla<BannerListPage>("/banners?page[limit]=200", 300);
    const ids = new Map<string, string>();

    for (const entry of res?.data || []) {
      const attr = entry.attributes;
      if (attr.category_title === PAGE_STRIPS_CATEGORY && attr.name) {
        ids.set(attr.name.trim().toUpperCase(), entry.id);
      }
    }

    return ids;
  }
}

export const joomlaService = new JoomlaService();
