// `_*` exports read ctx/stack/threads from the current run (see
// `lib/runtime/asyncContext.ts`) so message pushes target the active
// scope's thread, cost/token reads see the branch-local
// accumulators, and guard scopes push/pop on the right stack. The
// deprecated `__internal_*` exports remain in `lib/stdlib/thread.ts`
// for the migration window.
import {
  _systemMessage,
  _userMessage,
  _toolMessage,
  _assistantMessage,
  _imageAttachment,
  _fileAttachment,
  _audioAttachment,
  _attachToReply,
  _endTurn,
  _handBack,
  _insideToolCall,
  _viewFilePrecheck,
  _getCost,
  _getTokens,
  _getModelCosts,
  _lastReply,
  _threadIsNew,
  _threadHasSystemMessage,
 } from "agency-lang/stdlib-lib/thread.js"
import { _realTarget } from "agency-lang/stdlib-lib/fs.js"
import { _approvedFilePath } from "agency-lang/stdlib-lib/approvedPath.js"
import { dirname, basename } from "std::path"

import { _installSlowInputImpl } from "agency-lang/stdlib-lib/builtins.js"
import {
  _listThreadsRaw,
  _getThread,
  _setThreadSummary,
  _currentThreadId,
  _sessionThreadId,
} from "agency-lang/stdlib-lib/threads.js"

/** @module
@summary Manage LLM conversation history, attachments, and usage records.

Use message helpers to seed the active conversation, attachment builders
to provide images or files, and `lastReply` for reply metadata. The thread
inspection helpers can read other conversations in the same run.

```ts
import { systemMessage, currentThreadId, getThread } from "std::thread"

node main() {
  thread {
    systemMessage("Answer in one sentence.")
    const answer: string = llm("What causes rain?")
    const id = currentThreadId()
    print(getThread(id))
  }
}
```

`listThreads` may call an LLM to summarize closed threads. Pass
`lazySummarize: false` to inspect the registry without generating summaries.
*/

// The single source of truth for the attachment shape. `llm()`'s first-param
// type in lib/typeChecker/builtins.ts references this `Attachment` alias by
// name (no duplicated structure). The TS builders in lib/stdlib/thread.ts
// (`_imageAttachment` / `_fileAttachment`) construct values of this shape at
// runtime, so their return types must stay structurally compatible. The
// end-to-end fixture in tests/agency-js/multimodal-attachments guards that.
/** Attachment data can come from a path, a URL, or inline base64 with a MIME type.
Use `image`, `file`, and `audio` to construct these values. */
export type AttachmentSource =
  | { kind: "path", path: string, mimeType?: string }
  | { kind: "url", url: string, mimeType?: string }
  | { kind: "base64", base64: string, mimeType: string }

// The NARROW union `attachToReply` accepts. The reply pipeline
// (lib/runtime/replyAttachments.ts) only handles image and file parts, so audio
// is deliberately excluded here: passing audio() to attachToReply is a type
// error, on purpose.
/** An image or file that can be sent in a message or queued by `attachToReply`. */
export type Attachment =
  | { type: "image", source: AttachmentSource }
  | { type: "file", source: AttachmentSource, filename?: string }

// The WIDER union usable in ordinary user messages (userMessage / llm). Audio is
// only supported in-message, not as a tool reply attachment. The image/file arms
// are inlined (not `| Attachment`) because the typechecker resolves a union of
// object arms, not a union that nests another alias. Kept structurally
// compatible with `_imageAttachment`/`_fileAttachment`/`_audioAttachment`.
/** An image, file, or audio input for `llm` or `userMessage`.
Audio inputs require a model that supports audio. */
export type MessageAttachment =
  | { type: "image", source: AttachmentSource }
  | { type: "file", source: AttachmentSource, filename?: string }
  | { type: "audio", source: AttachmentSource, filename?: string }

export def systemMessage(msg: string, label: string = "", expires: "never" | "llmCall" = "never") {
  """
  Add a system message to the current thread's message history.
  The message becomes part of the conversation context for subsequent
  llm() calls.

  @param msg - The system message content
  @param label - Optional debug label shown in statelog. Never sent to the model.
  @param expires - "never" (the default) keeps the message on the thread; "llmCall" removes it when the next llm() call on this thread returns.
  """
  _systemMessage(msg, label, expires)
}

