/**
 * `loadKnowledge` — read Smithy's knowledge pack off disk into a {@link KnowledgePack}.
 *
 * The knowledge pack used to live as one big inline constant baked into the
 * source. That made it invisible to anyone editing the prose, impossible to diff
 * sensibly, and welded the docs to the build. Here it is *externalised*: a
 * `manifest.json` lists the guides by id, title, file, and summary, and each
 * guide is a plain markdown file beside it under `guides/`. This loader is the
 * single seam that turns those files back into structured, in-memory data the
 * rest of Smithy can consult.
 *
 * The manifest is the source of truth for *which* guides exist and how they are
 * described; the markdown files are the source of truth for their *bodies*. The
 * loader reads the manifest first, then pulls each referenced file, so a guide
 * that is listed but missing on disk surfaces as a clear error rather than a
 * silent gap.
 */

import { readFile } from "node:fs/promises";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

/** One guide's manifest entry: how it is catalogued, plus where its body lives. */
export interface GuideManifestEntry {
  /** Stable, file-safe identifier used to look the guide up. */
  readonly id: string;
  /** Human-readable title. */
  readonly title: string;
  /** Path to the guide's markdown body, relative to the manifest. */
  readonly file: string;
  /** One-line description of what the guide covers. */
  readonly summary: string;
}

/** The parsed shape of `manifest.json` before bodies are attached. */
export interface KnowledgeManifest {
  /** Manifest schema/content version. */
  readonly version: string;
  /** The guides this pack ships, in catalogue order. */
  readonly guides: readonly GuideManifestEntry[];
}

/** One fully-loaded guide: its manifest metadata plus its markdown body. */
export interface Guide extends GuideManifestEntry {
  /** The guide's markdown body, read from {@link GuideManifestEntry.file}. */
  readonly body: string;
}

/** The whole knowledge pack: the manifest version plus every loaded guide. */
export interface KnowledgePack {
  /** The manifest's declared version. */
  readonly version: string;
  /** Every guide, with its body attached, in manifest order. */
  readonly guides: readonly Guide[];
  /** Look a guide up by its {@link Guide.id}; `undefined` if absent. */
  guide(id: string): Guide | undefined;
}

/** Raised when the knowledge pack cannot be loaded or fails its shape check. */
export class KnowledgeError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "KnowledgeError";
  }
}

/** The default knowledge directory: the folder this module lives in. */
function defaultDir(): string {
  return dirname(fileURLToPath(import.meta.url));
}

/**
 * Load the knowledge pack from `dir` (defaults to this module's own directory).
 *
 * Reads and validates `manifest.json`, then reads each guide's markdown body,
 * resolving file paths relative to the manifest. The returned pack is frozen and
 * carries a {@link KnowledgePack.guide} lookup. Any missing or malformed input
 * surfaces as a {@link KnowledgeError}.
 *
 * @param dir Optional directory holding `manifest.json` and `guides/`.
 */
export async function loadKnowledge(dir?: string): Promise<KnowledgePack> {
  const root = dir ? resolve(dir) : defaultDir();
  const manifest = await readManifest(root);

  const guides: Guide[] = [];
  for (const entry of manifest.guides) {
    const path = isAbsolute(entry.file) ? entry.file : join(root, entry.file);
    const body = await readGuideBody(path, entry.id);
    guides.push({ ...entry, body });
  }

  return freezePack(manifest.version, guides);
}

/** Read and shape-check `manifest.json` from `root`. */
async function readManifest(root: string): Promise<KnowledgeManifest> {
  const path = join(root, "manifest.json");
  let raw: string;
  try {
    raw = await readFile(path, "utf8");
  } catch (cause) {
    throw new KnowledgeError(
      `could not read knowledge manifest at ${path}: ${describe(cause)}`,
    );
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch (cause) {
    throw new KnowledgeError(
      `knowledge manifest is not valid JSON (${path}): ${describe(cause)}`,
    );
  }

  return validateManifest(parsed, path);
}

/** Read one guide's markdown body, mapping a read failure to a clear error. */
async function readGuideBody(path: string, id: string): Promise<string> {
  try {
    return await readFile(path, "utf8");
  } catch (cause) {
    throw new KnowledgeError(
      `could not read body for guide "${id}" at ${path}: ${describe(cause)}`,
    );
  }
}

/** Assert that a parsed value has the {@link KnowledgeManifest} shape. */
function validateManifest(value: unknown, path: string): KnowledgeManifest {
  if (!isRecord(value)) {
    throw new KnowledgeError(`knowledge manifest (${path}) is not an object`);
  }
  if (typeof value.version !== "string" || value.version.length === 0) {
    throw new KnowledgeError(
      `knowledge manifest (${path}) is missing a non-empty "version"`,
    );
  }
  if (!Array.isArray(value.guides)) {
    throw new KnowledgeError(
      `knowledge manifest (${path}) is missing a "guides" array`,
    );
  }

  const guides = value.guides.map((entry, index) =>
    validateEntry(entry, index, path),
  );

  return { version: value.version, guides };
}

/** Assert that one manifest entry has the {@link GuideManifestEntry} shape. */
function validateEntry(
  entry: unknown,
  index: number,
  path: string,
): GuideManifestEntry {
  if (!isRecord(entry)) {
    throw new KnowledgeError(
      `guide entry ${index} in ${path} is not an object`,
    );
  }
  for (const field of ["id", "title", "file", "summary"] as const) {
    if (typeof entry[field] !== "string" || entry[field].length === 0) {
      throw new KnowledgeError(
        `guide entry ${index} in ${path} is missing a non-empty "${field}"`,
      );
    }
  }
  return {
    id: entry.id as string,
    title: entry.title as string,
    file: entry.file as string,
    summary: entry.summary as string,
  };
}

/** Build the frozen pack and its `guide` lookup over a fixed index. */
function freezePack(version: string, guides: Guide[]): KnowledgePack {
  const byId = new Map(guides.map((g) => [g.id, g]));
  return Object.freeze({
    version,
    guides: Object.freeze([...guides]),
    guide(id: string): Guide | undefined {
      return byId.get(id);
    },
  });
}

/** Narrow an unknown to a plain object record. */
function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

/** Best-effort human-readable description of a thrown cause. */
function describe(cause: unknown): string {
  return cause instanceof Error ? cause.message : String(cause);
}
