import type { Logger } from "pino";
import {
  BOT_VERSION,
  INSTANCE_ID,
  DAILY_REPORT,
  SLACK_MENTION,
  SQUAD_SLACK_MAP,
} from "@/core/constants.js";
import type { TriageVerdict } from "@/types/index.js";
import { getSlackThread, saveSlackThread } from "@/slack/message-store.js";

type SlackBlock = Record<string, unknown>;

function squadSlackGroupId(prefix: string): string | undefined {
  const fromEnv = process.env[`SQUAD_SLACK_${prefix}`]?.trim();
  if (fromEnv && fromEnv.length > 0) return fromEnv;
  const fromMap = SQUAD_SLACK_MAP[prefix]?.trim();
  if (fromMap && fromMap.length > 0) return fromMap;
  return undefined;
}

function resolveSquadMentionFromPrefix(prefix: string): string | null {
  const groupId = squadSlackGroupId(prefix);
  if (!groupId) return null;
  return `<!subteam^${groupId}>`;
}

/** Resolves `<!subteam^…>` from a Jira issue key (e.g. SQDP-123 → prefix SQDP). */
function resolveSquadMention(issueKey: string): string | null {
  const prefix = issueKey.match(/^([A-Z]{2,10})-/)?.[1];
  if (!prefix) return null;
  return resolveSquadMentionFromPrefix(prefix);
}

function appendSquadLine(issueKey: string, text: string, sandboxMode: boolean): string {
  if (sandboxMode) return text;
  const mention = resolveSquadMention(issueKey);
  return mention ? `${text}\n${mention}` : text;
}

/**
 * Escape free-form text before interpolating it into Slack mrkdwn.
 * Slack uses `<`, `>`, `&` as control chars for links, mentions, and entities;
 * unescaped content like `<processId>` or `A & B` breaks parsing (the enclosing
 * `<url|text>` link collapses to a raw URL). See
 * https://api.slack.com/reference/surfaces/formatting#escaping
 */
function escapeSlackText(text: string): string {
  return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}

interface SlackApiResponse {
  ok: boolean;
  error?: string;
  ts?: string;
}

/** Retry a fetch when Slack returns 429, respecting the Retry-After header. */
async function fetchWithRateLimit(
  url: string,
  init?: RequestInit,
  maxRetries = 3,
): Promise<Response> {
  for (let attempt = 0; ; attempt++) {
    const response = await fetch(url, init);
    if (response.status !== 429 || attempt >= maxRetries) return response;

    const retryAfter = Number(response.headers.get("Retry-After") || "5");
    await new Promise((r) => setTimeout(r, retryAfter * 1000));
  }
}

/**
 * When set on the SlackNotifier, every outbound message (including thread
 * replies) is rerouted to `channelId` and prefixed with `prefix`. Wired in
 * dev mode (`config.isDev`) so a local run cannot post to real channels.
 */
export interface SlackDevOverride {
  channelId: string;
  prefix: string;
}

export class SlackNotifier {
  private readonly userCache = new Map<string, { name: string; fetchedAt: number }>();

  constructor(
    private readonly token: string,
    private readonly channelId: string,
    private readonly emittedBy: string,
    private readonly logger: Logger,
    private readonly sandboxMode: boolean = false,
    private readonly devOverride?: SlackDevOverride,
  ) {}

  private applyPrefix(text: string): string {
    return this.devOverride ? `${this.devOverride.prefix} ${text}` : text;
  }

  /**
   * In dev mode, prepend `[DEV]` to the first mrkdwn `text` block so the
   * Slack header line also carries the marker, not only the notification text.
   */
  private prefixBlocks(blocks: ReadonlyArray<SlackBlock>): SlackBlock[] {
    if (!this.devOverride) return [...blocks];
    const prefix = this.devOverride.prefix;
    let prefixed = false;
    return blocks.map((block) => {
      if (prefixed) return block;
      const txt = (block as { text?: { type?: string; text?: string } }).text;
      if (txt && txt.type === "mrkdwn" && typeof txt.text === "string") {
        prefixed = true;
        return { ...block, text: { ...txt, text: `${prefix} ${txt.text}` } };
      }
      return block;
    });
  }

