/**
 * Resolve markdown image references in agent answers into Feishu-hosted
 * image_keys so cards can embed them without remote URLs (which trip Feishu
 * content-security controls).
 */
import { isIP } from "node:net";
import type { FeishuRuntime } from "./client.js";
import { withRetry } from "./client.js";
import {
  WorkspaceFileBoundaryError,
  readWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";

export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export const DEFAULT_MAX_OUTBOUND_IMAGES = 10;

const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]+)\)/g;
const FENCED_CODE_RE = /```[\s\S]*?```/g;

export type CardContentSegment =
  | { readonly type: "markdown"; readonly content: string }
  | { readonly type: "image"; readonly imgKey: string; readonly alt: string };

export interface MarkdownImageRef {
  readonly fullMatch: string;
  readonly alt: string;
  readonly src: string;
  readonly index: number;
  readonly length: number;
}

export interface OutboundImageContext {
  readonly rt: FeishuRuntime;
  readonly workspaceRoot?: string | undefined;
  readonly workspaceDir?: string | undefined;
  readonly maxImageBytes?: number | undefined;
  readonly maxImages?: number | undefined;
  readonly fetchImpl?: typeof fetch | undefined;
}

type ImageCreateResponse = {
  image_key?: string;
  data?: { image_key?: string };
} | null;

type MessageCreateResponse = {
  message_id?: string;
  data?: { message_id?: string };
} | null;

/** Streaming-safe view: drop markdown image URLs so partial cards do not hit Feishu URL checks. */
export function maskMarkdownImagesForStreaming(text: string): string {
  return rewriteMarkdownImagesOutsideCode(text, (ref) => {
    const alt = ref.alt.trim();
    return alt === "" ? "\u3010\u56fe\u7247\u3011" : alt;
  });
}

export function findMarkdownImagesOutsideCode(text: string): MarkdownImageRef[] {
  const blocked = blockedRanges(text);
  const refs: MarkdownImageRef[] = [];
  IMAGE_MARKDOWN_RE.lastIndex = 0;
  let match: RegExpExecArray | null;
  while ((match = IMAGE_MARKDOWN_RE.exec(text)) !== null) {
    const index = match.index;
    if (blocked.some((range) => index >= range.start && index < range.end)) continue;
    const fullMatch = match[0];
    const alt = match[1] ?? "";
    const rawSrc = (match[2] ?? "").trim();
    const src = stripUrlTitle(rawSrc);
    if (src === "") continue;
    refs.push({ fullMatch, alt, src, index, length: fullMatch.length });
  }
  return refs;
}

/**
 * Upload reachable markdown images and split the answer into card segments
 * (markdown + Feishu img elements). Unresolved images become visible alt text.
 */
export async function materializeAnswerSegments(
  text: string,
  ctx: OutboundImageContext,
): Promise<{ segments: CardContentSegment[]; unresolved: string[] }> {
  const refs = findMarkdownImagesOutsideCode(text);
  if (refs.length === 0) {
    return {
      segments: text === "" ? [] : [{ type: "markdown", content: text }],
      unresolved: [],
    };
  }

  const maxImages = ctx.maxImages ?? DEFAULT_MAX_OUTBOUND_IMAGES;
  const maxBytes = ctx.maxImageBytes ?? FEISHU_MAX_IMAGE_BYTES;
  const keyBySrc = new Map<string, string>();
  const unresolved: string[] = [];
  const uniqueSrcs: string[] = [];
  for (const ref of refs) {
    if (!uniqueSrcs.includes(ref.src)) uniqueSrcs.push(ref.src);
  }

  for (const src of uniqueSrcs.slice(0, maxImages)) {
    try {
      const bytes = await resolveOutboundImageBytes(src, ctx, maxBytes);
      if (bytes === null) {
        unresolved.push(src);
        continue;
      }
      const imageKey = await uploadMessageImage(ctx.rt, bytes);
      keyBySrc.set(src, imageKey);
    } catch (error) {
      ctx.rt.logger.warn(
        { src, err: error instanceof Error ? error.message : String(error) },
        "outbound image materialize failed",
      );
      unresolved.push(src);
    }
  }
  for (const src of uniqueSrcs.slice(maxImages)) {
    unresolved.push(src);
  }

  const segments: CardContentSegment[] = [];
  let cursor = 0;
  for (const ref of refs) {
    if (ref.index > cursor) {
      pushMarkdown(segments, text.slice(cursor, ref.index));
    }
    const imageKey = keyBySrc.get(ref.src);
    if (imageKey !== undefined) {
      segments.push({
        type: "image",
        imgKey: imageKey,
        alt: ref.alt.trim() === "" ? "\u56fe\u7247" : ref.alt.trim(),
      });
    } else {
      const alt = ref.alt.trim();
      pushMarkdown(segments, alt === "" ? "\u3010\u56fe\u7247\u3011" : alt);
    }
    cursor = ref.index + ref.length;
  }
  if (cursor < text.length) {
    pushMarkdown(segments, text.slice(cursor));
  }
  return { segments, unresolved };
}

