import { initStderrTee } from "../../../../platform/lib/mcp-stderr-tee/dist/index.js";
initStderrTee("writer-craft");

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { voiceTagContent, AUTHORSHIP_MODES, type AuthorshipMode } from "./tools/voice-tag-content.js";
import { voiceDistilProfile } from "./tools/voice-distil-profile.js";
import { voiceRetrieveConditioning } from "./tools/voice-retrieve-conditioning.js";
import { voiceRecordFeedback } from "./tools/voice-record-feedback.js";
import { voiceIngestSessionText } from "./tools/voice-ingest-session-text.js";
import { FORMAT_VALUES } from "./lib/voice-corpus.js";
import { closeDriver } from "./lib/neo4j.js";

const server = new McpServer({
  name: "maxy-writer-craft",
  version: "0.1.0",
});

// Plugin-system boot tolerance — module-init must never throw. Per-call
// refusal preserves account isolation. Mirrors the memory plugin's
// pattern (Task 202).
const accountId = process.env.ACCOUNT_ID ?? null;
const userIdEnv = process.env.ADMIN_USER_ID ?? null;
process.stderr.write(
  `[writer-craft] boot accountId=${accountId ?? "null"} userId=${userIdEnv ?? "null"}\n`,
);

function refuseNoAccount(toolName: string) {
  process.stderr.write(
    `[writer-craft] tool=${toolName} refuse reason=no-account-context\n`,
  );
  return {
    content: [
      {
        type: "text" as const,
        text: `${toolName} cannot execute: this MCP instance was launched without ACCOUNT_ID. Per-account graph access requires ACCOUNT_ID at spawn.`,
      },
    ],
    isError: true as const,
  };
}

function resolveUserId(): string | null {
  // ADMIN_USER_ID is stamped by the admin session manager at spawn time.
  // When absent (e.g. specialist subagents), the operator must pass it as
  // a tool argument or the tools refuse rather than orphan a profile.
  return userIdEnv;
}

// Format enum schema — shared across tools that accept VoiceFormat.
const zFormat = z.enum([...FORMAT_VALUES] as [string, ...string[]]).describe(
  "Writing format: text | email | social-post | article | marketing-copy | note. One :VoiceProfile per (accountId, userId, format).",
);

server.tool(
  "voice-tag-content",
  "Stamp authorshipMode and format on one or many content nodes (:KnowledgeDocument | :Message | :SocialPost) the operator owns. Email threads live as :KnowledgeDocument {source:'email'} since Task 321. Valid modes: human-only, human-led-agent-assisted, agent-led-human-reviewed, agent-only, unknown. Valid formats: text, email, social-post, article, marketing-copy, note. format is editorial — the operator chooses it per node; the tool does not infer it.",
  {
    nodeIds: z.array(z.string()).min(1).describe("Element IDs of nodes to tag"),
    mode: z
      .enum([
        "human-only",
        "human-led-agent-assisted",
        "agent-led-human-reviewed",
        "agent-only",
        "unknown",
      ])
      .describe("Authorship mode to apply"),
    format: zFormat.describe(
      "Writing format. Required — the operator's editorial classification of the content.",
    ),
  },
  async ({ nodeIds, mode, format }) => {
    if (!accountId) return refuseNoAccount("voice-tag-content");
    try {
      const result = await voiceTagContent({
        nodeIds,
        mode: mode as AuthorshipMode,
        accountId,
        format: format as import("./lib/voice-corpus.js").VoiceFormat,
      });
      return {
        content: [
          {
            type: "text" as const,
            text: `voice-tag: mode=${mode} format=${format} updated=${result.updated} skipped=${result.skipped}`,
          },
        ],
      };
    } catch (err) {
      return {
        content: [
          {
            type: "text" as const,
            text: `voice-tag-content failed: ${err instanceof Error ? err.message : String(err)}`,
          },
        ],
        isError: true,
      };
    }
  },
);

