/**
 * voice-retrieve-conditioning — return the operator's style card plus K
 * voice-matched exemplars for a drafting brief.
 *
 * Task 462 changes:
 *   - `brief.format` (K-selector: 'short'|'long') renamed to `brief.length`
 *     to free the name for the new `brief.format: VoiceFormat` axis.
 *   - `brief.format: VoiceFormat` (required) — corpus exemplars are filtered
 *     to this writing format; the `:VoiceProfile` is the per-format profile.
 *   - All callers updated in the same commit.
 *
 * Retrieval order (best to worst):
 *   1. If brief.topic is provided AND keyword search on the per-format corpus
 *      returns ≥K matches → use those (ordered by recency).
 *   2. Otherwise, return the K most-recent per-format corpus nodes.
 *
 * Embeddings are not used in v1: the corpus is small (single operator's
 * personal writing — typically <500 docs), and recency-with-keyword-filter
 * is both faster and more predictable than vector ANN at that scale.
 * Vector-search is a Task 365-follow-up if the corpus grows beyond the
 * 500-doc breakeven point.
 *
 * Graceful degradation, discriminated (Task 493): the function never throws.
 * Missing profile / empty corpus returns status "no-data"; a failed lookup
 * (blank identity, driver/connection fault, Cypher error) returns status
 * "error" with the message. The calling drafting skill falls back to its
 * default register on both, but only "no-data" means "you have no profile".
 */
import neo4j, { type Session } from "neo4j-driver";
import { getSession } from "../lib/neo4j.js";
import { notTrashed } from "../lib/voice-corpus.js";
import {
  VOICE_CORPUS_WHERE,
  voiceCorpusWhereWithFormat,
  type VoiceFormat,
} from "../lib/voice-corpus.js";

const DEFAULT_TOKEN_BUDGET_SHORT = 16_000;
const DEFAULT_TOKEN_BUDGET_LONG = 96_000;
const CHARS_PER_TOKEN = 4;

export interface VoiceRetrieveConditioningParams {
  accountId: string;
  userId: string;
  brief: {
    topic?: string;
    /**
     * Writing format for corpus and profile scoping (required, Task 462).
     * One :VoiceProfile per (accountId, userId, format).
     */
    format: VoiceFormat;
    /**
     * K-selector: 'short' = K=5 (16k token budget, email/post);
     *             'long'  = K=15 (96k token budget, brochure/prospectus).
     * Renamed from `format` in Task 462 to free the name for VoiceFormat.
     */
    length?: "short" | "long";
    register?: string;
  };
  tokenBudget?: number;
}

export interface VoiceExemplar {
  nodeId: string;
  label: string;
  body: string;
  source: string;
}

/**
 * Outcome discriminator (Task 493). Three mutually-exclusive states so the
 * drafting agent and operator can tell genuine no-data apart from a failed
 * lookup — both previously collapsed into the same empty payload:
 *   - "ok"      — query ran; a style card and/or exemplars were returned.
 *   - "no-data" — query ran; no profile exists and the corpus is empty.
 *   - "error"   — the lookup failed (blank identity, driver/connection
 *                 fault, or Cypher error). `error` carries the message.
 * Drafting degrades gracefully to default register on both "no-data" and
 * "error"; the caller must only report "no profile exists" on "no-data".
 */
export type VoiceRetrieveStatus = "ok" | "no-data" | "error";

export interface VoiceRetrieveConditioningResult {
  styleCard: string | null;
  exemplars: VoiceExemplar[];
  status: VoiceRetrieveStatus;
  error?: string;
}

function errorResult(error: string): VoiceRetrieveConditioningResult {
  return { styleCard: null, exemplars: [], status: "error", error };
}

