/**
 * Proactive Feedback Loop (kb #125) — closes the loop DESIGN-proactive-
 * prioritization.md (kb #123) needs: suggested -> got a rating -> took it
 * into account -> became more accurate.
 *
 * Producer/consumer split with kb #123 (not built yet, design-only):
 *   THIS plugin owns the log (schema = that design's §5 `proactive_outcome`)
 *   and the two capture paths (text reply, best-effort emoji reaction).
 *   kb #123's future gate is a *reader* of `get_proactive_feedback_stats` and
 *   a *writer* of `log_proactive_action` for suppressed/deferred candidates
 *   (outcome: "not_sent") once it exists. Until then, Adolf itself is the
 *   only writer/reader: it calls `log_proactive_action` right after drafting
 *   a proactive send (same generation pass, no extra LLM call — matching the
 *   design's cost discipline) and can call `get_proactive_feedback_stats`
 *   before deciding whether a class of nudge is worth sending again.
 *
 * Storage decision (flagged explicitly, per kb #125's brief): this is NOT a
 * Hindsight bank. kb #123 needs per-class *counts and decayed rates* — a
 * tabular aggregate, not semantic recall — and Hindsight's recall/reflect
 * endpoints have no "give me accepted_count for class X" primitive; getting
 * one out would mean re-deriving a SQL-shaped answer from ranked free-text
 * memories, which is strictly worse than just keeping the rows. This plugin
 * is also NOT eligible for OpenClaw's own trusted plugin-state SQLite
 * (`api.state.openKeyedStore` throws "only available for trusted plugins in
 * this release" for any installed plugin that isn't bundled or
 * trustedOfficialInstall — verified against src/plugins/registry.ts — and
 * this plugin, like its hindsight-memory/quota-command siblings, is a local
 * bind-mounted install, neither). So: a small JSON array file via the public
 * `openclaw/plugin-sdk/json-store` helpers (atomic, 0o600), sized for
 * homelab volume (dozens/day, capped at maxRecords). If plugin-state SQLite
 * ever opens up to installed plugins, this is the one file to migrate.
 *
 * Capture paths:
 *
 * 1) TEXT (primary, robust) — `message_received` (observation-only, fires
 *    pre-agent-turn, zero marginal Kimi cost since the user's message was
 *    already going to produce a turn regardless): matches short exact
 *    replies ("+", "-"/"−", "неактуально", etc.) against the pending record
 *    correlated by `event.replyToId` (an explicit Matrix "reply to" quoting
 *    Adolf's proactive message) or, absent that, the sender's single newest
 *    still-pending record within `replyFallbackWindowMs` (never guessed if
 *    more than one candidate is pending — see resolvePendingTarget below).
 *
 * 2) EMOJI REACTION (secondary, best-effort, flagged low-confidence) — there
 *    is NO public plugin hook for inbound Matrix reactions in this OpenClaw
 *    version (checked docs/plugins/hooks.md's full hook catalog and
 *    extensions/matrix/src/matrix/monitor/reaction-events.ts directly).
 *    Reactions are handled entirely inside the bundled matrix extension: a
 *    reaction that targets a pending *approval* resolves through a private
 *    target store (extensions/matrix/src/approval-reactions.ts) a
 *    third-party plugin cannot register into; a reaction on any other
 *    message (the case that matters here — reacting to a proactive send)
 *    falls through to `core.system.enqueueSystemEvent(...)`, which queues
 *    free text ("Matrix reaction added: <emoji> by <sender> on msg <id>")
 *    to be prefixed onto the *next* prompt for that session — i.e. the
 *    model would have to read and interpret it, at whatever future turn
 *    happens to occur next, which could be a long delay and is not a
 *    deterministic capture. `openclaw/plugin-sdk/system-event-runtime`
 *    exports `peekSystemEventEntries` (read-only, non-consuming) as a public
 *    surface, so this plugin opportunistically peeks the queue in
 *    `before_prompt_build` and regex-matches that exact line format against
 *    pending records by message id — a side effect that costs nothing extra
 *    (the turn was already about to happen) and never removes/mutates the
 *    queue entry core itself will still drain normally. This is explicitly a
 *    best-effort enhancement, not the load-bearing mechanism: whether
 *    `before_prompt_build` fires before or after core's own queue drain for
 *    the *same* turn is unverified (would need a live-fire trace), so a
 *    reaction and the turn that would have surfaced it to this hook can, in
 *    the worst case, race. Text replies remain the mechanism kb #123 should
 *    trust; treat reaction-derived rows as a bonus signal only.
 */
