/**
 * Blog image layout — publish side
 *
 * The Assets step fixes where every image sits in an article and stores that as
 * `post.imageLayout`; the Final Preview step renders the article from it. This
 * is the same composition, on the server, for the publish path — so what goes
 * out to Blogger is the article the user signed off on rather than the bare
 * text.
 *
 * It is a deliberate twin of the frontend's
 * src/frontend/src/modules/content/blog-content-os/assetPlan.ts (layout +
 * composition sections). The placement rules have to agree exactly or the
 * published post and the preview drift apart; change one, change both.
 *
 * The one thing this file adds over the frontend twin is materialisation:
 * generated and uploaded images are held as base64 data URIs, which no blog
 * host will accept inline. materialiseBlogImages() writes them out as real
 * files under `uploads/blog-images/` and rewrites the document to point at
 * their public URLs.
 */

import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { buildTocFromMarkdown, slugifyHeading, type BlogTocEntry } from '../aiContext/blogToc';

// ============================================
// TYPES
// ============================================

export type BlogImageSlot = 'hero' | 'section' | 'closing';
export type BlogImageWidth = 'full' | 'wide' | 'inline';

export interface BlogImagePlacement {
  assetId: string;
  slot: BlogImageSlot;
  anchorHeading?: string;
  anchorId?: string;
  order: number;
  width: BlogImageWidth;
  alignment: 'center' | 'left' | 'right';
  caption: string;
  altText: string;
}

export interface BlogAssetLike {
  id: string;
  type: string;
  description?: string;
  sectionTitle?: string;
  dimensions?: string;
  images?: { url: string; name?: string; source?: string }[];
}

export interface BlogPostLike {
  title: string;
  content?: string;
  imageLayout?: BlogImagePlacement[];
  suggestedAssets?: BlogAssetLike[];
}

export type BlogDocumentBlock =
  | { kind: 'markdown'; markdown: string }
  | { kind: 'image'; placement: BlogImagePlacement; asset?: BlogAssetLike; urls: string[] };

/** Asset types that are social collateral, not images inside the article. */
const SOCIAL_ASSET_TYPES = new Set(['social-post', 'linkedin-post', 'twitter-thread']);

// ============================================
// LAYOUT
// ============================================

function placeableAssets(assets: BlogAssetLike[]): BlogAssetLike[] {
  return assets.filter((a) => !SOCIAL_ASSET_TYPES.has(String(a.type)));
}

function widthForAsset(asset: BlogAssetLike, slot: BlogImageSlot): BlogImageWidth {
  if (slot === 'hero') return 'full';
  return asset.type === 'chart' ? 'inline' : 'wide';
}

function altTextFor(asset: BlogAssetLike, title: string, slot: BlogImageSlot): string {
  if (slot === 'hero') return title;
  if (asset.sectionTitle) return `${asset.sectionTitle} — ${title}`;
  return asset.description || title;
}

/**
 * Work out where each image goes, from the article's own headings.
 *
 * Mirrors buildImageLayout() in the frontend twin: featured image under the
 * title, every other image under the heading it was written for, images whose
 * heading is gone spread across headings that have none, the rest at the end.
 */