export async function uploadMessageImage(rt: FeishuRuntime, image: Buffer): Promise<string> {
  if (image.byteLength === 0) {
    throw new Error("image is empty");
  }
  if (image.byteLength > FEISHU_MAX_IMAGE_BYTES) {
    throw new Error(`image exceeds Feishu limit of ${FEISHU_MAX_IMAGE_BYTES} bytes`);
  }
  const client = rt.client as unknown as {
    im: { v1: { image: { create: (p: unknown) => Promise<ImageCreateResponse> } } };
  };
  const res = await withRetry(async () =>
    client.im.v1.image.create({
      data: { image_type: "message", image },
    }),
  );
  const imageKey = res?.image_key ?? res?.data?.image_key;
  if (imageKey === undefined || imageKey === "") {
    throw new Error("Feishu image upload response is missing image_key");
  }
  return imageKey;
}

/** Send a standalone image message (fallback if card embed is unavailable). */
export async function sendImageMessage(
  rt: FeishuRuntime,
  chatId: string,
  imageKey: string,
  options?: { readonly replyToMessageId?: string | undefined },
): Promise<string | null> {
  const client = rt.client as unknown as {
    im: {
      v1: {
        message: {
          create: (p: unknown) => Promise<MessageCreateResponse>;
          reply: (p: unknown) => Promise<MessageCreateResponse>;
        };
      };
    };
  };
  const replyTo = options?.replyToMessageId;
  if (replyTo !== undefined && replyTo !== "") {
    const res = await client.im.v1.message.reply({
      path: { message_id: replyTo },
      data: { msg_type: "image", content: JSON.stringify({ image_key: imageKey }) },
    });
    return res?.data?.message_id ?? res?.message_id ?? null;
  }
  const res = await client.im.v1.message.create({
    params: { receive_id_type: "chat_id" },
    data: {
      receive_id: chatId,
      msg_type: "image",
      content: JSON.stringify({ image_key: imageKey }),
    },
  });
  return res?.data?.message_id ?? res?.message_id ?? null;
}

export async function resolveOutboundImageBytes(
  src: string,
  ctx: OutboundImageContext,
  maxBytes: number,
): Promise<Buffer | null> {
  if (isRemoteUrl(src)) {
    return fetchRemoteImage(src, ctx.fetchImpl ?? fetch, maxBytes);
  }
  const root = ctx.workspaceRoot?.trim();
  const dir = ctx.workspaceDir?.trim();
  if (root === undefined || root === "" || dir === undefined || dir === "") {
    return null;
  }
  try {
    const file = await readWorkspaceFileNoFollow(root, dir, src, maxBytes);
    return file.data;
  } catch (error) {
    if (error instanceof WorkspaceFileBoundaryError && error.reason === "not_found") {
      return null;
    }
    throw error;
  }
}

function rewriteMarkdownImagesOutsideCode(
  text: string,
  replace: (ref: MarkdownImageRef) => string,
): string {
  const refs = findMarkdownImagesOutsideCode(text);
  if (refs.length === 0) return text;
  let out = "";
  let cursor = 0;
  for (const ref of refs) {
    out += text.slice(cursor, ref.index);
    out += replace(ref);
    cursor = ref.index + ref.length;
  }
  out += text.slice(cursor);
  return out;
}

function pushMarkdown(segments: CardContentSegment[], content: string): void {
  if (content === "") return;
  const last = segments[segments.length - 1];
  if (last !== undefined && last.type === "markdown") {
    segments[segments.length - 1] = { type: "markdown", content: last.content + content };
    return;
  }
  segments.push({ type: "markdown", content });
}