import crypto from "node:crypto";
import path from "node:path";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { peekSystemEventEntries } from "openclaw/plugin-sdk/system-event-runtime";

const DEFAULTS = {
  enabled: true,
  maxRecords: 5000,
  ignoreAfterMs: 24 * 60 * 60 * 1000,
  replyFallbackWindowMs: 24 * 60 * 60 * 1000,
  acceptedTextPatterns: ["+", "+1"],
  dismissedTextPatterns: ["-", "−", "-1"], // hyphen-minus and Unicode minus sign (U+2212, what "−" often renders as)
  irrelevantTextPatterns: ["неактуально", "не актуально", "irrelevant", "not relevant"],
  acceptedEmoji: ["\u{1F44D}"], // 👍
  dismissedEmoji: ["\u{1F44E}"], // 👎
  irrelevantEmoji: ["\u{1F937}"], // 🤷
  statsTrailingN: 50,
};

function normalizeConfig(raw) {
  const c = raw && typeof raw === "object" ? raw : {};
  const int = (v, d, min) => (Number.isFinite(v) && v >= min ? Math.floor(v) : d);
  const strArr = (v, d) =>
    Array.isArray(v) && v.length ? v.filter((s) => typeof s === "string" && s.trim()) : d;
  return {
    enabled: c.enabled !== false,
    maxRecords: int(c.maxRecords, DEFAULTS.maxRecords, 50),
    ignoreAfterMs: int(c.ignoreAfterMs, DEFAULTS.ignoreAfterMs, 60000),
    replyFallbackWindowMs: int(c.replyFallbackWindowMs, DEFAULTS.replyFallbackWindowMs, 60000),
    acceptedTextPatterns: strArr(c.acceptedTextPatterns, DEFAULTS.acceptedTextPatterns),
    dismissedTextPatterns: strArr(c.dismissedTextPatterns, DEFAULTS.dismissedTextPatterns),
    irrelevantTextPatterns: strArr(c.irrelevantTextPatterns, DEFAULTS.irrelevantTextPatterns),
    acceptedEmoji: strArr(c.acceptedEmoji, DEFAULTS.acceptedEmoji),
    dismissedEmoji: strArr(c.dismissedEmoji, DEFAULTS.dismissedEmoji),
    irrelevantEmoji: strArr(c.irrelevantEmoji, DEFAULTS.irrelevantEmoji),
    statsTrailingN: int(c.statsTrailingN, DEFAULTS.statsTrailingN, 5),
  };
}

// --- log file -----------------------------------------------------------

function logFilePath() {
  // Writable adolf-state volume (/home/node/.openclaw), NOT the read-only
  // bind-mounted plugin source dir — see docker-compose.yml's adolf.volumes.
  return path.join(resolveStateDir(), "plugins", "feedback-loop", "proactive-feedback.json");
}

// Tiny in-process sequential lock so overlapping hook/tool invocations
// (message_sent racing a text reply racing a reaction peek) always
// read-modify-write the log file one at a time instead of clobbering each
// other's writes. File-level, not cross-process — fine for a single Adolf
// gateway process owning one log file.
let chain = Promise.resolve();
function withLogLock(fn) {
  const run = chain.then(fn, fn);
  chain = run.then(
    () => undefined,
    () => undefined,
  );
  return run;
}

async function loadRecordsRaw() {
  const { value } = await readJsonFileWithFallback(logFilePath(), { records: [] });
  return Array.isArray(value?.records) ? value.records : [];
}

async function saveRecordsRaw(records) {
  await writeJsonFileAtomically(logFilePath(), { records });
}

// Settle stale pending (outcome == null, sent, no response) rows to
// "ignored" — the design's required distinction from an explicit "-"
// (dismissed): an ignored item is a weaker negative signal and should not
// decay the acceptance rate as aggressively as an explicit rejection.
function settleStale(records, cfg, nowMs) {
  let changed = false;
  for (const r of records) {
    if (r.outcome == null && r.sent !== false) {
      const sentAtMs = Date.parse(r.sent_at);
      if (Number.isFinite(sentAtMs) && nowMs - sentAtMs >= cfg.ignoreAfterMs) {
        r.outcome = "ignored";
        changed = true;
      }
    }
  }
  return changed;
}