export async function voiceRetrieveConditioning(
  params: VoiceRetrieveConditioningParams,
): Promise<VoiceRetrieveConditioningResult> {
  const { accountId, userId, brief } = params;
  // Blank identity is an error, never genuine no-data — index.ts guards both
  // accountId and userId before calling, so a blank value reaching here is a
  // precondition failure, not "you have no profile". Emit a line so this path
  // is no longer silent (Task 493 — closes the 06:46 path-1 ambiguity).
  if (!accountId || !userId) {
    process.stderr.write(
      `[voice-retrieve] error: missing-identity accountId=${Boolean(accountId)} userId=${Boolean(userId)}\n`,
    );
    return errorResult("missing accountId/userId");
  }

  const length = brief.length ?? "short";
  const k = length === "long" ? 15 : 5;
  const tokenBudget =
    params.tokenBudget ??
    (length === "long" ? DEFAULT_TOKEN_BUDGET_LONG : DEFAULT_TOKEN_BUDGET_SHORT);
  const charBudget = tokenBudget * CHARS_PER_TOKEN;
  const format = brief.format;
  const corpusWhere = voiceCorpusWhereWithFormat(format);

  // getSession() is inside the try so driver-construction / connection-config
  // faults (NEO4J_URI unset, password file missing) are caught, logged, and
  // returned as the error state rather than escaping the function silently
  // (Task 493).
  let session: Session | undefined;
  try {
    session = getSession();
    // 1. Style card — per-format profile.
    const profile = await session.run(
      `MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
       OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
       RETURN p.styleCard AS styleCard`,
      { accountId, userId, format },
    );
    const styleCard = (profile.records[0]?.get("styleCard") as string | null) ?? null;

    // 2. Exemplars — filtered to brief.format. Keyword-aware when topic is set.
    //
    // Body resolution (Task 471):
    //   - For :KnowledgeDocument with HAS_SECTION children, concatenate
    //     child :Section bodies in `position` order. KD.body is null after
    //     Task 465 server-slicing.
    //   - Other labels read n.body directly via the COALESCE fallback chain.
    //
    // The topic filter runs against the computed body, so a topic that
    // appears only in n.summary (LLM abstract) no longer matches — by
    // design: the corpus surfaces verbatim prose, not the abstract.
    const topic = (brief.topic ?? "").trim();
    const usesTopic = topic.length > 0;

    const bodyComputeBlock = `WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
         OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
         WHERE ${notTrashed("s")}
         WITH n, ts, s ORDER BY s.position
         WITH n, ts, collect(s.body) AS sectionBodies
         WITH n, ts, CASE
           WHEN 'KnowledgeDocument' IN labels(n) AND size(sectionBodies) > 0
             THEN reduce(acc = '', b IN sectionBodies | acc + b + '\n\n')
           ELSE coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '')
         END AS body`;

    const cypher = usesTopic
      ? `MATCH (n)
         WHERE ${corpusWhere}
         ${bodyComputeBlock}
         WHERE toLower(body) CONTAINS toLower($topic)
         RETURN elementId(n) AS id,
                labels(n) AS labels,
                body,
                coalesce(n.subject, n.title, n.name, '') AS source,
                ts
         ORDER BY ts IS NULL, ts DESC
         LIMIT $k`
      : `MATCH (n)
         WHERE ${corpusWhere}
         ${bodyComputeBlock}
         RETURN elementId(n) AS id,
                labels(n) AS labels,
                body,
                coalesce(n.subject, n.title, n.name, '') AS source,
                ts
         ORDER BY ts IS NULL, ts DESC
         LIMIT $k`;

    const result = await session.run(cypher, {
      accountId,
      format,
      topic,
      // neo4j-driver serializes a plain JS number as a Cypher float (15 → 15.0),
      // which LIMIT rejects ("not a valid value. Must be a non-negative integer").
      // Send it as a Neo4j integer.
      k: neo4j.int(k),
    });

    let charsUsed = 0;
    const exemplars: VoiceExemplar[] = [];
    for (const r of result.records) {
      const body = (r.get("body") as string | null) ?? "";
      if (body.length === 0) continue;
      const labels = (r.get("labels") as string[]) ?? [];
      const label =
        labels.find((l) =>
          ["KnowledgeDocument", "Message", "SocialPost", "Conversation"].includes(l),
        ) ?? labels[0] ?? "Unknown";
      const remaining = charBudget - charsUsed;
      if (remaining <= 0) break;
      const truncated = body.length > remaining ? body.slice(0, remaining) : body;
      exemplars.push({
        nodeId: r.get("id") as string,
        label,
        body: truncated,
        source: ((r.get("source") as string | null) ?? "").slice(0, 200),
      });
      charsUsed += truncated.length;
    }

    process.stderr.write(
      `[voice-retrieve] task=${brief.topic ?? length} format=${format} styleCardBytes=${
        styleCard?.length ?? 0
      } exemplarCount=${exemplars.length} tokenBudget=${tokenBudget}\n`,
    );

    // An empty-string styleCard with no exemplars is no-data, not ok — a blank
    // card conditions nothing, so reporting "ok" would be the same lie this
    // discriminator exists to prevent (Task 493).
    const hasStyleCard = styleCard !== null && styleCard.length > 0;
    const status: VoiceRetrieveStatus =
      hasStyleCard || exemplars.length > 0 ? "ok" : "no-data";
    return { styleCard, exemplars, status };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    process.stderr.write(`[voice-retrieve] error: ${message}\n`);
    return errorResult(message);
  } finally {
    if (session) await session.close();
  }
}
