// AssistantUI AttachmentAdapter for Omnipus.
//
// This replaces the old hand-rolled upload bridge (pendingFiles +
// handleSendWithFiles + a custom paperclip + a form-submit hack in
// ChatScreen). With this adapter wired into useExternalStoreRuntime, the
// native composer flow — ComposerPrimitive.AddAttachment / .Attachments and
// ComposerPrimitive.Send — carries files identically whether the user presses
// the Send button, hits Enter, drags-drops, or pastes.
//
// Flow:
//   add()    — register a pending attachment; DO NOT upload yet (the user may
//              remove it before sending).
//   send()   — called for each attachment when the composer sends. Ensures a
//              session exists, uploads the file to /api/v1/uploads, and stashes
//              the resulting media:// ref keyed by attachment id so onNew (in
//              omnipus-runtime.ts) can thread it into our WS transport.
//   remove() — drop any stashed ref.

import type {
  AttachmentAdapter,
  CompleteAttachment,
  PendingAttachment,
} from "@assistant-ui/react";

import { createSession, uploadFiles } from "@/lib/api";
import { useSessionStore } from "@/store/session";
import { useUiStore } from "@/store/ui";
import { isImageAttachment } from "@/components/chat/AttachmentCard";

/** A resolved upload: the agent-facing media ref plus display metadata. */
export interface ResolvedUpload { // not-wire-format: internal client-side upload state (media ref + display metadata) drained by onNew; never serialized across the gateway/SPA boundary
  ref: string;
  url: string;
  filename: string;
  contentType: string;
}

// attachment.id -> resolved upload. Populated in send(), drained by onNew via
// takeResolvedUpload(). A Map (not message content) is used because our backend
// resolves media:// refs itself; AssistantUI's attachment content is inert here.
//
// Capped at MAX_RESOLVED_UPLOADS entries (evict oldest on overflow) to prevent
// unbounded growth when onNew never drains an entry (e.g. partial multi-file
// failure or navigating away before sending).
const MAX_RESOLVED_UPLOADS = 50;
const resolvedUploads = new Map<string, ResolvedUpload>();

/** Pull (and remove) the resolved upload for a completed attachment id. */
export function takeResolvedUpload(id: string): ResolvedUpload | undefined {
  const r = resolvedUploads.get(id);
  if (r) resolvedUploads.delete(id);
  return r;
}

/** Stash a resolved upload, evicting the oldest entry if the cap is reached. */
function stashResolvedUpload(id: string, upload: ResolvedUpload): void {
  if (resolvedUploads.size >= MAX_RESOLVED_UPLOADS) {
    // Map iteration order is insertion order — delete the first (oldest) key.
    const oldestKey = resolvedUploads.keys().next().value;
    if (oldestKey !== undefined) resolvedUploads.delete(oldestKey);
  }
  resolvedUploads.set(id, upload);
}

// Ensure a session exists before uploading. send() runs once per attachment and
// AssistantUI dispatches them concurrently, so memoize the in-flight creation to
// avoid racing multiple createSession calls.
let sessionEnsure: Promise<string> | null = null;
async function ensureSession(): Promise<string> {
  const { activeSessionId, activeAgentId, setActiveSession } = useSessionStore.getState();
  if (activeSessionId && activeSessionId !== "__pending") return activeSessionId;
  if (!sessionEnsure) {
    sessionEnsure = (async () => {
      if (!activeAgentId) throw new Error("Select an agent before sending files");
      const created = await createSession(activeAgentId);
      setActiveSession(created.id, created.agent_id, null);
      return created.id;
    })().finally(() => {
      sessionEnsure = null;
    });
  }
  return sessionEnsure;
}

// Accept images plus the document formats the backend can extract (docextract)
// or render natively (PDF). Roughly tracks docextract's supported set; note
// `.log` relies on the browser sending a text/* MIME (docextract has no .log
// extension rule), so a .log labelled application/octet-stream won't extract.
const ACCEPT = [
  "image/*",
  "application/pdf",
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  "application/vnd.openxmlformats-officedocument.presentationml.presentation",
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  ".pdf", ".docx", ".pptx", ".xlsx", ".txt", ".md", ".csv", ".json", ".log", ".yaml", ".yml",
].join(",");

let counter = 0;

// `satisfies` (not `: AttachmentAdapter`) so callers keep the concrete return
// types (add → Promise<PendingAttachment>) instead of the adapter interface's
// Promise|AsyncGenerator union, while still being checked for conformance.
export const omnipusAttachmentAdapter = {
  accept: ACCEPT,

  async add({ file }): Promise<PendingAttachment> {
    // Defer the upload to send() — registering here only tracks the pending file.
    return {
      id: `att-${Date.now()}-${counter++}-${file.name}`,
      type: isImageAttachment(file.name, file.type) ? "image" : "file",
      name: file.name,
      contentType: file.type || "application/octet-stream",
      file,
      status: { type: "requires-action", reason: "composer-send" },
    };
  },

  async send(attachment): Promise<CompleteAttachment> {
    // CompleteAttachment with no ref stashed — used when upload/registration
    // fails so the composer doesn't throw and lose the user's typed text.
    const failedComplete: CompleteAttachment = {
      ...attachment,
      status: { type: "complete" },
      content: [],
    };

    let sessionId: string;
    try {
      sessionId = await ensureSession();
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      useUiStore.getState().addToast({ message: `Could not send "${attachment.name}": ${msg}`, variant: "error" });
      return failedComplete;
    }

    let uploadResult: Awaited<ReturnType<typeof uploadFiles>>;
    try {
      uploadResult = await uploadFiles(sessionId, [attachment.file]);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      useUiStore.getState().addToast({ message: `Upload failed for "${attachment.name}": ${msg}`, variant: "error" });
      return failedComplete;
    }

    // We pass a single-element array, so files[0] is this attachment's result.
    const uploaded = uploadResult.files[0];
    if (!uploaded || !uploaded.path) {
      useUiStore.getState().addToast({ message: `Upload failed for "${attachment.name}" — no path returned.`, variant: "error" });
      return failedComplete;
    }

    const ref = typeof uploaded.ref === "string" ? uploaded.ref : undefined;
    if (!ref) {
      // File was saved but media-store registration failed — the agent cannot
      // see it inline. Toast and return without stashing so onNew skips the ref
      // while the user's typed text still sends.
      useUiStore.getState().addToast({
        message: `"${attachment.name}" could not be registered for the agent — re-attach and try again.`,
        variant: "error",
      });
      return failedComplete;
    }

    stashResolvedUpload(attachment.id, {
      ref,
      // URL uses the server-sanitized name, but display uses the ORIGINAL
      // filename (the server appends a uniqueness suffix, e.g.
      // report_1780….docx, which is ugly in the chat).
      url: `/api/v1/uploads/${sessionId}/${uploaded.name}`,
      filename: attachment.name,
      contentType: uploaded.content_type,
    });

    return {
      ...attachment,
      status: { type: "complete" },
      // Inert for our transport (the backend resolves the media:// ref); a single
      // text part keeps the CompleteAttachment shape valid. NOT merged into the
      // message content (AssistantUI keeps content = typed text only).
      content: [{ type: "text", text: `[attached file: ${attachment.name}]` }],
    };
  },

  async remove(attachment): Promise<void> {
    resolvedUploads.delete(attachment.id);
  },
} satisfies AttachmentAdapter;
