"use server";

import * as Y from "yjs";
import * as base64 from "base64-js";
import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
import { getIdentityData } from "actions/getIdentityData";
import {
  AtpBaseClient,
  PubLeafletBlocksHeader,
  PubLeafletBlocksImage,
  PubLeafletBlocksText,
  PubLeafletBlocksUnorderedList,
  PubLeafletBlocksOrderedList,
  PubLeafletDocument,
  SiteStandardDocument,
  PubLeafletContent,
  PubLeafletPagesLinearDocument,
  PubLeafletPagesCanvas,
  PubLeafletRichtextFacet,
  PubLeafletBlocksWebsite,
  PubLeafletBlocksCode,
  PubLeafletBlocksMath,
  PubLeafletBlocksHorizontalRule,
  PubLeafletBlocksBskyPost,
  PubLeafletBlocksBlockquote,
  PubLeafletBlocksIframe,
  PubLeafletBlocksPage,
  PubLeafletBlocksPoll,
  PubLeafletBlocksButton,
  PubLeafletPollDefinition,
} from "lexicons/api";
import { Block } from "components/Blocks/Block";
import { TID } from "@atproto/common";
import { supabaseServerClient } from "supabase/serverClient";
import { scanIndexLocal } from "src/replicache/utils";
import type { Fact } from "src/replicache";
import type { Attribute } from "src/replicache/attributes";
import { Delta, YJSFragmentToString } from "src/utils/yjsFragmentToString";
import { ids } from "lexicons/api/lexicons";
import { BlobRef } from "@atproto/lexicon";
import { AtUri } from "@atproto/syntax";
import { Json } from "supabase/database.types";
import { $Typed, UnicodeString } from "@atproto/api";
import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
import { getBlocksWithTypeLocal } from "src/replicache/getBlocks";
import { Lock } from "src/utils/lock";
import type { PubLeafletPublication } from "lexicons/api";
import {
  normalizeDocumentRecord,
  type NormalizedDocument,
} from "src/utils/normalizeRecords";
import {
  ColorToRGB,
  ColorToRGBA,
} from "components/ThemeManager/colorToLexicons";
import { parseColor } from "@react-stately/color";
import {
  Notification,
  pingIdentityToUpdateNotification,
} from "src/notifications";
import { v7 } from "uuid";
import {
  isDocumentCollection,
  isPublicationCollection,
  getDocumentType,
} from "src/utils/collectionHelpers";

type PublishResult =
  | { success: true; rkey: string; record: SiteStandardDocument.Record }
  | { success: false; error: OAuthSessionError };

