/**
 * RRYM / 002 — SULSYS
 * Content loaders. Everything is read from `content/` at build time and
 * validated against the Zod schemas; a malformed file fails the build.
 *
 * Gating lives here, not in components: rows whose fact is unverified and
 * client names without permission are removed before any component sees them.
 * A component therefore cannot leak an unconfirmed claim (Blueprint 17 rule 1).
 */

import fs from "node:fs";
import path from "node:path";
import { cache } from "react";
import { z } from "zod";

import type { Locale } from "./locales";
import { LOCALES } from "./locales";
import { MODULE_IDS, SECTOR_IDS, type ModuleId, type SectorId } from "./routes";
import {
  businessTypesSchema,
  clientsFileSchema,
  companySchema,
  factsFileSchema,
  glossaryFileSchema,
  homeSchema,
  moduleSchema,
  peopleFileSchema,
  productSchema,
  sectorSchema,
  supportSchema,
  evidenceSchema,
  trailSchema,
  tutorialsFileSchema,
  uiFileSchema,
  type BusinessTypes,
  type Client,
  type CompanyContent,
  type FactsFile,
  type GatedFeature,
  type GatedRow,
  type GlossaryEntry,
  type HomeContent,
  type ModuleContent,
  type Product,
  type PublicClient,
  type SectorContent,
  type SupportContent,
  type EvidenceItem,
  type TrailContent,
  type Tutorial,
  type UiStrings,
} from "./schemas";

const CONTENT_DIR = path.join(process.cwd(), "content");

function readJson(...segments: string[]): unknown {
  const file = path.join(CONTENT_DIR, ...segments);
  let raw: string;
  try {
    raw = fs.readFileSync(file, "utf8");
  } catch {
    throw new Error(`Content file missing: ${path.join("content", ...segments)}`);
  }
  try {
    return JSON.parse(raw) as unknown;
  } catch (error) {
    throw new Error(
      `Content file is not valid JSON: ${path.join("content", ...segments)} — ${String(error)}`,
    );
  }
}

function parse<T extends z.ZodTypeAny>(schema: T, data: unknown, where: string): z.infer<T> {
  const result = schema.safeParse(data);
  if (!result.success) {
    const issues = result.error.issues
      .map((i) => `  · ${i.path.join(".") || "(root)"}: ${i.message}`)
      .join("\n");
    throw new Error(`Content validation failed for ${where}:\n${issues}`);
  }
  return result.data;
}

/* -------------------------------------------------------------------------- */
/* Facts and gating                                                           */
/* -------------------------------------------------------------------------- */

export const getFacts = cache((): FactsFile =>
  parse(factsFileSchema, readJson("facts.json"), "content/facts.json"),
);

/** True only when the named fact exists and is confirmed for publication. */
export function isVerified(facts: FactsFile, id: string): boolean {
  const fact = facts[id];
  if (!fact) {
    throw new Error(`Unknown fact id "${id}". Every claim must be declared in content/facts.json.`);
  }
  return fact.verified;
}

/** Drop every row whose fact is not verified. */
export function gateRows(facts: FactsFile, rows: GatedRow[]): GatedRow[] {
  return rows.filter((row) => isVerified(facts, row.fact));
}

export function gateFeatures(facts: FactsFile, rows: GatedFeature[]): GatedFeature[] {
  return rows.filter((row) => isVerified(facts, row.fact));
}

/* -------------------------------------------------------------------------- */
/* UI strings                                                                 */
/* -------------------------------------------------------------------------- */

export const getUi = cache((locale: Locale): UiStrings =>
  parse(uiFileSchema, readJson("ui", `ui.${locale}.json`), `content/ui/ui.${locale}.json`),
);

/**
 * Typed string lookup. A missing key throws rather than rendering a key name,
 * which keeps the failure at build time where it can be fixed.
 */
export function makeT(strings: UiStrings) {
  return function t(key: string, replacements?: Record<string, string | number>): string {
    const value = strings[key];
    if (value === undefined) {
      throw new Error(`Missing UI string: "${key}"`);
    }
    if (!replacements) return value;
    return Object.entries(replacements).reduce(
      (acc, [token, replacement]) => acc.split(`{${token}}`).join(String(replacement)),
      value,
    );
  };
}

export type T = ReturnType<typeof makeT>;

/** The string lookup for a locale. Components call this directly. */
export const getT = cache((locale: Locale): T => makeT(getUi(locale)));

/* -------------------------------------------------------------------------- */
/* Product, modules, sectors                                                  */
/* -------------------------------------------------------------------------- */

export const getProduct = cache((locale: Locale): Product => {
  const facts = getFacts();
  const raw = parse(
    productSchema,
    readJson("product", `nexys.${locale}.json`),
    `content/product/nexys.${locale}.json`,
  );
  return {
    ...raw,
    runtime: gateRows(facts, raw.runtime),
    platforms: gateRows(facts, raw.platforms),
  };
});