  private footer(triggeredBy?: string): SlackBlock {
    const who = triggeredBy ?? this.emittedBy;
    return {
      type: "context",
      elements: [
        { type: "mrkdwn", text: `v${BOT_VERSION} · \`${INSTANCE_ID}\` · Emitido por: *${who}*` },
      ],
    };
  }

  async notifyTicketStarted(
    issueKey: string,
    summary: string,
    jiraUrl: string,
    triggeredBy?: string,
  ): Promise<void> {
    await this.post(
      `Inicio de ticket: ${issueKey}`,
      [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: `:rocket: *Ticket iniciado*\n*${issueKey}* - <${jiraUrl}|${escapeSlackText(summary)}>`,
          },
        },
        this.footer(triggeredBy),
      ],
      { issueKey, triggeredBy: triggeredBy ?? this.emittedBy },
    );
  }

  /** Jira @mention + `review-ticket` → implementation readiness pipeline started. */
  async notifyTicketReviewStarted(
    issueKey: string,
    summary: string,
    jiraUrl: string,
  ): Promise<void> {
    await this.post(
      `Revision de ticket: ${issueKey}`,
      [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: appendSquadLine(
              issueKey,
              `:mag: *Revision de implementacion iniciada*\n*${issueKey}* — <${jiraUrl}|${escapeSlackText(summary.slice(0, 200))}${summary.length > 200 ? "…" : ""}>`,
              this.sandboxMode,
            ),
          },
        },
        {
          type: "context",
          elements: [
            {
              type: "mrkdwn",
              text: "hu-developer-bot esta evaluando readiness del ticket (comentario con review-ticket).",
            },
            { type: "mrkdwn", text: `Emitido por: \`${this.emittedBy}\`` },
          ],
        },
      ],
      { issueKey, jiraUrl, emittedBy: this.emittedBy },
    );
  }

  async notifyTicketReviewFinished(
    issueKey: string,
    jiraUrl: string,
    verdict: TriageVerdict,
    summarySnippet: string,
  ): Promise<void> {
    const icon =
      verdict === "PROCEED" ? ":white_check_mark:" : verdict === "RUN_FAILED" ? ":x:" : ":warning:";
    const verdictLabel =
      verdict === "PROCEED"
        ? "Listo para implementar"
        : verdict === "RUN_FAILED"
          ? "RUN_FAILED — la revision automatizada no completo"
          : "No listo — falta detalle o claridad de alcance";
    const snippet =
      summarySnippet.length > 400 ? `${summarySnippet.slice(0, 400)}…` : summarySnippet;
    await this.post(
      `Revision lista: ${issueKey}`,
      [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: appendSquadLine(
              issueKey,
              `${icon} *Revision de implementacion — ${verdictLabel}*\n<${jiraUrl}|${issueKey}>`,
              this.sandboxMode,
            ),
          },
        },
        {
          type: "context",
          elements: [
            { type: "mrkdwn", text: escapeSlackText(snippet) },
            { type: "mrkdwn", text: `Emitido por: \`${this.emittedBy}\`` },
          ],
        },
      ],
      { issueKey, jiraUrl, verdict, emittedBy: this.emittedBy },
    );
  }

  async notifyTicketReviewFailed(issueKey: string, jiraUrl: string, reason: string): Promise<void> {
    const reasonSnippet = reason.length > 350 ? `${reason.slice(0, 350)}…` : reason;
    await this.post(
      `Revision fallo: ${issueKey}`,
      [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: appendSquadLine(
              issueKey,
              `:x: *Revision de implementacion fallo* — <${jiraUrl}|${issueKey}>\n>${escapeSlackText(reasonSnippet)}`,
              this.sandboxMode,
            ),
          },
        },
        {
          type: "context",
          elements: [{ type: "mrkdwn", text: `Emitido por: \`${this.emittedBy}\`` }],
        },
      ],
      { issueKey, jiraUrl, reason: reasonSnippet, emittedBy: this.emittedBy },
    );
  }

  async notifyPrCreated(
    issueKey: string,
    repoName: string,
    prUrl: string,
    prTitle: string,
    prNumber: number,
  ): Promise<void> {
    const ts = await this.post(
      `PR creada para ${issueKey} en ${repoName}`,
      [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: appendSquadLine(
              issueKey,
              `:white_check_mark: *PR creada*\n<${prUrl}|${repoName}: ${escapeSlackText(prTitle)}>\nTicket: *${issueKey}*`,
              this.sandboxMode,
            ),
          },
        },
        this.footer(),
      ],
      { issueKey, repoName, prUrl },
    );

    if (ts) {
      saveSlackThread(`${repoName}#${prNumber}`, { channelId: this.channelId, threadTs: ts });
    }
  }

  async notifyCommentAttended(
    repoName: string,
    prNumber: number,
    prUrl?: string,
    issueKey?: string,
    channelId?: string,
  ): Promise<void> {
    const prReference = prUrl ? `<${prUrl}|${repoName}#${prNumber}>` : `${repoName}#${prNumber}`;
    const sectionText = `:speech_balloon: *Comentario atendido*\n${prReference}`;
    const thread = getSlackThread(`${repoName}#${prNumber}`);
    await this.post(
      `Comentario atendido en ${repoName}#${prNumber}`,
      [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: sectionText,
          },
        },
        this.footer(),
      ],
      { repoName, prNumber, prUrl, issueKey },
      thread
        ? { threadTs: thread.threadTs, channelOverride: channelId }
        : { channelOverride: channelId },
    );
  }

  async notifyNeedsInfo(
    issueKey: string,
    jiraUrl: string,
    summary: string,
    questions?: string,
  ): Promise<void> {
    const detail = questions ? `\n>_${escapeSlackText(questions.slice(0, 500))}_` : "";
    await this.post(
      `Ticket requiere más información: ${issueKey}`,
      [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: appendSquadLine(
              issueKey,
              `:question: *Needs info: <${jiraUrl}|${issueKey}>*\n>${escapeSlackText(summary.slice(0, 300))}${detail}`,
              this.sandboxMode,
            ),
          },
        },
        this.footer(),
      ],
      { issueKey, jiraUrl },
    );
  }

  async notifyNoChangesNeeded(
    issueKey: string,
    jiraUrl: string,
    summary: string,
    reason?: string,
  ): Promise<void> {
    const reasonSnippet = reason ? `\n>${escapeSlackText(reason.slice(0, 300))}` : "";
    const body = [
      `:handshake: *No changes needed: <${jiraUrl}|${issueKey}>*`,
      `>${escapeSlackText(summary.slice(0, 300))}${reasonSnippet}`,
      `I reviewed the ticket and concluded that no code changes are required.`,
      `If this is incorrect or context is missing, please update the ticket description. Otherwise, close it.`,
    ].join("\n");
    await this.post(
      `No changes needed: ${issueKey}`,
      [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: appendSquadLine(issueKey, body, this.sandboxMode),
          },
        },
        this.footer(),
      ],
      { issueKey, jiraUrl },
    );
  }

  async notifyJobFailed(issueKey: string, jiraUrl: string, reason: string): Promise<void> {
    const reasonSnippet = reason.length > 300 ? `${reason.slice(0, 300)}…` : reason;
    await this.post(
      `Pipeline falló: ${issueKey}`,
      [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: appendSquadLine(
              issueKey,
              `:x: *Pipeline falló: <${jiraUrl}|${issueKey}>*\n>${escapeSlackText(reasonSnippet)}`,
              this.sandboxMode,
            ),
          },
        },
        this.footer(),
      ],
      { issueKey, jiraUrl, reason: reasonSnippet },
    );
  }

  /** Daily digest: all Jira tickets assigned to the bot (non-Done), grouped by team; each row shows status and linked PRs if any. */
  async notifyDailyReport(
    payload: {
      ticketsByTeam: Record<
        string,
        Array<{
          key: string;
          summary: string;
          jiraStatus: string;
          jiraUrl: string;
          prs: Array<{ url: string; title: string; repoName: string }>;
          lastStatusChangeAt: string | null;
        }>
      >;
      closedPrs?: Array<{ repo: string; number: number; url: string; title: string }>;
    },
    channelId?: string,
  ): Promise<void> {
    const { ticketsByTeam } = payload;
    const sortedTeams = [...Object.keys(ticketsByTeam)].sort();

    const flat = sortedTeams.flatMap((team) => ticketsByTeam[team] ?? []);
    const totalTickets = flat.length;
    const totalWithPr = flat.filter((t) => t.prs.length > 0).length;
    const totalWithoutPr = totalTickets - totalWithPr;

    const now = new Date();
    const dateLabel = now.toLocaleDateString("es-AR", {
      day: "numeric",
      month: "short",
      year: "numeric",
    });
    const period = now.getHours() < 14 ? "mañana" : "tarde";

    const blocks: SlackBlock[] = [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `:chart_with_upwards_trend: *Estado de la ${period} · ${dateLabel}*`,
        },
      },
    ];

    const maxSummary = 120;
    const maxTicketLines = 40;

    for (const team of sortedTeams) {
      const tickets = ticketsByTeam[team] ?? [];
      if (tickets.length === 0) continue;

      const teamHeaderLine = `*${team}* (${tickets.length})`;

      const slice = tickets.slice(0, maxTicketLines);
      const STALE_THRESHOLD_MS = DAILY_REPORT.STALE_THRESHOLD_MS;
      const lines = slice.map((t) => {
        const sum =
          t.summary.length > maxSummary ? `${t.summary.slice(0, maxSummary)}…` : t.summary;

        // Flag tickets whose status hasn't changed in >5 days.
        let staleTag = "";
        if (t.lastStatusChangeAt) {
          const elapsed = now.getTime() - new Date(t.lastStatusChangeAt).getTime();
          if (elapsed > STALE_THRESHOLD_MS) {
            const days = Math.floor(elapsed / (1000 * 60 * 60 * 24));
            staleTag =
              days === 1
                ? " :warning: _1d sin cambio de estado_"
                : ` :warning: _${days}d sin cambio de estado_`;
          }
        }

        let line = `• <${t.jiraUrl}|${t.key}> · _${escapeSlackText(t.jiraStatus)}_ · ${escapeSlackText(sum)}`;
        if (t.prs.length > 0) {
          const prPart = t.prs.map((p) => `<${p.url}|\`${p.repoName}\`>`).join(", ");
          line += ` · PR: ${prPart}`;
        }
        line += staleTag;
        return line;
      });
      const more =
        tickets.length > maxTicketLines ? `\n_…y ${tickets.length - maxTicketLines} más_` : "";

      blocks.push({
        type: "section",
        text: {
          type: "mrkdwn",
          text: `${teamHeaderLine}\n${lines.join("\n")}${more}`,
        },
      });
    }

    const { closedPrs } = payload;
    if (closedPrs && closedPrs.length > 0) {
      blocks.push({ type: "divider" });
      const maxClosedLines = 20;
      const closedSlice = closedPrs.slice(0, maxClosedLines);
      const closedLines = closedSlice.map(
        (p) => `• <${p.url}|${p.repo}#${p.number}> · ${escapeSlackText(p.title)}`,
      );
      const closedMore =
        closedPrs.length > maxClosedLines ? `\n_…y ${closedPrs.length - maxClosedLines} más_` : "";
      blocks.push({
        type: "section",
        text: {
          type: "mrkdwn",
          text: `:white_check_mark: *PRs cerradas/mergeadas desde el ultimo reporte* (${closedPrs.length})\n${closedLines.join("\n")}${closedMore}`,
        },
      });
    }

    const STALE_THRESHOLD_MS_FOOTER = DAILY_REPORT.STALE_THRESHOLD_MS;
    const staleCount = flat.filter((t) => {
      if (!t.lastStatusChangeAt) return false;
      return now.getTime() - new Date(t.lastStatusChangeAt).getTime() > STALE_THRESHOLD_MS_FOOTER;
    }).length;
    const closedCount = closedPrs?.length ?? 0;
    const closedSuffix = closedCount > 0 ? ` · PRs cerradas: ${closedCount}` : "";
    const staleSuffix = staleCount > 0 ? ` · :warning: Estancados: ${staleCount}` : "";
    blocks.push({
      type: "context",
      elements: [
        {
          type: "mrkdwn",
          text: `_Tickets: ${totalTickets} · Con PR: ${totalWithPr} · Sin PR: ${totalWithoutPr} · Equipos: ${sortedTeams.length}${closedSuffix}${staleSuffix}_`,
        },
      ],
    });

    await this.post(
      `Estado de la ${period} · ${dateLabel} · ${totalTickets} tickets`,
      blocks,
      {
        dailyReport: true,
        totalTickets,
        totalWithPr,
        totalWithoutPr,
        teams: sortedTeams.length,
      },
      { channelOverride: channelId },
    );
  }

  /** Alert when queue load is high. Posts to the given channelId (e.g. SLACK_ALERT_CHANNEL_ID). */
  async notifyHighLoad(
    currentJobKey: string | null,
    currentJobKind: string | null,
    pendingCount: number,
    pendingJobKeys: string[],
    channelId: string,
  ): Promise<void> {
    const currentLabel = currentJobKey ? `${currentJobKey} (${currentJobKind ?? "?"})` : "—";
    const pendingList =
      pendingJobKeys.length > 0 ? pendingJobKeys.map((k) => `• ${k}`).join("\n") : "—";
    await this.post(
      `Queue load high: ${pendingCount} pending`,
      [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: `:warning: *Queue load > 5*\n*Processing:* ${currentLabel}\n*Pending:* ${pendingCount}`,
          },
        },
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: `*Enqueued:*\n${pendingList}`,
          },
        },
        this.footer(),
      ],
      { currentJobKey, currentJobKind, pendingCount, pendingJobKeys },
      { channelOverride: channelId },
    );
  }

  /**
   * Lightweight auth probe: calls `auth.test` and returns true when the token
   * is valid. Returns false on any error without logging (the AuthChecker handles
   * state-transition logging).
   */
  async checkAuth(): Promise<boolean> {
    try {
      const response = await fetchWithRateLimit("https://slack.com/api/auth.test", {
        method: "POST",
        headers: { Authorization: `Bearer ${this.token}` },
      });
      if (!response.ok) return false;
      const data = (await response.json()) as { ok?: boolean };
      return data.ok === true;
    } catch {
      return false;
    }
  }

  /** List all channels the bot is a member of. */
  async listBotChannels(): Promise<Array<{ id: string; name: string }>> {
    const channels: Array<{ id: string; name: string }> = [];
    let cursor: string | undefined;

    do {
      const params = new URLSearchParams({
        types: "public_channel,private_channel",
        exclude_archived: "true",
        limit: "200",
      });
      if (cursor) params.set("cursor", cursor);

      const response = await fetchWithRateLimit(
        `https://slack.com/api/users.conversations?${params.toString()}`,
        {
          headers: { Authorization: `Bearer ${this.token}` },
        },
      );

      if (!response.ok) throw new Error(`Slack HTTP ${response.status}`);

      const data = (await response.json()) as {
        ok: boolean;
        error?: string;
        channels?: Array<{ id: string; name: string }>;
        response_metadata?: { next_cursor?: string };
      };
      if (!data.ok) throw new Error(`Slack API error: ${data.error ?? "unknown_error"}`);

      for (const ch of data.channels ?? []) {
        channels.push({ id: ch.id, name: ch.name });
      }

      cursor = data.response_metadata?.next_cursor || undefined;
    } while (cursor);

    return channels;
  }

  /** Fetch recent messages from a channel, optionally after a given timestamp. */
  async fetchChannelHistory(
    channelId: string,
    oldestTs?: string,
  ): Promise<
    Array<{
      ts: string;
      user?: string;
      text?: string;
      bot_id?: string;
      reply_count?: number;
      latest_reply?: string;
    }>
  > {
    const params = new URLSearchParams({
      channel: channelId,
      limit: "200",
    });
    if (oldestTs) params.set("oldest", oldestTs);

    const response = await fetchWithRateLimit(
      `https://slack.com/api/conversations.history?${params.toString()}`,
      {
        headers: { Authorization: `Bearer ${this.token}` },
      },
    );

    if (!response.ok) throw new Error(`Slack HTTP ${response.status}`);

    const data = (await response.json()) as {
      ok: boolean;
      error?: string;
      messages?: Array<{
        ts: string;
        user?: string;
        text?: string;
        bot_id?: string;
        subtype?: string;
        reply_count?: number;
        latest_reply?: string;
      }>;
    };
    if (!data.ok) throw new Error(`Slack API error: ${data.error ?? "unknown_error"}`);

    return (data.messages ?? []).filter((m) => !m.subtype);
  }

  /**
   * Fetch all replies in a thread (conversations.replies). Returns messages
   * sorted oldest-first. By default bot messages are filtered out; pass
   * `{ includeBots: true }` to receive them too (e.g. so the bot can see what
   * it already said in a thread when building context).
   */
  async fetchThreadReplies(
    channelId: string,
    threadTs: string,
    opts?: { includeBots?: boolean },
  ): Promise<Array<{ ts: string; user?: string; text?: string; bot_id?: string }>> {
    const raw = await this.fetchThreadRepliesRaw(channelId, threadTs);
    if (opts?.includeBots === true) return raw;
    return raw.filter((m) => !m.bot_id);
  }

  /** Check whether the bot has replied in a thread after a specific message timestamp. */
  async hasBotRepliedAfter(channelId: string, threadTs: string, afterTs: string): Promise<boolean> {
    const msgs = await this.fetchThreadRepliesRaw(channelId, threadTs);
    return msgs.some((m) => !!m.bot_id && m.ts > afterTs);
  }

  private async fetchThreadRepliesRaw(
    channelId: string,
    threadTs: string,
  ): Promise<Array<{ ts: string; user?: string; text?: string; bot_id?: string }>> {
    const params = new URLSearchParams({
      channel: channelId,
      ts: threadTs,
      limit: "100",
    });

    const response = await fetchWithRateLimit(
      `https://slack.com/api/conversations.replies?${params.toString()}`,
      {
        headers: { Authorization: `Bearer ${this.token}` },
      },
    );

    if (!response.ok) throw new Error(`Slack HTTP ${response.status}`);

    const data = (await response.json()) as {
      ok: boolean;
      error?: string;
      messages?: Array<{ ts: string; user?: string; text?: string; bot_id?: string }>;
    };
    if (!data.ok) throw new Error(`Slack API error: ${data.error ?? "unknown_error"}`);

    return data.messages ?? [];
  }

  /**
   * Resolve a list of Slack user IDs to display names via `users.info`,
   * with an in-memory TTL cache (controlled by `SLACK_MENTION.USER_CACHE_TTL_MS`).
   *
   * Name picking priority: `profile.display_name` → `profile.real_name` →
   * `name` → the raw id as fallback.
   *
   * The returned map contains every requested (deduplicated) id; on Slack
   * errors the raw id is used as the fallback name and cached so we don't
   * hammer the API on repeated bad ids.
   */
  async resolveUsers(userIds: readonly string[]): Promise<Map<string, string>> {
    if (userIds.length === 0) return new Map();
    const unique = Array.from(new Set(userIds));
    const result = new Map<string, string>();
    const now = Date.now();

    for (const id of unique) {
      const cached = this.userCache.get(id);
      if (cached && now - cached.fetchedAt < SLACK_MENTION.USER_CACHE_TTL_MS) {
        result.set(id, cached.name);
        continue;
      }

      const name = await this.resolveSingleUser(id);
      this.userCache.set(id, { name, fetchedAt: Date.now() });
      result.set(id, name);
    }

    return result;
  }

  private async resolveSingleUser(userId: string): Promise<string> {
    try {
      const params = new URLSearchParams({ user: userId });
      const response = await fetchWithRateLimit(
        `https://slack.com/api/users.info?${params.toString()}`,
        {
          headers: { Authorization: `Bearer ${this.token}` },
        },
      );

      if (!response.ok) {
        this.logger.warn(
          { userId, error: `Slack HTTP ${response.status}` },
          "slack_resolve_user_failed",
        );
        return userId;
      }

      const data = (await response.json()) as {
        ok: boolean;
        error?: string;
        user?: {
          id?: string;
          name?: string;
          profile?: {
            real_name?: string;
            display_name?: string;
            display_name_normalized?: string;
          };
        };
      };

      if (!data.ok) {
        this.logger.warn(
          { userId, error: data.error ?? "unknown_error" },
          "slack_resolve_user_failed",
        );
        return userId;
      }

      const user = data.user;
      const displayName = user?.profile?.display_name?.trim();
      if (displayName && displayName.length > 0) return displayName;
      const realName = user?.profile?.real_name?.trim();
      if (realName && realName.length > 0) return realName;
      const name = user?.name?.trim();
      if (name && name.length > 0) return name;
      return userId;
    } catch (err) {
      this.logger.warn({ userId, err }, "slack_resolve_user_failed");
      return userId;
    }
  }

  /** Reply to a message in a thread. */
  async replyInThread(channelId: string, threadTs: string, text: string): Promise<void> {
    // In dev mode, drop the thread_ts (it belongs to a different channel) and
    // post a fresh message in the dev channel; include the original target as
    // a context line so the trace is preserved.
    const targetChannel = this.devOverride?.channelId ?? channelId;
    const targetThreadTs = this.devOverride ? undefined : threadTs;
    const finalText = this.devOverride
      ? `${this.devOverride.prefix} ${text}\n\n_(orig: channel ${channelId}, thread ${threadTs})_`
      : text;

    const body: Record<string, unknown> = { channel: targetChannel, text: finalText };
    if (targetThreadTs) body.thread_ts = targetThreadTs;

    const response = await fetchWithRateLimit("https://slack.com/api/chat.postMessage", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${this.token}`,
        "Content-Type": "application/json; charset=utf-8",
      },
      body: JSON.stringify(body),
    });

    if (!response.ok) throw new Error(`Slack HTTP ${response.status}`);

    const data = (await response.json()) as SlackApiResponse;
    if (!data.ok) throw new Error(`Slack API error: ${data.error ?? "unknown_error"}`);

    this.logger.info(
      { channelId: targetChannel, threadTs: targetThreadTs, devRedirected: !!this.devOverride },
      "slack_thread_reply_sent",
    );
  }

  /**
   * Add an emoji reaction to a message. No-op in dev mode (messages are
   * redirected, so the original ts belongs to a foreign channel). Slack's
   * `already_reacted` is swallowed (idempotent); other API errors are logged
   * but never thrown — reactions are advisory.
   */
  async addReaction(channelId: string, messageTs: string, emoji: string): Promise<void> {
    await this.postReaction(
      "reactions.add",
      channelId,
      messageTs,
      emoji,
      "already_reacted",
      "slack_reaction_add_failed",
    );
  }

  /**
   * Remove an emoji reaction from a message. No-op in dev mode. Slack's
   * `no_reaction` is swallowed (idempotent); other API errors are logged but
   * never thrown.
   */
  async removeReaction(channelId: string, messageTs: string, emoji: string): Promise<void> {
    await this.postReaction(
      "reactions.remove",
      channelId,
      messageTs,
      emoji,
      "no_reaction",
      "slack_reaction_remove_failed",
    );
  }

  /**
   * Shared POST for the advisory reaction mutations. Never throws: a non-ok HTTP
   * status or a non-ok Slack response (other than the idempotent `swallowError`)
   * is logged via `warnEvent` and swallowed.
   */
  private async postReaction(
    apiMethod: "reactions.add" | "reactions.remove",
    channelId: string,
    messageTs: string,
    emoji: string,
    swallowError: string,
    warnEvent: string,
  ): Promise<void> {
    if (this.devOverride) return;
    const response = await fetchWithRateLimit(`https://slack.com/api/${apiMethod}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${this.token}`,
        "Content-Type": "application/json; charset=utf-8",
      },
      body: JSON.stringify({ channel: channelId, timestamp: messageTs, name: emoji }),
    });
    if (!response.ok) {
      this.logger.warn({ channelId, messageTs, emoji, httpStatus: response.status }, warnEvent);
      return;
    }
    const data = (await response.json()) as { ok: boolean; error?: string };
    if (!data.ok && data.error !== swallowError) {
      this.logger.warn({ channelId, messageTs, emoji, error: data.error }, warnEvent);
    }
  }

  /**
   * Read the bot-relevant reaction state of a message: which of the claimed /
   * done / failed markers are present (count > 0). On a hard HTTP/API error
   * this throws so the caller (poller) decides how to handle it.
   */
  async getBotReactions(
    channelId: string,
    messageTs: string,
    botUserId: string,
  ): Promise<{ claimed: boolean; done: boolean; failed: boolean }> {
    // In dev, messages are redirected, so the original ts belongs to a foreign
    // channel whose reactions we can't read; report "no markers" (matches the
    // dev no-op on addReaction/removeReaction).
    if (this.devOverride) return { claimed: false, done: false, failed: false };
    // full=true so the per-reaction `users` list is complete (not truncated),
    // letting us count only the bot's own markers — a human reacting with the
    // same emoji must NOT mark a mention as handled.
    const params = new URLSearchParams({ channel: channelId, timestamp: messageTs, full: "true" });
    const response = await fetchWithRateLimit(
      `https://slack.com/api/reactions.get?${params.toString()}`,
      {
        headers: { Authorization: `Bearer ${this.token}` },
      },
    );
    if (!response.ok) throw new Error(`Slack HTTP ${response.status}`);
    const data = (await response.json()) as {
      ok: boolean;
      error?: string;
      message?: { reactions?: Array<{ name: string; count: number; users?: string[] }> };
    };
    if (!data.ok) throw new Error(`Slack API error: ${data.error ?? "unknown_error"}`);
    const names = new Set(
      (data.message?.reactions ?? [])
        .filter((r) => r.users?.includes(botUserId))
        .map((r) => r.name),
    );
    return {
      claimed: names.has(SLACK_MENTION.REACTION_CLAIMED),
      done: names.has(SLACK_MENTION.REACTION_DONE),
      failed: names.has(SLACK_MENTION.REACTION_FAILED),
    };
  }

  private async post(
    text: string,
    blocks: ReadonlyArray<SlackBlock>,
    context: Record<string, unknown>,
    options?: { channelOverride?: string; threadTs?: string },
  ): Promise<string> {
    // Dev override wins over caller-provided channelOverride; thread_ts from a
    // foreign channel is dropped (Slack rejects cross-channel thread posts).
    const channel = this.devOverride?.channelId ?? options?.channelOverride ?? this.channelId;
    const finalText = this.applyPrefix(text);
    const finalBlocks = this.prefixBlocks(blocks);
    const body: Record<string, unknown> = { channel, text: finalText, blocks: finalBlocks };
    if (!this.devOverride && options?.threadTs) body.thread_ts = options.threadTs;

    const response = await fetchWithRateLimit("https://slack.com/api/chat.postMessage", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${this.token}`,
        "Content-Type": "application/json; charset=utf-8",
      },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      throw new Error(`Slack HTTP ${response.status}`);
    }

    const data = (await response.json()) as SlackApiResponse;
    if (!data.ok) {
      throw new Error(`Slack API error: ${data.error ?? "unknown_error"}`);
    }

    this.logger.info({ ...context, devRedirected: !!this.devOverride }, "slack_notification_sent");
    return data.ts ?? "";
  }
}