export async function publishToPublication({
  root_entity,
  publication_uri,
  leaflet_id,
  title,
  description,
  tags,
  cover_image,
  entitiesToDelete,
  publishedAt,
  postPreferences,
}: {
  root_entity: string;
  publication_uri?: string;
  leaflet_id: string;
  title?: string;
  description?: string;
  tags?: string[];
  cover_image?: string | null;
  entitiesToDelete?: string[];
  publishedAt?: string;
  postPreferences?: {
    showComments?: boolean;
    showMentions?: boolean;
    showRecommends?: boolean;
  } | null;
}): Promise<PublishResult> {
  let identity = await getIdentityData();
  if (!identity || !identity.atp_did) {
    return {
      success: false,
      error: {
        type: "oauth_session_expired",
        message: "Not authenticated",
        did: "",
      },
    };
  }

  const sessionResult = await restoreOAuthSession(identity.atp_did);
  if (!sessionResult.ok) {
    return { success: false, error: sessionResult.error };
  }
  let credentialSession = sessionResult.value;
  let agent = new AtpBaseClient(
    credentialSession.fetchHandler.bind(credentialSession),
  );

  // Check if we're publishing to a publication or standalone
  let draft: any = null;
  let existingDocUri: string | null = null;

  if (publication_uri) {
    // Publishing to a publication - use leaflets_in_publications
    let { data, error } = await supabaseServerClient
      .from("publications")
      .select("*, leaflets_in_publications(*, documents(*))")
      .eq("uri", publication_uri)
      .eq("leaflets_in_publications.leaflet", leaflet_id)
      .single();
    console.log(error);

    if (!data || identity.atp_did !== data?.identity_did)
      throw new Error("No draft or not publisher");
    draft = data.leaflets_in_publications[0];
    existingDocUri = draft?.doc;
  } else {
    // Publishing standalone - use leaflets_to_documents
    let { data } = await supabaseServerClient
      .from("leaflets_to_documents")
      .select("*, documents(*)")
      .eq("leaflet", leaflet_id)
      .single();
    draft = data;
    existingDocUri = draft?.document;

    // If updating an existing document, verify the current user is the owner
    if (existingDocUri) {
      let docOwner = new AtUri(existingDocUri).host;
      if (docOwner !== identity.atp_did) {
        return {
          success: false,
          error: {
            type: "oauth_session_expired" as const,
            message: "Not the document owner",
            did: identity.atp_did,
          },
        };
      }
    }
  }

  // Heuristic: Remove title entities if this is the first time publishing
  // (when coming from a standalone leaflet with entitiesToDelete passed in)
  if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) {
    await supabaseServerClient
      .from("entities")
      .delete()
      .in("id", entitiesToDelete);
  }

  let { data } = await supabaseServerClient.rpc("get_facts", {
    root: root_entity,
  });
  let facts = (data as unknown as Fact<Attribute>[]) || [];

  let { pages } = await processBlocksToPages(
    facts,
    agent,
    root_entity,
    credentialSession.did!,
  );

  let existingRecord: Partial<SiteStandardDocument.Record> = {};
  const normalizedDoc = normalizeDocumentRecord(draft?.documents?.data);
  if (normalizedDoc) {
    // When reading existing data, use normalized format to extract fields
    // The theme is preserved in NormalizedDocument for backward compatibility
    existingRecord = {
      publishedAt: normalizedDoc.publishedAt,
      title: normalizedDoc.title,
      description: normalizedDoc.description,
      tags: normalizedDoc.tags,
      coverImage: normalizedDoc.coverImage,
      theme: normalizedDoc.theme,
      bskyPostRef: normalizedDoc.bskyPostRef,
    };
  }

  // Resolve preferences: explicit param > draft DB value
  const preferences = postPreferences ?? draft?.preferences;

  // Extract theme for standalone documents (not for publications)
  let theme: PubLeafletPublication.Theme | undefined;
  if (!publication_uri) {
    theme = await extractThemeFromFacts(facts, root_entity, agent);
  }

  // Upload cover image if provided
  let coverImageBlob: BlobRef | undefined;
  if (cover_image) {
    let scan = scanIndexLocal(facts);
    let [imageData] = scan.eav(cover_image, "block/image");
    if (imageData) {
      let imageResponse = await fetch(imageData.data.src);
      if (imageResponse.status === 200) {
        let binary = await imageResponse.blob();
        let blob = await agent.com.atproto.repo.uploadBlob(binary, {
          headers: { "Content-Type": binary.type },
        });
        coverImageBlob = blob.data.blob;
      }
    }
  }

  // Determine the collection to use - preserve existing schema if updating
  const existingCollection = existingDocUri
    ? new AtUri(existingDocUri).collection
    : undefined;
  const documentType = getDocumentType(existingCollection);

  // Build the pages array (used by both formats)
  const pagesArray = pages.map((p) => {
    if (p.type === "canvas") {
      return {
        $type: "pub.leaflet.pages.canvas" as const,
        id: p.id,
        blocks: p.blocks as PubLeafletPagesCanvas.Block[],
      };
    } else {
      return {
        $type: "pub.leaflet.pages.linearDocument" as const,
        id: p.id,
        blocks: p.blocks as PubLeafletPagesLinearDocument.Block[],
      };
    }
  });

  // Determine the rkey early since we need it for the path field
  const rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr();

  // Resolve fields: use new values if provided, otherwise preserve existing
  const resolvedDescription =
    description !== undefined ? description : existingRecord.description;
  const resolvedTags = tags !== undefined ? tags : existingRecord.tags;
  const resolvedCoverImage = coverImageBlob ?? existingRecord.coverImage;
  const resolvedPublishedAt =
    publishedAt || existingRecord.publishedAt || new Date().toISOString();

  // Create record based on the document type
  let record: PubLeafletDocument.Record | SiteStandardDocument.Record;

  if (documentType === "site.standard.document") {
    // site.standard.document format
    // For standalone docs, use HTTPS URL; for publication docs, use the publication AT-URI
    const siteUri =
      publication_uri || `https://leaflet.pub/p/${credentialSession.did}`;

    record = {
      $type: "site.standard.document",
      title: title || "",
      site: siteUri,
      path: "/" + rkey,
      publishedAt: resolvedPublishedAt,
      ...(resolvedDescription !== undefined && {
        description: resolvedDescription,
      }),
      ...(resolvedTags !== undefined && { tags: resolvedTags }),
      ...(resolvedCoverImage && { coverImage: resolvedCoverImage }),
      ...(existingRecord.bskyPostRef && {
        bskyPostRef: existingRecord.bskyPostRef,
      }),
      // Include theme for standalone documents (not for publication documents)
      ...(!publication_uri && theme && { theme }),
      ...(preferences && {
        preferences: {
          $type: "pub.leaflet.publication#preferences" as const,
          ...preferences,
        },
      }),
      content: {
        $type: "pub.leaflet.content" as const,
        pages: pagesArray,
      },
    } satisfies SiteStandardDocument.Record;
  } else {
    // pub.leaflet.document format (legacy)
    record = {
      $type: "pub.leaflet.document",
      author: credentialSession.did!,
      ...(publication_uri && { publication: publication_uri }),
      ...(theme && { theme }),
      ...(preferences && {
        preferences: {
          $type: "pub.leaflet.publication#preferences" as const,
          ...preferences,
        },
      }),
      title: title || "",
      description: resolvedDescription || "",
      ...(resolvedTags !== undefined && { tags: resolvedTags }),
      ...(resolvedCoverImage && { coverImage: resolvedCoverImage }),
      ...(existingRecord.bskyPostRef && {
        postRef: existingRecord.bskyPostRef,
      }),
      pages: pagesArray,
      publishedAt: resolvedPublishedAt,
    } satisfies PubLeafletDocument.Record;
  }

  let { data: result } = await agent.com.atproto.repo.putRecord({
    rkey,
    repo: credentialSession.did!,
    collection: record.$type,
    record,
    validate: false, //TODO publish the lexicon so we can validate!
  });

  // Optimistically create database entries
  await supabaseServerClient.from("documents").upsert({
    uri: result.uri,
    data: record as unknown as Json,
    indexed: true,
  });

  if (publication_uri) {
    // Publishing to a publication - update both tables
    await Promise.all([
      supabaseServerClient.from("documents_in_publications").upsert({
        publication: publication_uri,
        document: result.uri,
      }),
      supabaseServerClient.from("leaflets_in_publications").upsert({
        doc: result.uri,
        leaflet: leaflet_id,
        publication: publication_uri,
        title: title,
        description: description,
        tags: resolvedTags ?? [],
        cover_image: cover_image ?? null,
      }),
    ]);
  } else {
    // Publishing standalone - update leaflets_to_documents
    await supabaseServerClient.from("leaflets_to_documents").upsert({
      leaflet: leaflet_id,
      document: result.uri,
      title: title || "",
      description: description || "",
      tags: resolvedTags ?? [],
      cover_image: cover_image ?? null,
    });

    // Heuristic: Remove title entities if this is the first time publishing standalone
    // (when entitiesToDelete is provided and there's no existing document)
    if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) {
      await supabaseServerClient
        .from("entities")
        .delete()
        .in("id", entitiesToDelete);
    }
  }

  // Create notifications for mentions (only on first publish)
  if (!existingDocUri) {
    await createMentionNotifications(
      result.uri,
      record,
      credentialSession.did!,
    );
  }

  return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) };
}