export def userMessage(
  msg: string | (string | MessageAttachment)[],
  label: string = "",
  expires: "never" | "llmCall" = "never",
) {
  """
  Add a user message to the current thread's message history. Use this
  to seed the conversation with prior user context that wasn't actually
  typed by the user this turn.

  @param msg - The user message content: a string, or an array mixing text strings and attachments.
  @param label - Optional debug label shown in statelog. Never sent to the model.
  @param expires - "never" (the default) keeps the message on the thread; "llmCall" removes it when the next llm() call on this thread returns.
  """
  _userMessage(msg, label, expires)
}

/** Use this to record work done outside the model's tool loop. The model
sees the synthetic call and result on its next request. */
export def toolMessage(
  name: string,
  args: any,
  result: string,
  label: string = "",
) {
  """
  Add a synthetic tool call and result to the active conversation without executing it.
  Call only when no tool exchange is waiting for its result.

  @param args - Call arguments to serialize as JSON.
  @param label - Debug tag in statelog that is not sent to the model.
  """
  _toolMessage(name, args, result, label)
}

export def image(
  source: string,
  mimeType: string = "",
  base64: boolean = false,
): Attachment {
  """
  Build an image attachment for a multimodal llm() call. The source is
  read, fetched, and MIME-inferred when the message is sent.

  @param source - A local path, an http(s) URL, a base64-encoded data: URI, or raw base64 with base64: true.
  @param mimeType - Overrides inference and is required for raw base64.
  @param base64 - When true, treat `source` as raw base64 data.
  """
  return _imageAttachment(source, mimeType, base64)
}

export def file(
  source: string,
  filename: string = "",
  mimeType: string = "",
  base64: boolean = false,
): Attachment {
  """
  Build a file (e.g. PDF) attachment for a multimodal llm() call.

  @param source - A local path, an http(s) URL, a base64-encoded data: URI, or raw base64 with base64: true.
  @param filename - Name shown to the model, defaulting to the source basename.
  @param mimeType - Overrides inference and is required for raw base64.
  @param base64 - When true, treat `source` as raw base64 data.
  """
  return _fileAttachment(source, filename, mimeType, base64)
}

export def audio(
  source: string,
  filename: string = "",
  mimeType: string = "",
  base64: boolean = false,
): MessageAttachment {
  """
  Build an audio attachment for a multimodal llm() call. Only usable in
  `llm` and `userMessage` calls with a model that accepts audio input.
  It cannot be queued with `attachToReply`.

  @param source - A local path, an http(s) URL, a base64-encoded data: URI, or raw base64 with base64: true.
  @param filename - Name shown to the model, defaulting to the source basename.
  @param mimeType - Overrides inference and is required for raw base64.
  @param base64 - When true, treat `source` as raw base64 data.
  """
  return _audioAttachment(source, filename, mimeType, base64)
}

/** Queued images and files appear in a user message after the tool round.
Prefer path sources for generated files. The reply pipeline enforces model
modality, size, and attachment-count limits. */
@capabilities(fileRead)
export def attachToReply(attachment: Attachment) {
  """
  Show an image or file to the model after the current tool call finishes.
  Use only inside a tool invoked by `llm`; calls elsewhere have no effect.
  """
  _attachToReply(attachment)
}

/** The tool's result must match the caller's output contract. Failed tools,
ordinary tools sharing a round, incompatible return values, and queued
attachments cause the LLM call to continue. A handoff can end the call after
other tools in its round, since it runs last. Code after `endTurn` still runs. */
export def endTurn(scope: "llm" | "turn" = "llm") {
  """
  Use the current tool's return value as the LLM answer without a follow-up call.
  Call only inside an LLM tool invocation with a successful result matching
  the caller's requested output type and no queued reply attachments.

  @param scope - Use "turn" only when the answer covers the whole request and should end enclosing LLM calls too.
  """
  _endTurn(scope)
}