server.tool(
  "voice-distil-profile",
  "Three-mode tool. mode='sample' (default) walks the operator's full per-format human-only corpus and returns exemplars + recent edit intents for the agent to compose a fresh style card; cadence-guarded (≥20% corpus growth OR ≥30 days). mode='amend' (Task 472) reads only the named documents plus the existing :VoiceProfile.styleCard so the agent can decide whether one or more specific documents move the profile — operator-initiated, no cadence guard. mode='write' persists the YAML style card the agent composed; supply amendedFromNodeIds to attribute the write to specific documents (Task 472, bypasses cadence + corpus walk; existing LEARNED_FROM edges remain via MERGE). Trashed nodes are excluded from every walk. When format is omitted on 'sample', enumerates all formats in the corpus and distils each.",
  {
    userId: z
      .string()
      .optional()
      .describe(
        "Operator AdminUser identifier. Defaults to ADMIN_USER_ID env from spawn context.",
      ),
    format: zFormat.optional().describe(
      "Writing format to distil. Required for mode='amend' and mode='write'. When omitted on 'sample', enumerates corpus formats and distils each that fires the cadence guard.",
    ),
    force: z
      .boolean()
      .optional()
      .describe("Override cadence guard on full-corpus sample/write; ignored by mode='amend' (always proceeds)."),
    mode: z
      .enum(["sample", "amend", "write"])
      .optional()
      .describe("'sample' returns full-corpus exemplars + feedback for in-turn distillation; 'amend' returns existing styleCard + bodies of one or more named documents for incremental update; 'write' persists the agent-produced styleCardYaml."),
    styleCardYaml: z
      .string()
      .optional()
      .describe("Required for mode='write'. The YAML style card the agent composed (from the sample exemplars + feedback intents, or from the amend bodies + existing card)."),
    nodeIds: z
      .array(z.string())
      .optional()
      .describe(
        "Required for mode='amend'. ElementIds of the documents the operator wants folded into the per-format profile (one or more). Each id is eligibility-checked; ineligible ones come back in the ineligible[] payload.",
      ),
    amendedFromNodeIds: z
      .array(z.string())
      .optional()
      .describe(
        "Optional for mode='write' after an amend sample. ElementIds the agent decided actually moved the profile. Each becomes a LEARNED_FROM edge from the persisted :VoiceProfile. Setting this routes the write through the amend-write path (no corpus walk, cadence bypassed).",
      ),
  },
  async ({ userId, format, force, mode, styleCardYaml, nodeIds, amendedFromNodeIds }) => {
    if (!accountId) return refuseNoAccount("voice-distil-profile");
    const resolvedUserId = userId ?? resolveUserId();
    if (!resolvedUserId) {
      return {
        content: [
          {
            type: "text" as const,
            text:
              "voice-distil-profile: userId required (no ADMIN_USER_ID in spawn env).",
          },
        ],
        isError: true,
      };
    }
    try {
      const result = await voiceDistilProfile({
        accountId,
        userId: resolvedUserId,
        format: format as import("./lib/voice-corpus.js").VoiceFormat | undefined,
        force: force === true,
        mode,
        styleCardYaml,
        nodeIds,
        amendedFromNodeIds,
      });

      // Summarise single-format or multi-format result.
      const summariseOne = (r: import("./tools/voice-distil-profile.js").VoiceDistilProfileResult, fmt?: string): string => {
        const tag = fmt ? ` format=${fmt}` : "";
        if (r.eligible !== undefined) {
          // amend sample
          return `voice-distil amend${tag}: eligible=${r.eligible} ineligible=${r.ineligible?.length ?? 0} exemplars=${r.exemplars?.length ?? 0} existingProfile=${r.profileId ?? "none"} feedbackEntries=${r.feedbackEntries}\n${JSON.stringify({ existingStyleCard: r.existingStyleCard, exemplars: r.exemplars, feedbackIntents: r.feedbackIntents, ineligible: r.ineligible }, null, 2)}`;
        }
        if (r.skipped && r.exemplars) {
          return `voice-distil sample${tag}: corpusSize=${r.corpusSize} exemplars=${r.exemplars.length} feedbackEntries=${r.feedbackEntries}\n${JSON.stringify({ exemplars: r.exemplars, feedbackIntents: r.feedbackIntents }, null, 2)}`;
        }
        if (r.skipped) {
          return `voice-distil${tag}: skipped reason=${r.skipReason} corpusSize=${r.corpusSize}`;
        }
        return `voice-distil${tag}: ok profileId=${r.profileId} corpusSize=${r.corpusSize} feedbackEntries=${r.feedbackEntries} generatedAt=${r.generatedAt}`;
      };

      let summary: string;
      if (Array.isArray(result)) {
        summary = result.map((r, i) => summariseOne(r)).join("\n---\n");
      } else {
        summary = summariseOne(result, format);
      }

      return { content: [{ type: "text" as const, text: summary }] };
    } catch (err) {
      return {
        content: [
          {
            type: "text" as const,
            text: `voice-distil-profile failed: ${err instanceof Error ? err.message : String(err)}`,
          },
        ],
        isError: true,
      };
    }
  },
);