export const getModule = cache((locale: Locale, id: ModuleId): ModuleContent => {
  const facts = getFacts();
  const raw = parse(
    moduleSchema,
    readJson("modules", `${id}.${locale}.json`),
    `content/modules/${id}.${locale}.json`,
  );
  return { ...raw, features: gateFeatures(facts, raw.features) };
});

export const getModules = cache((locale: Locale): ModuleContent[] =>
  MODULE_IDS.map((id) => getModule(locale, id)),
);

export const getSector = cache((locale: Locale, id: SectorId): SectorContent =>
  parse(
    sectorSchema,
    readJson("sectors", `${id}.${locale}.json`),
    `content/sectors/${id}.${locale}.json`,
  ),
);

/** Only sectors whose content is enabled; a disabled sector ships nothing. */
export const getSectors = cache((locale: Locale): SectorContent[] =>
  SECTOR_IDS.map((id) => getSector(locale, id)).filter((s) => s.enabled),
);

export const getBusinessTypes = cache((locale: Locale): BusinessTypes =>
  parse(
    businessTypesSchema,
    readJson("sectors", `business-types.${locale}.json`),
    `content/sectors/business-types.${locale}.json`,
  ),
);

/* -------------------------------------------------------------------------- */
/* Clients                                                                    */
/* -------------------------------------------------------------------------- */

export const getClientsRaw = cache((): Client[] =>
  parse(clientsFileSchema, readJson("clients", "clients.json"), "content/clients/clients.json"),
);

/**
 * References as a component may see them. Names are present only for rows with
 * written permission; everything else is sector and town, per Blueprint 02
 * ("Sector-only references until written permission exists").
 */
export const getPublicClients = cache((): PublicClient[] => {
  const facts = getFacts();
  return (
    getClientsRaw()
      .filter((c) => c.permission !== "declined")
      // A row without an evidenced sector carries nothing a visitor can read,
      // and guessing one would be a claim. It stays in content, not on the page.
      .filter((c): c is Client & { sector: SectorId } => Boolean(c.sector))
      .map((c) => ({
        id: c.id,
        name: c.permission === "granted" ? c.name : null,
        sector: c.sector,
        // A town with an unconfirmed gate is a geographic claim, so it is
        // withheld the same way a name is.
        town: c.townFact && !isVerified(facts, c.townFact) ? null : (c.town ?? null),
        modules: [...c.modules],
      }))
  );
});

/** References of one sector, for the sector pages. */
export function clientsForSector(all: PublicClient[], sector: SectorId): PublicClient[] {
  return all.filter((c) => c.sector === sector);
}

/* -------------------------------------------------------------------------- */
/* Tutorials                                                                  */
/* -------------------------------------------------------------------------- */

export const getTutorialsRaw = cache((): Tutorial[] =>
  parse(tutorialsFileSchema, readJson("tutorials", "index.json"), "content/tutorials/index.json"),
);

/**
 * The library as shown. A video is listed only when it has been checked for
 * real customer data, and Nexys Web videos stay hidden until the edition is
 * confirmed (D-02, D-11).
 */
export const getTutorials = cache((): Tutorial[] => {
  const facts = getFacts();
  const webConfirmed = isVerified(facts, "nexysWeb");
  return getTutorialsRaw().filter((t) => t.listed && (webConfirmed || t.edition !== "web"));
});

export function tutorialsForModule(all: Tutorial[], id: ModuleId, limit?: number): Tutorial[] {
  const rows = all.filter((t) => t.module === id);
  return limit ? rows.slice(0, limit) : rows;
}

/* -------------------------------------------------------------------------- */
/* Support, company, home                                                     */
/* -------------------------------------------------------------------------- */

export const getSupport = cache((locale: Locale): SupportContent => {
  const facts = getFacts();
  const raw = parse(
    supportSchema,
    readJson("support", `channels.${locale}.json`),
    `content/support/channels.${locale}.json`,
  );
  return { ...raw, channels: raw.channels.filter((c) => isVerified(facts, c.fact)) };
});

export const getCompany = cache((locale: Locale): CompanyContent => {
  const facts = getFacts();
  const raw = parse(
    companySchema,
    readJson("company", `sulsys.${locale}.json`),
    `content/company/sulsys.${locale}.json`,
  );
  return {
    ...raw,
    facts: gateRows(facts, raw.facts),
    services: gateFeatures(facts, raw.services),
  };
});

export const getPeople = cache(() => {
  const rows = parse(
    peopleFileSchema,
    readJson("company", "people.json"),
    "content/company/people.json",
  );
  return rows.filter((p) => p.consent);
});

export const getHome = cache((locale: Locale): HomeContent => {
  const facts = getFacts();
  const raw = parse(homeSchema, readJson(`home.${locale}.json`), `content/home.${locale}.json`);
  return { ...raw, factRows: raw.factRows.filter((r) => isVerified(facts, r.fact)) };
});

/**
 * The public trail. Rows are gated like every other claim, and the tutorial
 * counts are measured from the library rather than written into copy, so the
 * section cannot drift away from what the channel actually holds.
 */