/** This replaces the default user-role message after a handoff returns.
An empty message, a rejected handoff, or a successful `endTurn` leaves the
custom message unused. Calls from ordinary tools have no effect. */
export def handBack(message: string) {
  """
  Set the message that returns control to the caller after a handoff.
  Call from inside a handoff function invoked as an LLM tool.
  """
  _handBack(message)
}

// viewFile sends a file's bytes to a model provider. That is a different
// permission from std::readImage, which reads the bytes into a variable.
// Approving one never authorizes the other.
@alwaysUnder(dir)
effect std::viewFile { dir: string, filename: string }

@capabilities(fileRead)
export def viewFile(path: string): Result<string> raises <std::viewFile> {
  """
  Show an image or PDF file to yourself. The file is attached to the
  message that follows this tool result. Only works when called as a
  tool inside an llm() call. Accepts .png, .jpg, .jpeg, .gif, .webp,
  and .pdf.

  @param path - Path to the image or PDF file
  """
  const real = try _realTarget(path)
  if (isFailure(real)) {
    return real
  }
  // Before the interrupt: name and stat only, never the bytes.
  const checked = try _viewFilePrecheck(real.value)
  if (isFailure(checked)) {
    return checked
  }
  if (!_insideToolCall()) {
    return failure("viewFile only works when called as a tool inside an llm() call.")
  }
  return interrupt std::viewFile("Show this file to the model?", {
    dir: dirname(real.value),
    filename: basename(real.value),
  })
  // After approval: re-validate the exact spelling the approver saw. The
  // reply pipeline reads the file itself at harvest, so it gets a path
  // that fixedPath and a descriptor open have both accepted.
  const approved = try _approvedFilePath(real.value)
  if (isFailure(approved)) {
    return approved
  }
  if (checked.value.kind == "pdf") {
    attachToReply(file(approved.value))
  } else {
    attachToReply(image(approved.value))
  }
  return success("Queued ${basename(real.value)} for attachment.")
}

export def assistantMessage(msg: string, label: string = "") {
  """
  Add an assistant message to the current thread's message history.
  Use this to inject prior assistant turns when reconstructing a
  conversation programmatically.

  @param msg - The assistant message content
  @param label - Optional debug label shown in statelog. Never sent to the model.
  """
  _assistantMessage(msg, label)
}

/** A fork branch includes its parent's accumulated cost plus its own spend.
After joining, the parent includes every branch, including race losers.
Subtract values taken before and after a section to measure its cost. */
export def getCost(): number {
  """
  Return cumulative usage cost in USD for the current execution branch.
  """
  return _getCost()
}

export idempotent def threadIsNew(): boolean {
  """
  True when the current message thread has no messages yet. Use it inside a
  thread block to send a system prompt only on the first entry.
  """
  return _threadIsNew()
}

/** For an agent's persona. An agent called twice from code on one thread
  keeps one persona; a handoff, whose system messages are removed when it
  hands back, pushes the persona fresh on every dispatch. */
export def ensureSystemMessage(msg: string) {
  """
  Push `msg` as a system message unless the active thread already holds it.

  @param msg - The system message
  """
  if (!_threadHasSystemMessage(msg)) {
    systemMessage(msg)
  }
}

export def getTokens(): number {
  """
  Return the cumulative token count for the current execution branch.
  """
  return _getTokens()
}

/** Usage for one kind and model across the run. `cost` is in USD.
Cache-read and cache-creation tokens are billed separately from ordinary
input tokens. */
export type ModelCost = {
  // "completion", "image", "embedding", "transcription", "speech" or "manual".
  kind: string;
  model: string;
  inputTokens: number;
  outputTokens: number;
  // Tokens read back from a cache entry, and tokens written to a new one.
  // Both are input-side and both are billed, at rates that differ from
  // `inputTokens`, so a total that leaves them out understates a cached
  // conversation by most of its size.
  cachedInputTokens: number;
  cacheCreationInputTokens: number;
  cost: number
}

/** Unlike the per-branch cost/token accessors, this covers every branch of
  the run, so it attributes spend per model even for
  subagents and tool calls that run on a different model. */
