import "server-only";

import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import matter from "gray-matter";
import readingTime from "reading-time";

import { siteUrl } from "@/lib/env";
import { defaultLocale, locales, type Locale } from "@/lib/i18n";
import { blogCategorySlugs, getCategory } from "@/lib/blog/categories";
import type { BlogPost, Tint } from "@/lib/blog/types";

const BLOG_DIR = join(process.cwd(), "content", "blog");

/**
 * Lightweight runtime guard for the required frontmatter fields. `gray-matter`
 * returns untyped data, so a missing/empty required field would otherwise sail
 * through the `as BlogPost` cast and blow up later (e.g. `post.author.name`).
 * Returns a reason string when invalid, or `null` when the post is usable.
 */
function frontmatterError(data: unknown, slug: string): string | null {
  if (typeof data !== "object" || data === null) return "frontmatter is not an object";
  const fm = data as Record<string, unknown>;
  const requireStr = (key: string) =>
    typeof fm[key] === "string" && (fm[key] as string).trim().length > 0
      ? null
      : `missing/invalid "${key}"`;

  const err =
    requireStr("title") ??
    requireStr("date") ??
    requireStr("excerpt") ??
    null;
  if (err) return err;

  // `slug` is allowed to fall back to the filename, so it isn't required here.
  void slug;

  const author = fm.author;
  if (typeof author !== "object" || author === null)
    return 'missing "author"';
  const name = (author as Record<string, unknown>).name;
  if (typeof name !== "string" || name.trim().length === 0)
    return 'missing "author.name"';

  const thumb = fm.thumb;
  if (typeof thumb !== "object" || thumb === null)
    return 'missing "thumb"';

  return null;
}

/** Walk content/blog/<category>/<slug>.mdx and resolve every post once. */
function readAllPosts(): BlogPost[] {
  let categories: string[];
  try {
    categories = readdirSync(BLOG_DIR, { withFileTypes: true })
      .filter((e) => e.isDirectory())
      .map((e) => e.name);
  } catch {
    return [];
  }

  const posts: BlogPost[] = [];
  for (const category of categories) {
    const dir = join(BLOG_DIR, category);
    const files = readdirSync(dir).filter((f) => f.endsWith(".mdx"));
    for (const file of files) {
      const rel = `${category}/${file}`;
      // Isolate each file: a single unreadable/malformed/invalid MDX must be
      // logged and skipped, never crash the whole module (which would 500
      // every blog route).
      try {
        const raw = readFileSync(join(dir, file), "utf8");
        const { data, content } = matter(raw);

        // Filename convention: <slug>.<lang>.mdx — the slug is identical across
        // locales, so attaching a translation never alters the slug. A bare
        // <slug>.mdx falls back to the frontmatter `lang` / default locale.
        const base = file.replace(/\.mdx$/, "");
        const langMatch = base.match(/^(.+)\.([a-z]{2})$/);
        const fileLang =
          langMatch && (locales as readonly string[]).includes(langMatch[2])
            ? (langMatch[2] as Locale)
            : undefined;
        const fileSlug = fileLang ? langMatch![1] : base;

        const invalid = frontmatterError(data, fileSlug);
        if (invalid) {
          console.warn(`[blog] skipping ${rel}: ${invalid}`);
          continue;
        }
        const fm = data as BlogPost;

        const slug = fm.slug ?? fileSlug;
        const cat = fm.category ?? category;
        const tint: Tint = fm.thumb?.tint ?? getCategory(cat)?.tint ?? "blue";

        posts.push({
          ...fm,
          slug,
          category: cat,
          lang: (fileLang ?? fm.lang ?? defaultLocale) as Locale,
          tint,
          body: content,
          readingMinutes:
            fm.readingMinutes ??
            Math.max(1, Math.round(readingTime(content).minutes)),
          href: `/blog/${cat}/${slug}`,
        });
      } catch (err) {
        console.warn(`[blog] skipping ${rel}: ${(err as Error).message}`);
        continue;
      }
    }
  }

  // Newest first.
  return posts.sort((a, b) => (a.date < b.date ? 1 : -1));
}

/** Build the singleton once, emitting a one-line cold-start timing/count. */
function buildAllPosts(): BlogPost[] {
  const started = Date.now();
  const result = readAllPosts();
  console.debug(
    `[blog] cold start: resolved ${result.length} posts in ${Date.now() - started}ms`,
  );
  return result;
}

// Resolve once per server process.
const ALL_POSTS = buildAllPosts();

/** Posts for a locale, falling back to the default locale when empty. */
export function getAllPosts(lang: Locale): BlogPost[] {
  const localized = ALL_POSTS.filter((p) => p.lang === lang);
  return localized.length > 0
    ? localized
    : ALL_POSTS.filter((p) => p.lang === defaultLocale);
}

export function getFeatured(lang: Locale): BlogPost[] {
  return getAllPosts(lang).filter((p) => p.featured);
}

export function getByCategory(lang: Locale, category: string): BlogPost[] {
  return getAllPosts(lang).filter((p) => p.category === category);
}

export function getPost(
  lang: Locale,
  category: string,
  slug: string,
): BlogPost | undefined {
  return getAllPosts(lang).find(
    (p) => p.category === category && p.slug === slug,
  );
}

/** Up to `limit` posts sharing the category (or tags), excluding the post. */
export function getRelated(post: BlogPost, limit = 3): BlogPost[] {
  const pool = getAllPosts(post.lang).filter((p) => p.href !== post.href);
  const sameCat = pool.filter((p) => p.category === post.category);
  const rest = pool.filter((p) => p.category !== post.category);
  return [...sameCat, ...rest].slice(0, limit);
}

/** Static params for every post across every locale. */
export function getAllPostParams(): { category: string; slug: string }[] {
  // Build from the default-locale set (URLs are the same across locales via
  // fallback); dedupe by href.
  const seen = new Set<string>();
  const out: { category: string; slug: string }[] = [];
  for (const p of ALL_POSTS) {
    const key = `${p.category}/${p.slug}`;
    if (seen.has(key)) continue;
    seen.add(key);
    out.push({ category: p.category, slug: p.slug });
  }
  return out;
}

const SITE_URL = siteUrl;

/** Locales that have a real translation (same slug) for this category/slug. */
export function getPostLocales(category: string, slug: string): Locale[] {
  return ALL_POSTS.filter(
    (p) => p.category === category && p.slug === slug,
  ).map((p) => p.lang);
}

/**
 * canonical + hreflang alternates for a post. `languages` lists only the
 * locales that actually have a translation; `x-default` points to the source
 * locale. A locale without its own translation canonicalises to the source so
 * the fallback page is not indexed as duplicate content.
 */
export function getPostAlternates(
  lang: Locale,
  category: string,
  slug: string,
): { canonical: string; languages: Record<string, string> } {
  const locs = getPostLocales(category, slug);
  const url = (l: Locale) => `${SITE_URL}/${l}/blog/${category}/${slug}`;
  const languages: Record<string, string> = {};
  for (const l of locs) languages[l] = url(l);
  languages["x-default"] = url(defaultLocale);
  // Canonical is strictly self: an untranslated locale never renders metadata
  // for this post (the page 404s), so this resolver is only ever called for a
  // locale that actually has a translation — no fallback canonical needed.
  const canonical = url(lang);
  return { canonical, languages };
}

export { blogCategorySlugs };