export function buildImageLayout(
  assets: BlogAssetLike[],
  context: { title: string; headings: BlogTocEntry[] },
): BlogImagePlacement[] {
  const images = placeableAssets(assets);
  if (images.length === 0) return [];

  const headingBySlug = new Map(context.headings.map((h) => [h.anchor, h]));
  const headingOrder = new Map(context.headings.map((h, i) => [h.anchor, i]));

  const hero = images.find((a) => a.type === 'featured-image');
  const rest = images.filter((a) => a !== hero);

  const anchored: { asset: BlogAssetLike; anchor: string }[] = [];
  const unanchored: BlogAssetLike[] = [];
  for (const asset of rest) {
    const slug = asset.sectionTitle ? slugifyHeading(asset.sectionTitle) : '';
    if (slug && headingBySlug.has(slug)) anchored.push({ asset, anchor: slug });
    else unanchored.push(asset);
  }

  const used = new Set(anchored.map((a) => a.anchor));
  const free = context.headings.filter((h) => !used.has(h.anchor));
  const leftovers: BlogAssetLike[] = [];
  unanchored.forEach((asset, i) => {
    const target = free[i];
    if (target) anchored.push({ asset, anchor: target.anchor });
    else leftovers.push(asset);
  });

  const assetIndex = new Map(images.map((a, i) => [a.id, i]));
  anchored.sort((a, b) => {
    const ha = headingOrder.get(a.anchor) ?? 0;
    const hb = headingOrder.get(b.anchor) ?? 0;
    return ha - hb || (assetIndex.get(a.asset.id) ?? 0) - (assetIndex.get(b.asset.id) ?? 0);
  });

  const placements: BlogImagePlacement[] = [];
  const push = (asset: BlogAssetLike, slot: BlogImageSlot, heading?: BlogTocEntry) => {
    placements.push({
      assetId: asset.id,
      slot,
      anchorHeading: heading?.title,
      anchorId: heading?.anchor,
      order: placements.length,
      width: widthForAsset(asset, slot),
      alignment: 'center',
      caption: slot === 'hero' ? '' : asset.description || '',
      altText: altTextFor(asset, context.title, slot),
    });
  };

  if (hero) push(hero, 'hero');
  for (const entry of anchored) push(entry.asset, 'section', headingBySlug.get(entry.anchor));
  for (const asset of leftovers) push(asset, 'closing');

  return placements;
}

/**
 * The layout to publish a post with.
 *
 * Falls back to building one when the post has none stored — images uploaded
 * before any prompts were generated would otherwise have nowhere to go and
 * would be dropped from the published article.
 */
export function resolveImageLayout(post: BlogPostLike): BlogImagePlacement[] {
  if (post.imageLayout && post.imageLayout.length > 0) return post.imageLayout;
  return buildImageLayout(post.suggestedAssets || [], {
    title: post.title,
    headings: buildTocFromMarkdown(post.content || ''),
  });
}

// ============================================
// COMPOSITION
// ============================================

function assetImageUrls(asset: BlogAssetLike | undefined): string[] {
  return (asset?.images || []).map((img) => img?.url).filter((u): u is string => !!u);
}

/**
 * Interleave the article's markdown with its placed images.
 *
 * Hero directly under the H1, each section image directly under its heading,
 * closing images at the end — the same order the Final Preview shows. Every
 * image on a placed asset is emitted, not just the first.
 */
export function composeBlogDocument(post: BlogPostLike): BlogDocumentBlock[] {
  const content = post.content || '';
  const placements = resolveImageLayout(post).slice().sort((a, b) => a.order - b.order);
  if (placements.length === 0) return content ? [{ kind: 'markdown', markdown: content }] : [];

  const assetById = new Map((post.suggestedAssets || []).map((a) => [a.id, a]));
  const toBlock = (placement: BlogImagePlacement): BlogDocumentBlock => {
    const asset = assetById.get(placement.assetId);
    return { kind: 'image', placement, asset, urls: assetImageUrls(asset) };
  };

  const heroes = placements.filter((p) => p.slot === 'hero');
  const closing = placements.filter((p) => p.slot === 'closing');
  const bySection = new Map<string, BlogImagePlacement[]>();
  for (const p of placements) {
    if (p.slot !== 'section' || !p.anchorId) continue;
    const list = bySection.get(p.anchorId) || [];
    list.push(p);
    bySection.set(p.anchorId, list);
  }

  const blocks: BlogDocumentBlock[] = [];
  let buffer: string[] = [];
  const flush = () => {
    const markdown = buffer.join('\n').trim();
    if (markdown) blocks.push({ kind: 'markdown', markdown });
    buffer = [];
  };

  let inFence = false;
  let heroEmitted = false;

  for (const line of content.split('\n')) {
    if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;

    buffer.push(line);
    if (inFence) continue;

    const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
    if (!heading) continue;

    if (heading[1].length === 1 && !heroEmitted && heroes.length > 0) {
      heroEmitted = true;
      flush();
      heroes.forEach((p) => blocks.push(toBlock(p)));
      continue;
    }

    const slug = slugifyHeading(heading[2]);
    const sectionImages = bySection.get(slug);
    if (sectionImages && sectionImages.length > 0) {
      flush();
      sectionImages.forEach((p) => blocks.push(toBlock(p)));
      bySection.delete(slug);
    }
  }

  flush();

  if (!heroEmitted) heroes.forEach((p) => blocks.unshift(toBlock(p)));
  bySection.forEach((list) => list.forEach((p) => blocks.push(toBlock(p))));
  closing.forEach((p) => blocks.push(toBlock(p)));

  return blocks;
}