server.tool(
  "voice-retrieve-conditioning",
  "Return the operator's per-format style card and K voice-matched exemplars for a drafting brief. K=5 short-form (length='short'), K=15 long-form (length='long'). Token-budget bounded. format is required (the writing type: text|email|social-post|article|marketing-copy|note). Returns {styleCard, exemplars, status} where status is one of: 'ok' (profile and/or exemplars returned), 'no-data' (no profile exists and the corpus is empty), 'error' (the lookup failed — carries an error message). Drafting falls back to default register on both 'no-data' and 'error', but only 'no-data' means 'you have no voice profile yet' — never report no-profile on 'error'.",
  {
    userId: z
      .string()
      .optional()
      .describe("Operator AdminUser identifier. Defaults to ADMIN_USER_ID env."),
    format: zFormat.describe("Writing format for corpus and profile scoping."),
    topic: z
      .string()
      .optional()
      .describe("Drafting topic keyword (filters exemplars to relevant ones)"),
    length: z
      .enum(["short", "long"])
      .optional()
      .describe("K-selector: short = K=5 (16k tokens, email/post); long = K=15 (96k tokens, brochure/prospectus). Defaults to short."),
    register: z.string().optional().describe("Optional register hint"),
    tokenBudget: z.number().int().positive().optional(),
  },
  async ({ userId, format, topic, length, register, tokenBudget }) => {
    if (!accountId) return refuseNoAccount("voice-retrieve-conditioning");
    const resolvedUserId = userId ?? resolveUserId();
    if (!resolvedUserId) {
      // Soft (no isError) so drafting degrades, but status:error keeps this
      // distinct from genuine no-data (Task 493).
      return {
        content: [
          {
            type: "text" as const,
            text: JSON.stringify({
              styleCard: null,
              exemplars: [],
              status: "error",
              error: "no userId (no ADMIN_USER_ID in spawn env)",
            }),
          },
        ],
      };
    }
    try {
      const result = await voiceRetrieveConditioning({
        accountId,
        userId: resolvedUserId,
        brief: {
          format: format as import("./lib/voice-corpus.js").VoiceFormat,
          topic,
          length: length as "short" | "long" | undefined,
          register,
        },
        tokenBudget,
      });
      return {
        content: [
          { type: "text" as const, text: JSON.stringify(result) },
        ],
      };
    } catch (err) {
      // Graceful degradation: never an error response (no isError) so drafting
      // does not crash — but the payload carries status:error so the failure
      // is not masquerading as no-data (Task 493).
      const message = err instanceof Error ? err.message : String(err);
      process.stderr.write(`[voice-retrieve] tool error: ${message}\n`);
      return {
        content: [
          {
            type: "text" as const,
            text: JSON.stringify({
              styleCard: null,
              exemplars: [],
              status: "error",
              error: message,
            }),
          },
        ],
      };
    }
  },
);