function pruneToCap(records, cap) {
  if (records.length <= cap) return records;
  return records.slice(records.length - cap);
}

async function withRecords(cfg, mutate) {
  return withLogLock(async () => {
    const records = await loadRecordsRaw();
    const changedByStale = settleStale(records, cfg, Date.now());
    const result = await mutate(records);
    const pruned = pruneToCap(records, cfg.maxRecords);
    if (changedByStale || pruned !== records || result?.dirty) {
      await saveRecordsRaw(pruned);
    }
    return result?.value;
  });
}

// --- feedback text/emoji matching ---------------------------------------

function classifyText(text, cfg) {
  const t = (text ?? "").trim();
  if (!t) return null;
  const lower = t.toLowerCase();
  if (cfg.acceptedTextPatterns.some((p) => lower === p.toLowerCase())) return "accepted";
  if (cfg.dismissedTextPatterns.some((p) => lower === p.toLowerCase())) return "dismissed";
  if (cfg.irrelevantTextPatterns.some((p) => lower === p.toLowerCase())) return "irrelevant";
  return null;
}

function classifyEmoji(emoji, cfg) {
  if (!emoji) return null;
  if (cfg.acceptedEmoji.includes(emoji)) return "accepted";
  if (cfg.dismissedEmoji.includes(emoji)) return "dismissed";
  if (cfg.irrelevantEmoji.includes(emoji)) return "irrelevant";
  return null;
}

// Find the record a feedback event should attach to. Prefers an explicit
// reply-to match (deterministic); falls back to "the sender's one and only
// still-pending record in the window" and refuses to guess when more than
// one candidate exists, per the design's "never guess" discipline (kb#153
// applies the same rule to bank resolution; feedback attribution is the
// same shape of problem).
function resolvePendingTarget(records, { messageIds, senderId, nowMs, windowMs }) {
  for (const messageId of messageIds || []) {
    if (!messageId) continue;
    const byId = records.find((r) => r.message_id === messageId && r.outcome == null);
    if (byId) return byId;
  }
  if (!senderId) return null;
  const candidates = records.filter((r) => {
    if (r.outcome != null) return false;
    if (r.sender_id && r.sender_id !== senderId) return false;
    const sentAtMs = Date.parse(r.sent_at);
    return Number.isFinite(sentAtMs) && nowMs - sentAtMs <= windowMs;
  });
  return candidates.length === 1 ? candidates[0] : null;
}

const REACTION_LINE_RE = /^Matrix reaction added: (.+) by (.+) on msg (\S+)$/;

function extractReactionsFromSystemEvents(entries) {
  const out = [];
  for (const e of entries) {
    const text = typeof e?.text === "string" ? e.text : "";
    const m = REACTION_LINE_RE.exec(text.trim());
    if (m) out.push({ emoji: m[1].trim(), sender: m[2].trim(), eventId: m[3].trim() });
  }
  return out;
}

// --- stats ---------------------------------------------------------------

function laplaceRate(accepted, total) {
  return (accepted + 1) / (total + 2);
}

function computeStats(records, statsTrailingN) {
  const byClass = new Map();
  for (const r of records) {
    if (!r.action_class) continue;
    if (!byClass.has(r.action_class)) byClass.set(r.action_class, []);
    byClass.get(r.action_class).push(r);
  }
  const out = [];
  for (const [action_class, rows] of byClass) {
    // Recency-weighted: trailing N most recent settled (non-pending,
    // non-not_sent) rows, per DESIGN-proactive-prioritization.md §3.3.
    const settled = rows
      .filter((r) => r.outcome && r.outcome !== "not_sent")
      .sort((a, b) => Date.parse(b.sent_at) - Date.parse(a.sent_at))
      .slice(0, statsTrailingN);
    const counts = { accepted: 0, dismissed: 0, ignored: 0, irrelevant: 0 };
    for (const r of settled) {
      if (counts[r.outcome] != null) counts[r.outcome] += 1;
    }
    const total = settled.length;
    out.push({
      action_class,
      total_settled: total,
      total_all_time: rows.length,
      pending: rows.filter((r) => r.outcome == null).length,
      not_sent: rows.filter((r) => r.outcome === "not_sent").length,
      ...counts,
      accept_prob: laplaceRate(counts.accepted, total),
    });
  }
  out.sort((a, b) => a.action_class.localeCompare(b.action_class));
  return out;
}