async function processBlocksToPages(
  facts: Fact<any>[],
  agent: AtpBaseClient,
  root_entity: string,
  did: string,
) {
  let scan = scanIndexLocal(facts);
  let pages: {
    id: string;
    blocks:
      | PubLeafletPagesLinearDocument.Block[]
      | PubLeafletPagesCanvas.Block[];
    type: "doc" | "canvas";
  }[] = [];

  // Create a lock to serialize image uploads
  const uploadLock = new Lock();

  let firstEntity = scan.eav(root_entity, "root/page")?.[0];
  if (!firstEntity) throw new Error("No root page");

  // Check if the first page is a canvas or linear document
  let [pageType] = scan.eav(firstEntity.data.value, "page/type");

  if (pageType?.data.value === "canvas") {
    // First page is a canvas
    let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did);
    pages.unshift({
      id: firstEntity.data.value,
      blocks: canvasBlocks,
      type: "canvas",
    });
  } else {
    // First page is a linear document
    let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value);
    let b = await blocksToRecord(blocks, did);
    pages.unshift({
      id: firstEntity.data.value,
      blocks: b,
      type: "doc",
    });
  }

  return { pages };

  async function uploadImage(src: string) {
    let data = await fetch(src);
    if (data.status !== 200) return;
    let binary = await data.blob();
    return uploadLock.withLock(async () => {
      let blob = await agent.com.atproto.repo.uploadBlob(binary, {
        headers: { "Content-Type": binary.type },
      });
      return blob.data.blob;
    });
  }
  async function blocksToRecord(
    blocks: Block[],
    did: string,
  ): Promise<PubLeafletPagesLinearDocument.Block[]> {
    let parsedBlocks = parseBlocksToList(blocks);
    return (
      await Promise.all(
        parsedBlocks.map(async (blockOrList) => {
          if (blockOrList.type === "block") {
            let alignmentValue = scan.eav(
              blockOrList.block.value,
              "block/text-alignment",
            )[0]?.data.value;
            let alignment: ExcludeString<
              PubLeafletPagesLinearDocument.Block["alignment"]
            > =
              alignmentValue === "center"
                ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter"
                : alignmentValue === "right"
                  ? "lex:pub.leaflet.pages.linearDocument#textAlignRight"
                  : alignmentValue === "justify"
                    ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify"
                    : alignmentValue === "left"
                      ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft"
                      : undefined;
            let b = await blockToRecord(blockOrList.block, did);
            if (!b) return [];
            let block: PubLeafletPagesLinearDocument.Block = {
              $type: "pub.leaflet.pages.linearDocument#block",
              block: b,
            };
            if (alignment) block.alignment = alignment;
            return [block];
          } else {
            let runs = splitListByStyle(blockOrList.children);
            let blocks = await Promise.all(
              runs.map(async (run) => {
                if (run.style === "ordered") {
                  let block: PubLeafletPagesLinearDocument.Block = {
                    $type: "pub.leaflet.pages.linearDocument#block",
                    block: {
                      $type: "pub.leaflet.blocks.orderedList",
                      startIndex:
                        run.children[0].block.listData?.listStart || 1,
                      children: await orderedChildrenToRecord(
                        run.children,
                        did,
                      ),
                    },
                  };
                  return block;
                } else {
                  let block: PubLeafletPagesLinearDocument.Block = {
                    $type: "pub.leaflet.pages.linearDocument#block",
                    block: {
                      $type: "pub.leaflet.blocks.unorderedList",
                      children: await unorderedChildrenToRecord(
                        run.children,
                        did,
                      ),
                    },
                  };
                  return block;
                }
              }),
            );
            return blocks;
          }
        }),
      )
    ).flat();
  }

  function splitListByStyle(children: List[]) {
    let runs: { style: "ordered" | "unordered"; children: List[] }[] = [];
    for (let child of children) {
      let style: "ordered" | "unordered" =
        child.block.listData?.listStyle === "ordered"
          ? "ordered"
          : "unordered";
      let last = runs[runs.length - 1];
      if (last && last.style === style) {
        last.children.push(child);
      } else {
        runs.push({ style, children: [child] });
      }
    }
    return runs;
  }

  async function unorderedChildrenToRecord(
    children: List[],
    did: string,
  ): Promise<PubLeafletBlocksUnorderedList.ListItem[]> {
    return (
      await Promise.all(
        children.map(async (child) => {
          let content = await blockToRecord(child.block, did);
          if (!content) return [];
          let record: PubLeafletBlocksUnorderedList.ListItem = {
            $type: "pub.leaflet.blocks.unorderedList#listItem",
            content,
            ...(child.block.listData?.checklist && {
              checked: child.block.listData.checked ?? false,
            }),
          };
          let sameStyle = child.children.filter(
            (c) => c.block.listData?.listStyle !== "ordered",
          );
          let diffStyle = child.children.filter(
            (c) => c.block.listData?.listStyle === "ordered",
          );
          if (sameStyle.length > 0) {
            record.children = await unorderedChildrenToRecord(sameStyle, did);
          }
          if (diffStyle.length > 0) {
            record.orderedListChildren = {
              $type: "pub.leaflet.blocks.orderedList",
              children: await orderedChildrenToRecord(diffStyle, did),
            };
          }
          return record;
        }),
      )
    ).flat();
  }

  async function orderedChildrenToRecord(
    children: List[],
    did: string,
  ): Promise<PubLeafletBlocksOrderedList.ListItem[]> {
    return (
      await Promise.all(
        children.map(async (child) => {
          let content = await blockToRecord(child.block, did);
          if (!content) return [];
          let record: PubLeafletBlocksOrderedList.ListItem = {
            $type: "pub.leaflet.blocks.orderedList#listItem",
            content,
            ...(child.block.listData?.checklist && {
              checked: child.block.listData.checked ?? false,
            }),
          };
          let sameStyle = child.children.filter(
            (c) => c.block.listData?.listStyle === "ordered",
          );
          let diffStyle = child.children.filter(
            (c) => c.block.listData?.listStyle !== "ordered",
          );
          if (sameStyle.length > 0) {
            record.children = await orderedChildrenToRecord(sameStyle, did);
          }
          if (diffStyle.length > 0) {
            record.unorderedListChildren = {
              $type: "pub.leaflet.blocks.unorderedList",
              children: await unorderedChildrenToRecord(diffStyle, did),
            };
          }
          return record;
        }),
      )
    ).flat();
  }
  async function blockToRecord(b: Block, did: string) {
    const footnoteContentResolver = (footnoteEntityID: string) => {
      let [content] = scan.eav(footnoteEntityID, "block/text");
      if (!content) return { plaintext: "", facets: [] as PubLeafletRichtextFacet.Main[] };
      let doc = new Y.Doc();
      const update = base64.toByteArray(content.data.value);
      Y.applyUpdate(doc, update);
      let nodes = doc.getXmlElement("prosemirror").toArray();
      let plaintext = YJSFragmentToString(nodes[0]);
      let { facets } = YJSFragmentToFacets(nodes[0]);
      return { plaintext, facets };
    };
    const getBlockContent = (b: string) => {
      let [content] = scan.eav(b, "block/text");
      if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const;
      let doc = new Y.Doc();
      const update = base64.toByteArray(content.data.value);
      Y.applyUpdate(doc, update);
      let nodes = doc.getXmlElement("prosemirror").toArray();
      let stringValue = YJSFragmentToString(nodes[0]);
      let { facets } = YJSFragmentToFacets(nodes[0], 0, footnoteContentResolver);
      return [stringValue, facets] as const;
    };
    if (b.type === "card") {
      let [page] = scan.eav(b.value, "block/card");
      if (!page) return;
      let [pageType] = scan.eav(page.data.value, "page/type");

      if (pageType?.data.value === "canvas") {
        let canvasBlocks = await canvasBlocksToRecord(page.data.value, did);
        pages.push({
          id: page.data.value,
          blocks: canvasBlocks,
          type: "canvas",
        });
      } else {
        let blocks = getBlocksWithTypeLocal(facts, page.data.value);
        pages.push({
          id: page.data.value,
          blocks: await blocksToRecord(blocks, did),
          type: "doc",
        });
      }

      let block: $Typed<PubLeafletBlocksPage.Main> = {
        $type: "pub.leaflet.blocks.page",
        id: page.data.value,
      };
      return block;
    }

    if (b.type === "bluesky-post") {
      let [post] = scan.eav(b.value, "block/bluesky-post");
      if (!post || !post.data.value.post) return;
      let [hostFact] = scan.eav(b.value, "bluesky-post/host");
      let block: $Typed<PubLeafletBlocksBskyPost.Main> = {
        $type: ids.PubLeafletBlocksBskyPost,
        postRef: {
          uri: post.data.value.post.uri,
          cid: post.data.value.post.cid,
        },
        clientHost: hostFact?.data.value,
      };
      return block;
    }
    if (b.type === "horizontal-rule") {
      let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = {
        $type: ids.PubLeafletBlocksHorizontalRule,
      };
      return block;
    }

    if (b.type === "heading") {
      let [headingLevel] = scan.eav(b.value, "block/heading-level");

      let [stringValue, facets] = getBlockContent(b.value);
      let block: $Typed<PubLeafletBlocksHeader.Main> = {
        $type: "pub.leaflet.blocks.header",
        level: Math.floor(headingLevel?.data.value || 1),
        plaintext: stringValue,
        ...(facets.length > 0 && { facets }),
      };
      return block;
    }

    if (b.type === "blockquote") {
      let [stringValue, facets] = getBlockContent(b.value);
      let block: $Typed<PubLeafletBlocksBlockquote.Main> = {
        $type: ids.PubLeafletBlocksBlockquote,
        plaintext: stringValue,
        ...(facets.length > 0 && { facets }),
      };
      return block;
    }

    if (b.type == "text") {
      let [stringValue, facets] = getBlockContent(b.value);
      let [textSize] = scan.eav(b.value, "block/text-size");
      let block: $Typed<PubLeafletBlocksText.Main> = {
        $type: ids.PubLeafletBlocksText,
        plaintext: stringValue,
        ...(facets.length > 0 && { facets }),
        ...(textSize && { textSize: textSize.data.value }),
      };
      return block;
    }
    if (b.type === "embed") {
      let [url] = scan.eav(b.value, "embed/url");
      let [height] = scan.eav(b.value, "embed/height");
      let [aspectRatio] = scan.eav(b.value, "embed/aspect-ratio");
      if (!url) return;
      let block: $Typed<PubLeafletBlocksIframe.Main> = {
        $type: "pub.leaflet.blocks.iframe",
        url: url.data.value,
        height: Math.floor(height?.data.value || 600),
      };
      if (aspectRatio) {
        let [w, h] = aspectRatio.data.value.split("/").map(Number);
        if (w && h) {
          block.aspectRatio = { width: w, height: h };
        }
      }
      return block;
    }
    if (b.type == "image") {
      let [image] = scan.eav(b.value, "block/image");
      if (!image) return;
      let [altText] = scan.eav(b.value, "image/alt");
      let [fullBleed] = scan.eav(b.value, "image/full-bleed");
      let blobref = await uploadImage(image.data.src);
      if (!blobref) return;
      let block: $Typed<PubLeafletBlocksImage.Main> = {
        $type: "pub.leaflet.blocks.image",
        image: blobref,
        aspectRatio: {
          height: Math.floor(image.data.height),
          width: Math.floor(image.data.width),
        },
        alt: altText ? altText.data.value : undefined,
        fullBleed: fullBleed?.data.value || undefined,
      };
      return block;
    }
    if (b.type === "link") {
      let [previewImage] = scan.eav(b.value, "link/preview");
      let [description] = scan.eav(b.value, "link/description");
      let [src] = scan.eav(b.value, "link/url");
      if (!src) return;
      let blobref = previewImage
        ? await uploadImage(previewImage?.data.src)
        : undefined;
      let [title] = scan.eav(b.value, "link/title");
      let block: $Typed<PubLeafletBlocksWebsite.Main> = {
        $type: "pub.leaflet.blocks.website",
        previewImage: blobref,
        src: src.data.value,
        description: description?.data.value,
        title: title?.data.value,
      };
      return block;
    }
    if (b.type === "code") {
      let [language] = scan.eav(b.value, "block/code-language");
      let [code] = scan.eav(b.value, "block/code");
      let [theme] = scan.eav(root_entity, "theme/code-theme");
      let block: $Typed<PubLeafletBlocksCode.Main> = {
        $type: "pub.leaflet.blocks.code",
        language: language?.data.value,
        plaintext: code?.data.value || "",
        syntaxHighlightingTheme: theme?.data.value,
      };
      return block;
    }
    if (b.type === "math") {
      let [math] = scan.eav(b.value, "block/math");
      let block: $Typed<PubLeafletBlocksMath.Main> = {
        $type: "pub.leaflet.blocks.math",
        tex: math?.data.value || "",
      };
      return block;
    }
    if (b.type === "poll") {
      // Get poll options from the entity
      let pollOptions = scan.eav(b.value, "poll/options");
      let options: PubLeafletPollDefinition.Option[] = pollOptions.map(
        (opt) => {
          let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0];
          return {
            $type: "pub.leaflet.poll.definition#option",
            text: optionName?.data.value || "",
          };
        },
      );

      // Create the poll definition record
      let pollRecord: PubLeafletPollDefinition.Record = {
        $type: "pub.leaflet.poll.definition",
        name: "Poll", // Default name, can be customized
        options,
      };

      // Upload the poll record
      let { data: pollResult } = await agent.com.atproto.repo.putRecord({
        //use the entity id as the rkey so we can associate it in the editor
        rkey: b.value,
        repo: did,
        collection: pollRecord.$type,
        record: pollRecord,
        validate: false,
      });

      // Optimistically write poll definition to database
      console.log(
        await supabaseServerClient.from("atp_poll_records").upsert({
          uri: pollResult.uri,
          cid: pollResult.cid,
          record: pollRecord as Json,
        }),
      );

      // Return a poll block with reference to the poll record
      let block: $Typed<PubLeafletBlocksPoll.Main> = {
        $type: "pub.leaflet.blocks.poll",
        pollRef: {
          uri: pollResult.uri,
          cid: pollResult.cid,
        },
      };
      return block;
    }
    if (b.type === "button") {
      let [text] = scan.eav(b.value, "button/text");
      let [url] = scan.eav(b.value, "button/url");
      if (!text || !url) return;
      let block: $Typed<PubLeafletBlocksButton.Main> = {
        $type: "pub.leaflet.blocks.button",
        text: text.data.value,
        url: url.data.value,
      };
      return block;
    }
    return;
  }

  async function canvasBlocksToRecord(
    pageID: string,
    did: string,
  ): Promise<PubLeafletPagesCanvas.Block[]> {
    let canvasBlocks = scan.eav(pageID, "canvas/block");
    return (
      await Promise.all(
        canvasBlocks.map(async (canvasBlock) => {
          let blockEntity = canvasBlock.data.value;
          let position = canvasBlock.data.position;

          // Get the block content
          let blockType = scan.eav(blockEntity, "block/type")?.[0];
          if (!blockType) return null;

          let block: Block = {
            type: blockType.data.value,
            value: blockEntity,
            parent: pageID,
            position: "",
            factID: canvasBlock.id,
          };

          let content = await blockToRecord(block, did);
          if (!content) return null;

          // Get canvas-specific properties
          let width =
            scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360;
          let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0]
            ?.data.value;

          let canvasBlockRecord: PubLeafletPagesCanvas.Block = {
            $type: "pub.leaflet.pages.canvas#block",
            block: content,
            x: Math.floor(position.x),
            y: Math.floor(position.y),
            width: Math.floor(width),
            ...(rotation !== undefined && { rotation: Math.floor(rotation) }),
          };

          return canvasBlockRecord;
        }),
      )
    ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null);
  }
}