server.tool(
  "voice-record-feedback",
  "Capture an operator's edit on an agent draft. Writes :VoiceEdit linked to the per-format :VoiceProfile via :FEEDBACK_FOR. The calling agent supplies `intent` (one sentence describing what changed and what voice preference it reveals — Task 424) and `format` (the writing format of the draft).",
  {
    userId: z.string().optional(),
    format: zFormat.describe("Writing format of the draft being edited."),
    originalText: z.string().min(1),
    editedText: z.string().min(1),
    intent: z
      .string()
      .min(1)
      .describe("One sentence summarising what changed and what voice preference it reveals — produced by the calling agent in-turn from originalText vs editedText (Task 424)."),
    skill: z.string().optional().describe("Calling skill name, for provenance"),
    brief: z.string().optional().describe("Drafting brief that produced the original"),
  },
  async ({ userId, format, originalText, editedText, intent, skill, brief }) => {
    if (!accountId) return refuseNoAccount("voice-record-feedback");
    const resolvedUserId = userId ?? resolveUserId();
    if (!resolvedUserId) {
      return {
        content: [
          {
            type: "text" as const,
            text: "voice-record-feedback: userId required (no ADMIN_USER_ID in spawn env).",
          },
        ],
        isError: true,
      };
    }
    try {
      const result = await voiceRecordFeedback({
        accountId,
        userId: resolvedUserId,
        format: format as import("./lib/voice-corpus.js").VoiceFormat,
        originalText,
        editedText,
        intent,
        context: { skill, brief },
      });
      return {
        content: [
          {
            type: "text" as const,
            text: `voice-record-feedback: editId=${result.editId} format=${format} intent="${result.intent}" hadProfile=${result.hadProfile}`,
          },
        ],
      };
    } catch (err) {
      return {
        content: [
          {
            type: "text" as const,
            text: `voice-record-feedback failed: ${err instanceof Error ? err.message : String(err)}`,
          },
        ],
        isError: true,
      };
    }
  },
);

server.tool(
  "voice-ingest-session-text",
  "Write operator turns from the current session as :Message {format:'text', source:'admin-pty', authorshipMode:'human-only'} corpus nodes. The admin agent reads its own operator turns from the live conversation context and passes them as `turns`. Deduplication via SHA256 contentHash — re-running with the same turns is a no-op. Triggers voice-distil-profile {format:'text'} after ingest (cadence guard decides whether distillation runs). Invoked on demand by the voice-mirror skill when the operator asks to capture this session's voice.",
  {
    turns: z.array(z.string()).describe("Operator turns the agent read from its own session context (one string per operator message). Slash commands, harness injections, and large paste blocks are filtered out by the tool."),
    sessionId: z.string().optional().describe("Session ID for provenance."),
    userId: z.string().optional().describe("Operator AdminUser identifier. Defaults to ADMIN_USER_ID env."),
  },
  async ({ turns, sessionId, userId }) => {
    if (!accountId) return refuseNoAccount("voice-ingest-session-text");
    const resolvedUserId = userId ?? resolveUserId();
    if (!resolvedUserId) {
      return {
        content: [
          {
            type: "text" as const,
            text: "voice-ingest-session-text: userId required (no ADMIN_USER_ID in spawn env).",
          },
        ],
        isError: true,
      };
    }
    try {
      const result = await voiceIngestSessionText({
        turns,
        accountId,
        userId: resolvedUserId,
        sessionId,
      });
      return {
        content: [
          {
            type: "text" as const,
            text: `voice-ingest-session-text: ingestedCount=${result.ingestedCount} skippedCount=${result.skippedCount} sessionId=${result.sessionId ?? "unknown"}`,
          },
        ],
      };
    } catch (err) {
      return {
        content: [
          {
            type: "text" as const,
            text: `voice-ingest-session-text failed: ${err instanceof Error ? err.message : String(err)}`,
          },
        ],
        isError: true,
      };
    }
  },
);

const transport = new StdioServerTransport();
await server.connect(transport);

process.on("SIGTERM", async () => {
  await closeDriver();
  process.exit(0);
});

// Surface AUTHORSHIP_MODES for downstream callers / docs.
export { AUTHORSHIP_MODES };