export const getTrail = cache((locale: Locale): TrailContent => {
  const facts = getFacts();
  const raw = parse(trailSchema, readJson(`trail.${locale}.json`), `content/trail.${locale}.json`);
  return { ...raw, entries: raw.entries.filter((entry) => isVerified(facts, entry.fact)) };
});

/* -------------------------------------------------------------------------- */
/* Public-source evidence                                                     */
/* -------------------------------------------------------------------------- */

/**
 * One resolved evidence item, with everything a component needs to render it
 * and nothing it has to look up.
 *
 * A YouTube item takes its title, duration and date from the tutorial index
 * rather than carrying its own copies, so the library and the citation can
 * never disagree about what a video is.
 */
export type ResolvedEvidence = EvidenceItem & {
  /** YouTube: the video's own title. Facebook: no title exists, so none. */
  title: string | null;
  duration: string | null;
  /** YYYY or YYYY-MM, whichever the source supports. */
  when: string | null;
  href: string;
};

const getEvidenceRaw = cache(
  (locale: Locale): EvidenceItem[] =>
    parse(evidenceSchema, readJson(`evidence.${locale}.json`), `content/evidence.${locale}.json`)
      .items,
);

/**
 * Evidence for one page, gated twice over.
 *
 * The fact register decides whether the claim may be made at all. For YouTube
 * the tutorial index decides a second time: a video that is unlisted pending a
 * personal-data spot-check, or that belongs to the unconfirmed Web edition, is
 * absent from `getTutorials()` and therefore cannot be cited here either. One
 * gate would have been enough to be correct; two mean a video cannot reappear
 * as a citation after being withheld from the library.
 */
export const getEvidence = cache((locale: Locale, placement: string): ResolvedEvidence[] => {
  const facts = getFacts();
  const tutorials = getTutorials();

  return getEvidenceRaw(locale)
    .filter((item) => item.placement === placement)
    .filter((item) => isVerified(facts, item.fact))
    .map((item): ResolvedEvidence | null => {
      if (item.source === "facebook") {
        return {
          ...item,
          title: null,
          duration: null,
          when: item.date ?? null,
          href: item.ref,
        };
      }
      const video = tutorials.find((t) => t.id === item.ref);
      if (!video) return null;
      return {
        ...item,
        title: video.title,
        duration: video.duration,
        when: video.date.slice(0, 7),
        href: `https://www.youtube.com/watch?v=${video.id}`,
      };
    })
    .filter((item): item is ResolvedEvidence => item !== null);
});

/** Every evidence row as authored, for the content lint. */
export function getEvidenceAll(locale: Locale): EvidenceItem[] {
  return getEvidenceRaw(locale);
}

/** Listed tutorials published in one calendar year. */
export function tutorialsInYear(all: Tutorial[], year: string): number {
  return all.filter((t) => t.date.startsWith(year)).length;
}

export const getGlossary = cache((): GlossaryEntry[] =>
  parse(glossaryFileSchema, readJson("glossary.json"), "content/glossary.json"),
);

/* -------------------------------------------------------------------------- */
/* Company contact details — verified; only the place names are translated    */
/* -------------------------------------------------------------------------- */

/**
 * VERIFIED on sulsys.net and facebook.com/Sulsysnet
 * (PUBLIC-SOURCE-RESEARCH 11). These are facts, not copy, so they live in code
 * rather than per-locale content and cannot drift between languages.
 */
export const CONTACT = {
  company: "Sulsys",
  street: "Mladen Simonoski 26",
  postalCode: "1230",
  city: {
    sq: "Gostivar",
    mk: "Гостивар",
    tr: "Gostivar",
    en: "Gostivar",
  } as Record<Locale, string>,
  country: {
    sq: "Maqedonia e Veriut",
    mk: "Северна Македонија",
    tr: "Kuzey Makedonya",
    en: "North Macedonia",
  } as Record<Locale, string>,
  countryCode: "MK",
  phoneDisplay: "075 251 235",
  phoneE164: "+38975251235",
  whatsapp: "https://wa.me/38975251235",
  email: "info@sulsys.net",
  facebook: "https://www.facebook.com/Sulsysnet",
  youtube: "https://www.youtube.com/@SulSysnet",
  mapsUrl: "https://www.google.com/maps/search/?api=1&query=Mladen+Simonoski+26%2C+1230+Gostivar",
  /** Verified on the old site and Facebook; used for geo structured data only. */
  website: "https://sulsys.net",
} as const;

/* -------------------------------------------------------------------------- */
/* Parity check used by the content lint                                      */
/* -------------------------------------------------------------------------- */

/** Loads every content file for every locale. Throws on the first problem. */
export function loadEverything(): void {
  getFacts();
  getClientsRaw();
  getTutorialsRaw();
  getPeople();
  getGlossary();
  for (const locale of LOCALES) {
    getUi(locale);
    getHome(locale);
    getProduct(locale);
    getSupport(locale);
    getCompany(locale);
    getTrail(locale);
    getBusinessTypes(locale);
    for (const id of MODULE_IDS) getModule(locale, id);
    for (const id of SECTOR_IDS) getSector(locale, id);
  }
}