function blockedRanges(text: string): Array<{ start: number; end: number }> {
  const ranges: Array<{ start: number; end: number }> = [];
  FENCED_CODE_RE.lastIndex = 0;
  let match: RegExpExecArray | null;
  while ((match = FENCED_CODE_RE.exec(text)) !== null) {
    ranges.push({ start: match.index, end: match.index + match[0].length });
  }
  return ranges;
}

function stripUrlTitle(raw: string): string {
  const trimmed = raw.trim();
  // Markdown optional title: url "title" or url 'title'
  const titled = /^(\S+)\s+(".*"|'.*')$/.exec(trimmed);
  return (titled?.[1] ?? trimmed).trim();
}

function isRemoteUrl(src: string): boolean {
  try {
    const url = new URL(src);
    return url.protocol === "http:" || url.protocol === "https:";
  } catch {
    return false;
  }
}

async function fetchRemoteImage(
  src: string,
  fetchImpl: typeof fetch,
  maxBytes: number,
): Promise<Buffer | null> {
  let url: URL;
  try {
    url = new URL(src);
  } catch {
    return null;
  }
  if (url.protocol !== "http:" && url.protocol !== "https:") return null;
  if (!isPublicHttpHost(url.hostname)) return null;

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 15_000);
  try {
    const response = await fetchImpl(url, {
      method: "GET",
      redirect: "manual",
      signal: controller.signal,
      headers: { accept: "image/*,*/*;q=0.8" },
    });
    // One safe redirect hop to another public http(s) host.
    if (response.status >= 300 && response.status < 400) {
      const location = response.headers.get("location");
      if (location === null || location === "") return null;
      let redirected: URL;
      try {
        redirected = new URL(location, url);
      } catch {
        return null;
      }
      if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null;
      if (!isPublicHttpHost(redirected.hostname)) return null;
      const second = await fetchImpl(redirected, {
        method: "GET",
        redirect: "manual",
        signal: controller.signal,
        headers: { accept: "image/*,*/*;q=0.8" },
      });
      return readImageBody(second, maxBytes);
    }
    return readImageBody(response, maxBytes);
  } finally {
    clearTimeout(timer);
  }
}

async function readImageBody(response: Response, maxBytes: number): Promise<Buffer | null> {
  if (!response.ok) return null;
  const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
  if (
    contentType !== "" &&
    !contentType.startsWith("image/") &&
    !contentType.includes("octet-stream") &&
    (contentType.startsWith("text/") || contentType.includes("json") || contentType.includes("html"))
  ) {
    return null;
  }
  const contentLength = Number(response.headers.get("content-length") ?? "NaN");
  if (Number.isFinite(contentLength) && contentLength > maxBytes) return null;
  const buf = Buffer.from(await response.arrayBuffer());
  if (buf.byteLength === 0 || buf.byteLength > maxBytes) return null;
  return buf;
}

function isPublicHttpHost(hostname: string): boolean {
  const host = hostname.trim().toLowerCase().replace(/\.$/, "");
  if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
    return false;
  }
  if (host === "0.0.0.0" || host === "::" || host === "[::]" || host === "::1" || host === "[::1]") {
    return false;
  }
  const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
  const ipVersion = isIP(unbracketed);
  if (ipVersion === 4) return !isPrivateIPv4(unbracketed);
  if (ipVersion === 6) return !isPrivateIPv6(unbracketed);
  return true;
}

function isPrivateIPv4(ip: string): boolean {
  const parts = ip.split(".").map((part) => Number(part));
  if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
    return true;
  }
  const a = parts[0]!;
  const b = parts[1]!;
  if (a === 10 || a === 127 || a === 0) return true;
  if (a === 169 && b === 254) return true;
  if (a === 172 && b >= 16 && b <= 31) return true;
  if (a === 192 && b === 168) return true;
  if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
  if (a >= 224) return true; // multicast / reserved
  return false;
}

function isPrivateIPv6(ip: string): boolean {
  const normalized = ip.toLowerCase();
  if (normalized === "::1") return true;
  if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // unique local
  if (normalized.startsWith("fe80:")) return true; // link-local
  const mapped = /^:ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(normalized);
  if (mapped?.[1] !== undefined) return isPrivateIPv4(mapped[1]);
  return false;
}