// ============================================
// PUBLIC BASE URL
// ============================================

/**
 * Hosts a blog platform's servers cannot reach.
 *
 * This is the whole reason images went out broken: falling back to the address
 * the request arrived on yields `http://localhost:3101` on a dev machine, and
 * an <img> pointing there renders as nothing for every reader. A URL that only
 * works from inside the network is worse than no URL — it fails silently.
 */
function isUnreachableHost(hostname: string): boolean {
  const host = hostname.toLowerCase();
  if (host === 'localhost' || host === '0.0.0.0' || host === '::1' || host === '[::1]') return true;
  if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.localhost')) return true;
  // Loopback, link-local and the RFC1918 private ranges.
  if (/^127\./.test(host) || /^169\.254\./.test(host)) return true;
  if (/^10\./.test(host) || /^192\.168\./.test(host)) return true;
  if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
  return false;
}

/**
 * The first candidate that a blog host could actually fetch from.
 *
 * Returns null when there is none — the caller then falls back to embedding the
 * images in the post rather than publishing links to a machine nobody else can
 * see.
 */
export function resolvePublicBaseUrl(candidates: (string | undefined | null)[]): string | null {
  for (const candidate of candidates) {
    const raw = (candidate || '').trim().replace(/\/$/, '');
    if (!raw) continue;
    try {
      const url = new URL(raw);
      if (url.protocol !== 'http:' && url.protocol !== 'https:') continue;
      if (isUnreachableHost(url.hostname)) continue;
      return raw;
    } catch {
      // Not a URL — ignore it rather than emitting something malformed.
    }
  }
  return null;
}

// ============================================
// MATERIALISATION
// ============================================

const BLOG_IMAGE_DIR = path.join(process.cwd(), 'uploads', 'blog-images');

const EXTENSION_BY_MIME: Record<string, string> = {
  'image/png': 'png',
  'image/jpeg': 'jpg',
  'image/jpg': 'jpg',
  'image/webp': 'webp',
  'image/gif': 'gif',
  'image/svg+xml': 'svg',
};

/**
 * Budget for images embedded directly in the post body.
 *
 * Deliberately conservative. Blogger caps a post at roughly 1 MB and base64 adds
 * about a third on top of the raw bytes, so a generous budget risks the publish
 * itself failing — and a post that does not publish is far worse than a post
 * published with fewer images. Hosting via PUBLIC_BASE_URL has no such limit and
 * is the path to prefer; this is the fallback that keeps things working without
 * it.
 */
const INLINE_TOTAL_BUDGET_BYTES = 300 * 1024;
const INLINE_PER_IMAGE_BUDGET_BYTES = 120 * 1024;
const INLINE_MAX_WIDTH = 1000;

const CONFIGURE_HOSTING_HINT = 'Set PUBLIC_BASE_URL to a public HTTPS address so images publish as links instead.';

export interface MaterialiseResult {
  /** The document with every usable image rewritten into something publishable. */
  blocks: BlogDocumentBlock[];
  /** Problems worth telling the user about. */
  warnings: string[];
  /** How many images ended up in the article. */
  publishedImageCount: number;
  /** True when images had to be embedded because no public host was available. */
  usedInlineFallback: boolean;
}