export def getModelCosts(): ModelCost[] {
  """
  Return total run usage by kind and model, sorted by cost from highest to lowest.
  """
  return _getModelCosts()
}

/** One block of a reasoning model's visible thinking. `signature` is the
  provider's token for it, opaque to you. */
export type ThinkingBlock = {
  text: string;
  signature: string
}

/** The tokens one reply consumed. */
export type ReplyUsage = {
  inputTokens: number;
  outputTokens: number
}

/** A decision model's answer to a yes-or-no question. `noul` is the
  probability that the answer is yes, from 0 to 1. */
export type NoulAnswer = {
  type: "noul";
  noul: number
}

/** A decision model's answer to a pick-one question: the option picked,
  a provider-specific confidence score, and the probability of every option.
  Read `probabilities[choice]` for the selected option's probability. */
export type ChoiceAnswer = {
  type: "choice";
  choice: string;
  confidence: number;
  probabilities: Record<string, number>
}

/** A decision model's answer on an ordered scale. `score` is the expected
  level and may be fractional; `legend` maps each level to its description.
  No Agency annotation produces a score question yet. */
export type ScoreAnswer = {
  type: "score";
  score: number;
  confidence: number;
  legend: Record<string, string>;
  probabilities: Record<string, number>
}

/** One answer from a decision model. Check `type` before reading the rest:
  `if (answer.type == "choice") { answer.confidence }`. */
export type DecisionAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer

/** A token the model could have produced at a position, and the log of
  its probability. */
export type TokenAlternative = {
  token: string;
  logprob: number
}

/** One generated token and the log of its probability. `top` lists the
  likeliest alternatives at that position, most likely first, when the
  call asked for them with `logprobs: { top: n }`; otherwise it is empty. */
export type TokenLogprob = {
  token: string;
  logprob: number;
  top: TokenAlternative[]
}

/** What the model attached to its last reply, beyond the text. `answers`
  is filled only by a decision model such as Jev: one answer per question,
  with a probability per option. `rawData` is whatever the provider put
  there, untyped. Null fields mean the reply carried nothing of that kind. */
export type Reply = {
  content: string;
  thinkingBlocks: ThinkingBlock[];
  logprobs: TokenLogprob[];
  usage: ReplyUsage | null;
  cost: number | null;
  answers: Record<string, DecisionAnswer> | null;
  rawData: any
}

/** Call after `llm` to inspect decision answers, thinking, log probabilities,
and usage. `answers.answer` holds a bare decision answer; object annotations
use the corresponding field names. Check an answer's `type` before reading
its fields.

For text models, request token probabilities with `logprobs: { top: 3 }`.
Providers that do not return log probabilities leave `logprobs` empty.
Values are natural logarithms, so zero means probability one.
Messages added by `assistantMessage` also count as replies. */
export def lastReply(): Reply | null {
  """
  Return the last assistant reply and its metadata on the active thread,
  or null when there is no reply.
  """
  return _lastReply()
}

/** The structured detail on a tripped guard's failure. It is the failure's
  `data`; the failure's message is a sentence naming the guard and its
  budget. */
export type GuardFailureData = {
  type: string;
  label: string | null;
  maxCost: number | null;
  actualCost: number | null;
  maxTime: number | null;
  actualTime: number | null
}

// Test-only seam (`import test { _installSlowInput }`): simulates a slow
// human without touching stdin. Guard fixtures use it to prove that time
// spent waiting for input does not count against time budgets.
def _installSlowInput(delayMs: number, answer: string) {
  """
  Simulate a delayed input response for tests.
  """
  _installSlowInputImpl(delayMs, answer)
}

/** A thread message rendered as text, with placeholders for attachments. */
export type ThreadMessage = {
  role: string,
  content: string,
}

/** A conversation registered in the current run, including its parent and activity state. */
export type ThreadInfo = {
  id: string,
  label?: string,
  summary?: string,
  parentId?: string,
  threadType: string,
  messageCount: number,
  isActive: boolean,
}

type SummaryResult = {
  summary: string,
}