function YJSFragmentToFacets(
  node: Y.XmlElement | Y.XmlText | Y.XmlHook,
  byteOffset: number = 0,
  footnoteContentResolver?: (footnoteEntityID: string) => { plaintext: string; facets: PubLeafletRichtextFacet.Main[] },
): { facets: PubLeafletRichtextFacet.Main[]; byteLength: number } {
  if (node.constructor === Y.XmlElement) {
    // Handle footnote inline nodes
    if (node.nodeName === "footnote") {
      const footnoteEntityID = node.getAttribute("footnoteEntityID") || "";
      const placeholder = "*";
      const unicodestring = new UnicodeString(placeholder);
      let footnoteContent = footnoteContentResolver?.(footnoteEntityID);
      const facet: PubLeafletRichtextFacet.Main = {
        index: {
          byteStart: byteOffset,
          byteEnd: byteOffset + unicodestring.length,
        },
        features: [
          {
            $type: "pub.leaflet.richtext.facet#footnote",
            footnoteId: footnoteEntityID,
            contentPlaintext: footnoteContent?.plaintext || "",
            ...(footnoteContent?.facets?.length
              ? { contentFacets: footnoteContent.facets }
              : {}),
          },
        ],
      };
      return { facets: [facet], byteLength: unicodestring.length };
    }

    // Handle inline mention nodes
    if (node.nodeName === "didMention") {
      const text = node.getAttribute("text") || "";
      const unicodestring = new UnicodeString(text);
      const facet: PubLeafletRichtextFacet.Main = {
        index: {
          byteStart: byteOffset,
          byteEnd: byteOffset + unicodestring.length,
        },
        features: [
          {
            $type: "pub.leaflet.richtext.facet#didMention",
            did: node.getAttribute("did"),
          },
        ],
      };
      return { facets: [facet], byteLength: unicodestring.length };
    }

    if (node.nodeName === "atMention") {
      const text = node.getAttribute("text") || "";
      const unicodestring = new UnicodeString(text);
      const facet: PubLeafletRichtextFacet.Main = {
        index: {
          byteStart: byteOffset,
          byteEnd: byteOffset + unicodestring.length,
        },
        features: [
          {
            $type: "pub.leaflet.richtext.facet#atMention",
            atURI: node.getAttribute("atURI"),
            ...(node.getAttribute("href")
              ? { href: node.getAttribute("href") }
              : {}),
          },
        ],
      };
      return { facets: [facet], byteLength: unicodestring.length };
    }

    if (node.nodeName === "hard_break") {
      const unicodestring = new UnicodeString("\n");
      return { facets: [], byteLength: unicodestring.length };
    }

    // For other elements (like paragraph), process children
    let allFacets: PubLeafletRichtextFacet.Main[] = [];
    let currentOffset = byteOffset;
    for (const child of node.toArray()) {
      const result = YJSFragmentToFacets(child, currentOffset, footnoteContentResolver);
      allFacets.push(...result.facets);
      currentOffset += result.byteLength;
    }
    return { facets: allFacets, byteLength: currentOffset - byteOffset };
  }

  if (node.constructor === Y.XmlText) {
    let facets: PubLeafletRichtextFacet.Main[] = [];
    let delta = node.toDelta() as Delta[];
    let byteStart = byteOffset;
    let totalLength = 0;
    for (let d of delta) {
      let unicodestring = new UnicodeString(d.insert);
      let facet: PubLeafletRichtextFacet.Main = {
        index: {
          byteStart,
          byteEnd: byteStart + unicodestring.length,
        },
        features: [],
      };

      if (d.attributes?.strikethrough)
        facet.features.push({
          $type: "pub.leaflet.richtext.facet#strikethrough",
        });

      if (d.attributes?.code)
        facet.features.push({ $type: "pub.leaflet.richtext.facet#code" });
      if (d.attributes?.highlight)
        facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" });
      if (d.attributes?.underline)
        facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" });
      if (d.attributes?.strong)
        facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" });
      if (d.attributes?.em)
        facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" });
      if (d.attributes?.link)
        facet.features.push({
          $type: "pub.leaflet.richtext.facet#link",
          uri: d.attributes.link.href,
        });
      if (facet.features.length > 0) facets.push(facet);
      byteStart += unicodestring.length;
      totalLength += unicodestring.length;
    }
    return { facets, byteLength: totalLength };
  }
  return { facets: [], byteLength: 0 };
}

