/**
 * voice-tag-content — set `authorshipMode` and `format` on one or many
 * content nodes.
 *
 * The tool routes through neo4j directly with an account-scoped MERGE so
 * the operator can only mutate their own nodes. We do not go through
 * `memory-update` because that tool re-embeds and re-validates on every
 * call — overkill for a single-property authorship stamp.
 *
 * `format` is an editorial classification only the operator can set; it is
 * therefore required. The sole hard-coded exception lives in
 * `voice-ingest-session-text`, which stamps `format='text'` on auto-ingested
 * `:Message` rows where the operator is not in the loop (Task 471).
 */
import { getSession } from "../lib/neo4j.js";
import {
  TAGGABLE_PRIMARY_LABELS,
  FORMAT_VALUES,
  type VoiceFormat,
} from "../lib/voice-corpus.js";

export type AuthorshipMode =
  | "human-only"
  | "human-led-agent-assisted"
  | "agent-led-human-reviewed"
  | "agent-only"
  | "unknown";

export const AUTHORSHIP_MODES: ReadonlySet<AuthorshipMode> = new Set([
  "human-only",
  "human-led-agent-assisted",
  "agent-led-human-reviewed",
  "agent-only",
  "unknown",
]);

export interface VoiceTagContentParams {
  nodeIds: string[];
  mode: AuthorshipMode;
  accountId: string;
  /** Writing format (required). Editorial classification — operator-supplied. */
  format: VoiceFormat;
}

export interface VoiceTagContentResult {
  updated: number;
  skipped: number;
  skippedReasons: Record<string, number>;
}

export async function voiceTagContent(
  params: VoiceTagContentParams,
): Promise<VoiceTagContentResult> {
  const { nodeIds, mode, accountId, format } = params;

  if (!AUTHORSHIP_MODES.has(mode)) {
    throw new Error(
      `voice-tag-content: invalid authorshipMode '${mode}'. Valid values: ${[
        ...AUTHORSHIP_MODES,
      ].join(", ")}`,
    );
  }
  if (!accountId) {
    throw new Error("voice-tag-content: accountId is required");
  }
  if (format === undefined || format === null) {
    throw new Error("voice-tag-content: format is required");
  }
  if (!(FORMAT_VALUES as readonly string[]).includes(format)) {
    throw new Error(
      `voice-tag-content: invalid format '${format}'. Valid values: ${FORMAT_VALUES.join(", ")}`,
    );
  }
  if (!Array.isArray(nodeIds) || nodeIds.length === 0) {
    return { updated: 0, skipped: 0, skippedReasons: {} };
  }

  const session = getSession();
  const now = new Date().toISOString();
  const skippedReasons: Record<string, number> = {};
  let updated = 0;

  try {
    // Bulk update in batches of 50 to keep transaction latency bounded.
    const BATCH = 50;
    for (let i = 0; i < nodeIds.length; i += BATCH) {
      const batch = nodeIds.slice(i, i + BATCH);
      const result = await session.run(
        // Tag any of the three primary content labels, or a conversation-
        // document :Section chunk (parent :KnowledgeDocument carries
        // `conversationIdentity`). The EXISTS sub-pattern guards against
        // accidentally tagging document-path :Section rows AND parent
        // `:Conversation` / `:AdminConversation` nodes (which are not
        // body-bearing content).
        `UNWIND $ids AS id
         MATCH (n) WHERE elementId(n) = id AND n.accountId = $accountId
         WITH n
         WHERE any(lbl IN labels(n) WHERE lbl IN $primaryLabels)
            OR ('Section' IN labels(n) AND EXISTS {
                 MATCH (parent:KnowledgeDocument)-[:HAS_SECTION]->(n)
                 WHERE parent.conversationIdentity IS NOT NULL
               })
         SET n.authorshipMode = $mode,
             n.authorshipTaggedAt = $now,
             n.format = $format
         RETURN elementId(n) AS id`,
        {
          ids: batch,
          accountId,
          mode,
          now,
          format,
          primaryLabels: [...TAGGABLE_PRIMARY_LABELS],
        },
      );
      const matched = new Set(result.records.map((r) => r.get("id") as string));
      updated += matched.size;
      for (const id of batch) {
        if (matched.has(id)) continue;
        const reason = "not-found-or-not-content-label";
        skippedReasons[reason] = (skippedReasons[reason] ?? 0) + 1;
      }
    }
  } finally {
    await session.close();
  }

  process.stderr.write(
    `[voice-tag] mode=${mode} format=${format} accountId=${accountId} updated=${updated} skipped=${
      nodeIds.length - updated
    }\n`,
  );

  return {
    updated,
    skipped: nodeIds.length - updated,
    skippedReasons,
  };
}