// ---------------------------------------------------------------------------

export default definePluginEntry({
  id: "feedback-loop",
  name: "Proactive Feedback Loop",
  description:
    "Logs proactive sends and their outcomes (kb #125), captures +/-/неактуально replies and best-effort emoji reactions, and exposes per-class acceptance-rate stats for kb #123's prioritization gate.",
  register(api) {
    const cfg = normalizeConfig(api.pluginConfig);
    if (!cfg.enabled) return;

    // 1) TOOL — record a proactive send (or a suppressed/deferred
    // candidate the future kb#123 gate decided NOT to send). Called in the
    // same generation pass Adolf drafts the candidate in, matching the
    // design's "no separate LLM call" cost constraint.
    api.registerTool(
      (toolCtx) => ({
        name: "log_proactive_action",
        label: "Log Proactive Action",
        description:
          "Record a proactive action for feedback tracking (kb #125). Call this right when you decide to send (or suppress/defer) a proactive nudge/reminder/digest item — pass the same action_class/benefit/urgency/cost you used to decide, so kb #123's gate can later learn from the outcome. Do not call this for ordinary replies to a direct user question.",
        parameters: {
          type: "object",
          additionalProperties: false,
          properties: {
            action_class: {
              type: "string",
              description:
                "Coarse category, e.g. calendar_reminder, task_overdue, ha_anomaly, family_wiki_gap, digest_item. One row is kept per exact class, not per message text.",
            },
            sent: {
              type: "boolean",
              description:
                "true if the message was actually sent to the user just now; false if this candidate was suppressed/deferred instead (logs outcome: not_sent immediately, no feedback expected).",
            },
            benefit_band: {
              type: "number",
              description: "Optional: the benefit(a) value used at send time (0/0.15/0.4/0.7/1.0 band).",
            },
            cost_tokens: {
              type: "integer",
              description: "Optional: estimated or actual marginal token cost of this send.",
            },
            urgency_at_send: {
              type: "number",
              description: "Optional: the urgency(a) value (0-1) used at send time.",
            },
            note: {
              type: "string",
              description: "Optional short free-text snippet of the candidate, for audit only (not scored).",
            },
          },
          required: ["action_class", "sent"],
        },
        execute: async (_toolCallId, params) => {
          const actionClass = String(params?.action_class || "").trim();
          if (!actionClass) {
            return {
              content: [{ type: "text", text: "log_proactive_action: action_class is required." }],
              details: { ok: false },
            };
          }
          const sent = params?.sent !== false;
          const id = crypto.randomUUID();
          const record = {
            id,
            action_class: actionClass,
            sent_at: new Date().toISOString(),
            sent,
            benefit_band: Number.isFinite(params?.benefit_band) ? params.benefit_band : null,
            cost_tokens: Number.isFinite(params?.cost_tokens) ? Math.floor(params.cost_tokens) : null,
            urgency_at_send: Number.isFinite(params?.urgency_at_send) ? params.urgency_at_send : null,
            note: typeof params?.note === "string" ? params.note.slice(0, 300) : null,
            outcome: sent ? null : "not_sent",
            responded_at: null,
            response_kind: null,
            message_id: null,
            // sessionKey lets the message_sent hook below attach the
            // resulting outbound message id to THIS record without a
            // second tool round-trip; sender_id lets text/reaction
            // attribution scope to the right human (kb#153-style
            // discipline, lower stakes here but kept consistent).
            session_key: toolCtx?.sessionKey || null,
            sender_id: toolCtx?.requesterSenderId || null,
          };
          await withRecords(cfg, (records) => {
            records.push(record);
            return { dirty: true };
          });
          return {
            content: [{ type: "text", text: `Logged proactive action ${id} (${actionClass}, sent=${sent}).` }],
            details: { ok: true, id },
          };
        },
      }),
      { name: "log_proactive_action" },
    );

    // 2) TOOL — read back per-class acceptance stats. Usable today by
    // Adolf itself (no kb#123 gate exists yet) to self-moderate proactive
    // sends, and by kb#123's gate once built.
    api.registerTool(
      {
        name: "get_proactive_feedback_stats",
        label: "Get Proactive Feedback Stats",
        description:
          "Read Laplace-smoothed per-class acceptance rates from the proactive-action feedback log (kb #125), trailing-window recency-weighted per kb #123 §3.3. Use before sending a proactive nudge of a class that has a history of being dismissed/ignored.",
        parameters: { type: "object", additionalProperties: false, properties: {} },
        execute: async () => {
          const stats = await withRecords(cfg, (records) => ({
            dirty: false,
            value: computeStats(records, cfg.statsTrailingN),
          }));
          return { content: [{ type: "text", text: JSON.stringify(stats, null, 2) }], details: { ok: true, stats } };
        },
      },
      { name: "get_proactive_feedback_stats" },
    );

    // 3) HOOK — message_sent: attach the outbound message id to the most
    // recent still-open record from this same turn's session, so a later
    // reply-to or reaction can find it. Best-effort correlation by
    // sessionKey (message_sent does not carry runId reliably — see
    // PluginHookMessageContext's doc comment in hook-message.types.ts);
    // assumes at most one proactive send per turn, a known v1 limitation.
    api.on("message_sent", async (event) => {
      if (!event?.success || !event?.messageId || !event?.sessionKey) return;
      await withRecords(cfg, (records) => {
        for (let i = records.length - 1; i >= 0; i--) {
          const r = records[i];
          if (r.session_key === event.sessionKey && r.outcome == null && !r.message_id) {
            r.message_id = event.messageId;
            return { dirty: true };
          }
        }
        return { dirty: false };
      });
    });

    // 4) HOOK — message_received: the primary, deterministic feedback
    // capture path. Observation-only (never blocks/rewrites the turn), so
    // this never changes normal chat behavior and never spends an extra
    // Kimi call — the user's message was already going to produce a turn.
    api.on("message_received", async (event) => {
      // Classify only the inbound message's OWN text — replyToBody (when
      // present) is Adolf's original proactive message being quoted, not
      // the user's feedback.
      const feedbackKind = classifyText(event?.content, cfg);
      if (!feedbackKind) return;
      await withRecords(cfg, (records) => {
        const target = resolvePendingTarget(records, {
          // Try both id forms — Matrix inbound reply metadata may carry a
          // normalized replyToId and/or the full event id, and message_sent
          // above only ever stores whatever `messageId` that hook received.
          messageIds: [event?.replyToId, event?.replyToIdFull],
          senderId: event?.senderId,
          nowMs: Date.now(),
          windowMs: cfg.replyFallbackWindowMs,
        });
        if (!target) return { dirty: false };
        target.outcome = feedbackKind;
        target.responded_at = new Date().toISOString();
        target.response_kind = "text";
        return { dirty: true };
      });
    });

    // 5) HOOK — before_prompt_build: best-effort emoji-reaction peek (see
    // the file-header note on why this is secondary/unverified-timing, not
    // the load-bearing path). Pure side effect: returns nothing, never
    // mutates the prompt, so no allowPromptInjection/allowConversationAccess
    // opt-in is needed for this plugin.
    api.on("before_prompt_build", async (_event, ctx) => {
      if (!ctx?.sessionKey) return;
      let entries;
      try {
        entries = peekSystemEventEntries(ctx.sessionKey);
      } catch {
        return; // best-effort only; never fail a turn over this
      }
      const reactions = extractReactionsFromSystemEvents(entries || []);
      if (reactions.length === 0) return;
      await withRecords(cfg, (records) => {
        let dirty = false;
        for (const { emoji, eventId } of reactions) {
          const outcome = classifyEmoji(emoji, cfg);
          if (!outcome) continue;
          const target = records.find((r) => r.message_id === eventId && r.outcome == null);
          if (!target) continue;
          target.outcome = outcome;
          target.responded_at = new Date().toISOString();
          target.response_kind = "reaction";
          dirty = true;
        }
        return { dirty };
      });
      // No return value: this hook only observes, never mutates the prompt.
    });
  },
});