def summarize(messages: ThreadMessage[]): string {
  """
  Summarize conversation messages in an isolated thread.
  """
  // System/developer messages are instructions, not conversation, and an
  // agent's system prompt can run thousands of tokens. Keep them out of the
  // summarizer prompt (mirrors the eager TS path in lib/stdlib/threads.ts).
  // maxTokens caps the summarizer so a model that misses its stop token
  // can't generate unboundedly.
  let transcript = ""
  for (m in messages) {
    if (m.role != "system" && m.role != "developer") {
      transcript = transcript + "[" + m.role + "] " + m.content + "\n"
    }
  }
  if (transcript == "") {
    return ""
  }
  let summary = ""
  thread(hidden: true) {
    // Bang validation: a malformed summary is a failure Result. The
    // summary stays "" then, which the cache treats as an intentional
    // empty marker.
    const result: SummaryResult! = llm("Summarize this conversation in 1-2 sentences:\n" + transcript, { maxTokens: 256 })
    if (result is success(value)) {
      summary = value.summary
    }
  }
  return summary
}

// Single owner of the "return cached summary, else LLM-summarize and
// cache" rule. Call it only for closed threads: an in-flight conversation
// should not be summarized mid-stream. An empty summary is cached too, so a
// thread is never summarized twice. Accepting only a non-nullable `messages`
// keeps the signature honest and avoids a flow-narrowing warning Agency's
// typechecker can't currently prove.
def summaryFor(id: string, existing: (string | null), messages: ThreadMessage[]): (string | null) {
  if (existing != null) {
    return existing
  }
  const s = summarize(messages)
  _setThreadSummary(id, s)
  return s
}

/** Success holds a `ThreadInfo[]` list. Failure means thread inspection could not run.
Closed threads may already have summaries from `thread(summarize: true)`.
With lazy summarization enabled, missing summaries are generated and cached.
Active threads are left unsummarized. Disable lazy summarization to use a
cached summary, the label, or an empty string without another LLM call. */
export def listThreads(lazySummarize: boolean = true): Result {
  """
  List active and closed conversations in the current run.

  @param lazySummarize - False avoids LLM calls and uses cached summaries or labels for missing summaries.
  """
  const raw = try _listThreadsRaw()
  if (isFailure(raw)) {
    return raw
  }
  const out: ThreadInfo[] = []
  for (t in raw.value) {
    let summary: (string | null) = null
    if (lazySummarize) {
      // Active threads are skipped, since the in-flight conversation
      // should not be summarized mid-stream. Closed threads with no
      // cached summary trigger a one-shot summarize via summaryFor().
      if (t.isActive) {
        summary = t.summary
      } else {
        summary = summaryFor(t.id, t.summary, t.messages)
      }
    } else {
      // No LLM round-trip — return whatever is cached, falling
      // back to the label (or "" if unlabeled) so the field is
      // always populated.
      if (t.summary != null) {
        summary = t.summary
      } else if (t.label != null) {
        summary = t.label
      } else {
        summary = ""
      }
    }
    out.push({
      id: t.id,
      label: t.label,
      summary: summary,
      parentId: t.parentId,
      threadType: t.threadType,
      messageCount: t.messageCount,
      isActive: t.isActive
    })
  }
  return success(out)
}

export def sessionThreadId(name: string): string {
  """
  Slug-form id of the thread that `thread(session: name)` resumes (e.g.
  "t3"), or `""` when nothing has been opened under that name yet.
  """
  return _sessionThreadId(name)
}

/** Save this id before leaving a thread block when you want to reopen it
later with `thread(continue: id)`. */
export def currentThreadId(): string {
  """
  Return the active thread id, such as "t3", or "" outside a runtime frame.
  """
  return _currentThreadId()
}

/** Success holds a `ThreadMessage[]` slice. Tool calls and structured content
are rendered as text, and attachment payloads become placeholders. */
export def getThread(id: string, offset: number = 0, limit: number = 50): Result {
  """
  Read a slice of a conversation's messages, returning an empty list for an unknown id.

  @param id - Thread id from `listThreads` or `currentThreadId`.
  @param offset - Zero-based index of the first message to return.
  """
  return try _getThread(id, offset, limit)
}
