import {
  _createNote,
  _appendToNote,
  _readNote,
  _searchNotes,
  _listNotes,
  _listFolders,
  _deleteNote,
  _folderExists,
  _preflightNote,
} from "agency-lang/stdlib-lib/appleNotes.js"
import { parse, renderForHtml } from "std::markdown"

/** @module
  @summary Create, read, search, and edit notes in the macOS Notes app.
  Create, read, search, and edit notes in the macOS Notes app. macOS only.
  The first call asks for Automation permission with a system dialog. If
  nobody answers the dialog, the call fails after 30 seconds.

  ```ts
  import { createNote } from "std::notes/apple"

  node main() {
    const result = createNote("Findings", "## Summary\n\n- one\n- two")
    print(result)
  }
  ```

  ## Notes are addressed by id

  Two notes in the same folder can share a title, so a title is not an
  address. Every read and edit takes the note's `id`, which comes from
  `listNotes` or `searchNotes`:

  ```ts
  import { searchNotes, appendToNote } from "std::notes/apple"

  node main() {
    const found = searchNotes("Q3 planning")
    if (found is success(notes)) {
      appendToNote(notes[0].id, "\n## Update\n\nShipped.")
    }
  }
  ```

  `createNote` returns the note it made, including its id, so
  create-then-append needs no search.

  ## Bodies are Markdown going in, plain text coming out

  `createNote` and `appendToNote` take Markdown and convert it to the HTML
  that Notes stores. `readNote` returns plain text. That makes a
  read-then-write round trip lossy: reading strips the formatting, and
  writing the text back would flatten the note. Use `appendToNote` instead
  of reading, editing, and rewriting a note yourself.

  ## Confining an agent to one folder

  Bind `folder` on tools before handing them to an agent. Search and list
  calls then stay within that folder. Reads, appends, and deletes fail if
  the note belongs to another folder. The model cannot see or change the
  bound folder, because a bound parameter is removed from the tool's schema:

  ```ts
  import { listNotes, readNote } from "std::notes/apple"

  node main() {
    const reader = readNote.partial(folder: "Work").rename("readWorkNote")
    const lister = listNotes.partial(folder: "Work").rename("listWorkNotes")
    llm("Summarise my Work notes", { tools: [lister, reader] })
  }
  ```

  These tools only list or read notes in Work. The model may pass any note
  id it likes; anything outside Work fails closed.

  ## Deciding with a policy

  Each operation raises its own effect, so a policy can approve reads and
  reject deletes:

  ```json
  {
    "std::notes::read": [
      { "match": { "folder": "Work" }, "action": "approve" },
      { "action": "reject" }
    ],
    "std::notes::delete": [{ "action": "reject" }]
  }
  ```

  `readNote`, `appendToNote`, and `deleteNote` look the note up before
  raising their interrupt, so the payload carries the note's real `title`,
  `folder`, and `account` even when the call passed only an id. The rule
  above therefore matches a bare `readNote(id)` for a note that lives in
  Work.

  Only those three calls populate `account`. `createNote`,
  `searchNotes`, and `listNotes` send it as an empty string, and an empty
  string matches no glob. A rule that matches on `account` never applies to
  them, so match on `folder` instead.

  The `NotesRead`, `NotesWrite`, and `Notes` sets in `std::effectSets`
  cover the same split for constraining a whole node.

  ## Locked notes

  A note locked with a password cannot be read or edited, and this module
  will not try. Calls against a locked note fail before their interrupt is
  raised, with a message naming the note.

  ## Deleting is recoverable

  `deleteNote` moves a note to Recently Deleted, where it stays for about
  30 days. `listFolders` returns that folder like any other.

  For an Obsidian vault, use `std::fs` to read and write its Markdown files.
*/

/** A folder in Notes, including the number of notes it contains. */
export type Folder = {
  id: string
  name: string
  noteCount: number
}

/** Note metadata from a listing, search, or write, without the body. */
export type Note = {
  id: string
  title: string
  folder: string
  account: string
  modified: string
  passwordProtected: boolean
}

/** A note including its content, as plaintext. */
export type NoteContent = {
  id: string
  title: string
  folder: string
  account: string
  body: string
  modified: string
}

@always(folder)
effect std::notes::create { account: string, folder: string, title: string, folderCreated: boolean }
@always(folder)
effect std::notes::append { account: string, folder: string, title: string, id: string }
@always(folder)
effect std::notes::read   { account: string, folder: string, title: string, id: string }
@always(folder)
effect std::notes::search { account: string, folder: string, query: string }
@always(folder)
effect std::notes::list   { account: string, folder: string }
@always(folder)
effect std::notes::delete { account: string, folder: string, title: string, id: string }

/* Convert Markdown to the HTML that Notes requires on write. */
def toHtml(body: string): Result<string> {
  const parsed = parse(body)
  if (!parsed.success) {
    return failure("Could not parse the note body as Markdown: ${parsed.error}")
  }
  return success(renderForHtml(parsed.blocks))
}