/** Decode whatever is stored for an image into raw bytes. */
function readImageBytes(url: string): { buffer: Buffer; mime: string } | null {
  const dataUri = url.match(/^data:([^;,]+);base64,(.+)$/s);
  if (dataUri) {
    return { buffer: Buffer.from(dataUri[2], 'base64'), mime: dataUri[1].toLowerCase() };
  }
  if (url.startsWith('/uploads/')) {
    const filePath = path.resolve(process.cwd(), url.replace(/^\//, ''));
    if (!fs.existsSync(filePath)) return null;
    const ext = path.extname(filePath).toLowerCase().replace('.', '');
    const normalised = ext === 'jpeg' ? 'jpg' : ext;
    const mime = Object.entries(EXTENSION_BY_MIME).find(([, e]) => e === normalised)?.[0];
    return { buffer: fs.readFileSync(filePath), mime: mime || 'image/png' };
  }
  return null;
}

/**
 * Write an image under uploads/blog-images and return its web path.
 *
 * Content-addressed: the filename is a hash of the bytes, so republishing the
 * same article reuses the file it wrote last time rather than filling the disk
 * with copies, and the published URLs stay stable.
 */
function persistImage(buffer: Buffer, mime: string): string | null {
  const ext = EXTENSION_BY_MIME[mime];
  if (!ext) return null;
  const hash = crypto.createHash('sha1').update(buffer).digest('hex').slice(0, 16);
  const fileName = `${hash}.${ext}`;
  const filePath = path.join(BLOG_IMAGE_DIR, fileName);
  if (!fs.existsSync(BLOG_IMAGE_DIR)) fs.mkdirSync(BLOG_IMAGE_DIR, { recursive: true });
  if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, buffer);
  return `/uploads/blog-images/${fileName}`;
}

/**
 * Write a base64 image out as a file and return the path to store instead.
 *
 * This is what keeps a blog post inside MongoDB's 16 MB document limit. Every
 * post for a company lives in ONE BlogContentOS document, and images used to be
 * kept on it as base64 data URIs — one real company's document reached 15.99 MB
 * with 84% of it five images, so the next image saved failed the whole write
 * with BSONObjectTooLarge and publishing stopped dead. A path costs ~40 bytes.
 *
 * Content-addressed, so re-saving the same picture reuses the file it wrote
 * before rather than filling the disk with copies, and the stored URL is stable.
 *
 * Returns null when the value is not a data URI this can store — the caller
 * keeps whatever it had, so an unrecognised value is passed through untouched
 * rather than lost.
 */
export function persistDataUriImage(dataUri: string): string | null {
  if (typeof dataUri !== 'string' || !dataUri.startsWith('data:')) return null;
  const decoded = readImageBytes(dataUri);
  if (!decoded || decoded.buffer.length === 0) return null;

  const webPath = persistImage(decoded.buffer, decoded.mime);
  if (!webPath) return null;

  // Read the file back before handing out its path. A path saved into MongoDB
  // for a file that is not actually on disk is the worst outcome available: the
  // image is gone, the database says it is fine, and the post publishes with a
  // hole in it. Better to return null and let the caller keep the data URI.
  const filePath = path.resolve(process.cwd(), webPath.replace(/^\//, ''));
  try {
    if (!fs.existsSync(filePath) || fs.statSync(filePath).size !== decoded.buffer.length) {
      console.error(`[BlogImages] Wrote ${webPath} but it did not read back intact — keeping the inline image.`);
      return null;
    }
  } catch (err: any) {
    console.error(`[BlogImages] Could not verify ${webPath}: ${err?.message}`);
    return null;
  }

  return webPath;
}

/**
 * Shrink an image until it fits the inline budget.
 *
 * Only used for the embedded fallback — a hosted image is published untouched.
 * Returns null when it cannot be brought under budget, so an oversized image is
 * reported rather than silently pushing the post past the platform limit.
 */
async function compressForInline(buffer: Buffer, mime: string): Promise<{ buffer: Buffer; mime: string } | null> {
  // SVG is already small and does not survive raster resizing.
  if (mime === 'image/svg+xml') {
    return buffer.byteLength <= INLINE_PER_IMAGE_BUDGET_BYTES ? { buffer, mime } : null;
  }
  if (buffer.byteLength <= INLINE_PER_IMAGE_BUDGET_BYTES) return { buffer, mime };

  let sharp: any;
  try {
    // Required lazily: a missing native binary must not take publishing down.
    sharp = require('sharp');
  } catch {
    return null;
  }

  // JPEG over PNG — these are photographic blog images, and flattening alpha
  // onto white matches the page background anyway.
  const attempts: [number, number][] = [[INLINE_MAX_WIDTH, 78], [800, 70], [640, 62], [480, 55]];
  for (const [width, quality] of attempts) {
    try {
      const out: Buffer = await sharp(buffer)
        .resize({ width, withoutEnlargement: true })
        .flatten({ background: '#ffffff' })
        .jpeg({ quality, mozjpeg: true })
        .toBuffer();
      if (out.byteLength <= INLINE_PER_IMAGE_BUDGET_BYTES) return { buffer: out, mime: 'image/jpeg' };
    } catch {
      return null;
    }
  }
  return null;
}

/**
 * Rewrite every image in a composed document into something the blog host can
 * actually render.
 *
 * Two modes, and the choice is not a preference — it is whether this server is
 * reachable from the public internet:
 *
 *  - `publicBaseUrl` given: images are written to uploads/blog-images and
 *    published as absolute URLs. This is the right answer — the post stays small
 *    and the images are cacheable.
 *
 *  - no public URL: images are embedded in the post as data URIs, shrunk to fit
 *    the post size limit. Publishing `http://localhost:3101/...` links instead —
 *    which is what the first version of this did — puts broken images in a live
 *    post and reports success, so embedding is the safer default and the caller
 *    is told to configure hosting.
 */
export async function materialiseBlogImages(
  blocks: BlogDocumentBlock[],
  publicBaseUrl: string | null,
): Promise<MaterialiseResult> {
  const warnings = new Set<string>();
  let publishedImageCount = 0;
  let usedInlineFallback = false;
  let inlineBudget = INLINE_TOTAL_BUDGET_BYTES;

  const out: BlogDocumentBlock[] = [];
  for (const block of blocks) {
    if (block.kind !== 'image') {
      out.push(block);
      continue;
    }

    const urls: string[] = [];
    for (const original of block.urls) {
      // Already hosted somewhere public — nothing to do.
      if (/^https?:\/\//i.test(original)) {
        urls.push(original);
        continue;
      }

      const source = readImageBytes(original);
      if (!source) {
        warnings.add('An image could not be read and was left out of the published post.');
        continue;
      }

      if (publicBaseUrl) {
        const webPath = original.startsWith('/uploads/')
          ? original
          : persistImage(source.buffer, source.mime);
        if (webPath) {
          urls.push(`${publicBaseUrl}${webPath}`);
          continue;
        }
        warnings.add(`An image could not be saved for publishing (unsupported type ${source.mime}).`);
        continue;
      }

      // No public host — embed it instead.
      const compressed = await compressForInline(source.buffer, source.mime);
      if (!compressed) {
        warnings.add(`An image was too large to embed and was left out. ${CONFIGURE_HOSTING_HINT}`);
        continue;
      }
      const encoded = compressed.buffer.toString('base64');
      if (encoded.length > inlineBudget) {
        warnings.add(`Some images were left out to keep the post within the size limit. ${CONFIGURE_HOSTING_HINT}`);
        continue;
      }
      inlineBudget -= encoded.length;
      usedInlineFallback = true;
      urls.push(`data:${compressed.mime};base64,${encoded}`);
    }

    publishedImageCount += urls.length;
    out.push({ ...block, urls });
  }

  if (usedInlineFallback) {
    warnings.add(`Images were embedded in the post because this server has no public address configured. ${CONFIGURE_HOSTING_HINT}`);
  }

  return { blocks: out, warnings: [...warnings], publishedImageCount, usedInlineFallback };
}

/** Escape the characters that would otherwise be read as markup in an attribute. */
function escapeAttribute(text: string): string {
  return String(text || '')
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

/**
 * One placed slot as publish-ready HTML.
 *
 * Emits a `<figure>` per slot with every image on the asset inside it. Widths
 * are inline styles rather than classes — the blog host applies its own theme,
 * and this app's utility classes mean nothing there.
 */
export function imageBlockToHtml(block: Extract<BlogDocumentBlock, { kind: 'image' }>): string {
  if (block.urls.length === 0) return '';

  const alt = escapeAttribute(block.placement.altText);
  const maxWidth = block.placement.width === 'inline' ? '75%' : '100%';
  const images = block.urls
    .map((url) => `<img src="${escapeAttribute(url)}" alt="${alt}" style="max-width:${maxWidth};height:auto;display:block;margin:0 auto;" />`)
    .join('\n');

  const caption = block.placement.caption
    ? `\n<figcaption style="text-align:center;font-size:0.9em;opacity:0.75;">${escapeAttribute(block.placement.caption)}</figcaption>`
    : '';

  return `<figure style="margin:1.5em 0;">\n${images}${caption}\n</figure>`;
}