type ExcludeString<T> = T extends string
  ? string extends T
    ? never
    : T /* maybe literal, not the whole `string` */
  : T; /* not a string */

async function extractThemeFromFacts(
  facts: Fact<any>[],
  root_entity: string,
  agent: AtpBaseClient,
): Promise<PubLeafletPublication.Theme | undefined> {
  let scan = scanIndexLocal(facts);
  let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data
    .value;
  let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data
    .value;
  let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value;
  let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0]
    ?.data.value;
  let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value;
  let showPageBackground = !scan.eav(
    root_entity,
    "theme/card-border-hidden",
  )?.[0]?.data.value;
  let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0];
  let backgroundImageRepeat = scan.eav(
    root_entity,
    "theme/background-image-repeat",
  )?.[0];
  let pageWidth = scan.eav(root_entity, "theme/page-width")?.[0];

  let theme: PubLeafletPublication.Theme = {
    showPageBackground: showPageBackground ?? true,
  };

  if (pageWidth) theme.pageWidth = pageWidth.data.value;
  if (pageBackground)
    theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`));
  if (cardBackground)
    theme.pageBackground = ColorToRGBA(parseColor(`hsba(${cardBackground})`));
  if (primary) theme.primary = ColorToRGB(parseColor(`hsba(${primary})`));
  if (accentBackground)
    theme.accentBackground = ColorToRGB(
      parseColor(`hsba(${accentBackground})`),
    );
  if (accentText)
    theme.accentText = ColorToRGB(parseColor(`hsba(${accentText})`));

  // Upload background image if present
  if (backgroundImage?.data) {
    let imageData = await fetch(backgroundImage.data.src);
    if (imageData.status === 200) {
      let binary = await imageData.blob();
      let blob = await agent.com.atproto.repo.uploadBlob(binary, {
        headers: { "Content-Type": binary.type },
      });

      theme.backgroundImage = {
        $type: "pub.leaflet.theme.backgroundImage",
        image: blob.data.blob,
        repeat: backgroundImageRepeat?.data.value ? true : false,
        ...(backgroundImageRepeat?.data.value && {
          width: Math.floor(backgroundImageRepeat.data.value),
        }),
      };
    }
  }

  // Only return theme if at least one property is set
  if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) {
    return theme;
  }

  return undefined;
}

/**
 * Extract mentions from a published document and create notifications
 */
async function createMentionNotifications(
  documentUri: string,
  record: PubLeafletDocument.Record | SiteStandardDocument.Record,
  authorDid: string,
) {
  const mentionedDids = new Set<string>();
  const mentionedPublications = new Map<string, string>(); // Map of DID -> publication URI
  const mentionedDocuments = new Map<string, string>(); // Map of DID -> document URI
  const embeddedBskyPosts = new Map<string, string>(); // Map of author DID -> post URI

  // Extract pages from either format
  let pages: PubLeafletContent.Main["pages"] | undefined;
  if (record.$type === "site.standard.document") {
    const content = record.content;
    if (content && PubLeafletContent.isMain(content)) {
      pages = content.pages;
    }
  } else {
    pages = record.pages;
  }

  if (!pages) return;

  // Helper to extract blocks from all pages (both linear and canvas)
  function getAllBlocks(pages: PubLeafletContent.Main["pages"]) {
    const blocks: (
      | PubLeafletPagesLinearDocument.Block["block"]
      | PubLeafletPagesCanvas.Block["block"]
    )[] = [];
    for (const page of pages) {
      if (page.$type === "pub.leaflet.pages.linearDocument") {
        const linearPage = page as PubLeafletPagesLinearDocument.Main;
        for (const blockWrapper of linearPage.blocks) {
          blocks.push(blockWrapper.block);
        }
      } else if (page.$type === "pub.leaflet.pages.canvas") {
        const canvasPage = page as PubLeafletPagesCanvas.Main;
        for (const blockWrapper of canvasPage.blocks) {
          blocks.push(blockWrapper.block);
        }
      }
    }
    return blocks;
  }

  const allBlocks = getAllBlocks(pages);

  // Extract mentions from all text blocks and embedded Bluesky posts
  for (const block of allBlocks) {
    // Check for embedded Bluesky posts
    if (PubLeafletBlocksBskyPost.isMain(block)) {
      const bskyPostUri = block.postRef.uri;
      // Extract the author DID from the post URI (at://did:xxx/app.bsky.feed.post/xxx)
      const postAuthorDid = new AtUri(bskyPostUri).host;
      if (postAuthorDid !== authorDid) {
        embeddedBskyPosts.set(postAuthorDid, bskyPostUri);
      }
    }

    // Check for text blocks with mentions
    if (block.$type === "pub.leaflet.blocks.text") {
      const textBlock = block as PubLeafletBlocksText.Main;
      if (textBlock.facets) {
        for (const facet of textBlock.facets) {
          for (const feature of facet.features) {
            // Check for DID mentions
            if (PubLeafletRichtextFacet.isDidMention(feature)) {
              if (feature.did !== authorDid) {
                mentionedDids.add(feature.did);
              }
            }
            // Check for AT URI mentions (publications and documents)
            if (PubLeafletRichtextFacet.isAtMention(feature)) {
              const uri = new AtUri(feature.atURI);

              if (isPublicationCollection(uri.collection)) {
                // Get the publication owner's DID
                const { data: publication } = await supabaseServerClient
                  .from("publications")
                  .select("identity_did")
                  .eq("uri", feature.atURI)
                  .single();

                if (publication && publication.identity_did !== authorDid) {
                  mentionedPublications.set(
                    publication.identity_did,
                    feature.atURI,
                  );
                }
              } else if (isDocumentCollection(uri.collection)) {
                // Get the document owner's DID
                const { data: document } = await supabaseServerClient
                  .from("documents")
                  .select("uri, data")
                  .eq("uri", feature.atURI)
                  .single();

                if (document) {
                  const normalizedMentionedDoc = normalizeDocumentRecord(
                    document.data,
                  );
                  // Get the author from the document URI (the DID is the host part)
                  const mentionedUri = new AtUri(feature.atURI);
                  const docAuthor = mentionedUri.host;
                  if (normalizedMentionedDoc && docAuthor !== authorDid) {
                    mentionedDocuments.set(docAuthor, feature.atURI);
                  }
                }
              }
            }
          }
        }
      }
    }
  }

  // Create notifications for DID mentions
  for (const did of mentionedDids) {
    const notification: Notification = {
      id: v7(),
      recipient: did,
      data: {
        type: "mention",
        document_uri: documentUri,
        mention_type: "did",
      },
    };
    await supabaseServerClient.from("notifications").insert(notification);
    await pingIdentityToUpdateNotification(did);
  }

  // Create notifications for publication mentions
  for (const [recipientDid, publicationUri] of mentionedPublications) {
    const notification: Notification = {
      id: v7(),
      recipient: recipientDid,
      data: {
        type: "mention",
        document_uri: documentUri,
        mention_type: "publication",
        mentioned_uri: publicationUri,
      },
    };
    await supabaseServerClient.from("notifications").insert(notification);
    await pingIdentityToUpdateNotification(recipientDid);
  }

  // Create notifications for document mentions
  for (const [recipientDid, mentionedDocUri] of mentionedDocuments) {
    const notification: Notification = {
      id: v7(),
      recipient: recipientDid,
      data: {
        type: "mention",
        document_uri: documentUri,
        mention_type: "document",
        mentioned_uri: mentionedDocUri,
      },
    };
    await supabaseServerClient.from("notifications").insert(notification);
    await pingIdentityToUpdateNotification(recipientDid);
  }

  // Create notifications for embedded Bluesky posts (only if the author has a Leaflet account)
  if (embeddedBskyPosts.size > 0) {
    // Check which of the Bluesky post authors have Leaflet accounts
    const { data: identities } = await supabaseServerClient
      .from("identities")
      .select("atp_did")
      .in("atp_did", Array.from(embeddedBskyPosts.keys()));

    const leafletUserDids = new Set(identities?.map((i) => i.atp_did) ?? []);

    for (const [postAuthorDid, bskyPostUri] of embeddedBskyPosts) {
      // Only notify if the post author has a Leaflet account
      if (leafletUserDids.has(postAuthorDid)) {
        const notification: Notification = {
          id: v7(),
          recipient: postAuthorDid,
          data: {
            type: "bsky_post_embed",
            document_uri: documentUri,
            bsky_post_uri: bskyPostUri,
          },
        };
        await supabaseServerClient.from("notifications").insert(notification);
        await pingIdentityToUpdateNotification(postAuthorDid);
      }
    }
  }
}