/* Mirror the TypeScript preflight record so Agency can typecheck its fields. */
type Preflight = {
  id: string
  title: string
  folder: string
  account: string
  locked: boolean
}

/* Read the real title, folder, and account before building the approval
   payload. Reject locked notes and folder mismatches before the interrupt.
   The TypeScript operation rechecks both after approval, since the note
   may change while waiting. */
@capabilities(subprocess)
def preflight(id: string, folder: string | null): Result<Preflight> {
  const meta = try _preflightNote(id)
  if (isFailure(meta)) {
    return meta
  }
  if (folder != null && meta.value.folder != folder) {
    return failure("Note ${meta.value.title} is in folder ${meta.value.folder}, not ${folder}. Refusing.")
  }
  if (meta.value.locked) {
    return failure("Note ${meta.value.title} is locked. Unlock it in Notes.app and retry.")
  }
  return meta
}

@capabilities(subprocess)
export def createNote(title: string, body: string, folder: string = "Agency Notes"): Result<Note> raises <std::notes::create> {
  """
  Create a note in the macOS Notes app.

  @param body - The note's content as Markdown.
  @param folder - The folder to use, creating it if missing.
  """
  const html = toHtml(body)
  if (isFailure(html)) {
    return html
  }

  const exists = try _folderExists(folder)
  if (isFailure(exists)) {
    return exists
  }

  // exists is the Result from `try`, so negate the VALUE. `!exists` would
  // negate the always-truthy success object and folderCreated would silently
  // always read false.
  return interrupt std::notes::create("Create a note in the Notes app?", {
    account: "",
    folder: folder,
    title: title,
    folderCreated: !exists.value
  })

  destructive {
    return try _createNote(title, html.value, folder)
  }
}

@capabilities(subprocess)
export def appendToNote(id: string, body: string, folder?: string): Result<Note> raises <std::notes::append> {
  """
  Append Markdown to an existing note.

  @param id - A note ID from a listing, search, or create result.
  @param body - Markdown content to add to the existing body.
  @param folder - If supplied, fail unless the note belongs to this folder.
  """
  const html = toHtml(body)
  if (isFailure(html)) {
    return html
  }

  const meta = preflight(id, folder)
  if (isFailure(meta)) {
    return meta
  }

  return interrupt std::notes::append("Append to a note in the Notes app?", {
    account: meta.value.account,
    folder: meta.value.folder,
    title: meta.value.title,
    id: id
  })

  destructive {
    return try _appendToNote(id, html.value, folder)
  }
}

@capabilities(subprocess)
export idempotent def readNote(id: string, folder?: string): Result<NoteContent> raises <std::notes::read> {
  """
  Read a note's contents as plain text.

  @param id - A note ID from a listing, search, or create result.
  @param folder - If supplied, fail unless the note belongs to this folder.
  """
  const meta = preflight(id, folder)
  if (isFailure(meta)) {
    return meta
  }

  return interrupt std::notes::read("Read a note from the Notes app?", {
    account: meta.value.account,
    folder: meta.value.folder,
    title: meta.value.title,
    id: id
  })

  return try _readNote(id, folder)
}

@capabilities(subprocess)
export idempotent def searchNotes(query: string, folder?: string): Result<Note[]> raises <std::notes::search> {
  """
  Search notes by their text and return matching notes' metadata, without their
  contents.

  @param query - The text to search for
  @param folder - If given, only search this folder
  """
  const payloadFolder = if folder == null then "" else folder

  return interrupt std::notes::search("Search the notes in the Notes app?", {
    account: "",
    folder: payloadFolder,
    query: query
  })

  return try _searchNotes(query, folder)
}

@capabilities(subprocess)
export idempotent def listNotes(folder?: string): Result<Note[]> raises <std::notes::list> {
  """
  List notes' metadata, without their contents.

  @param folder - If given, only list this folder
  """
  const payloadFolder = if folder == null then "" else folder

  return interrupt std::notes::list("List the notes in the Notes app?", {
    account: "",
    folder: payloadFolder
  })

  return try _listNotes(folder)
}

@capabilities(subprocess)
export idempotent def listFolders(): Result<Folder[]> raises <std::notes::list> {
  """
  List the folders in the Notes app, with a count of the notes in each.
  """
  return interrupt std::notes::list("List the folders in the Notes app?", {
    account: "",
    folder: ""
  })

  return try _listFolders()
}

@capabilities(subprocess)
export def deleteNote(id: string, folder?: string): Result<null> raises <std::notes::delete> {
  """
  Move a note to Recently Deleted, where it remains for about 30 days.

  @param id - A note ID from a listing, search, or create result.
  @param folder - If supplied, fail unless the note belongs to this folder.
  """
  const meta = preflight(id, folder)
  if (isFailure(meta)) {
    return meta
  }

  return interrupt std::notes::delete("Delete a note from the Notes app?", {
    account: meta.value.account,
    folder: meta.value.folder,
    title: meta.value.title,
    id: id
  })

  destructive {
    return try _deleteNote(id, folder)
  }
}
