/**
 * Group iMessage helpers for the Sendblue channel server.
 *
 * Provides:
 *   - Identifier classification (phone number vs Sendblue group_id)
 *   - Persistent registry of "seen" group_ids tied to allowed senders, used as
 *     a soft access-control gate for outbound group messages.
 *   - Per-group history append + load (keyed by group_id under
 *     data/sendblue-harness/groups/<group_id>/history.jsonl).
 */

import {
  appendFileSync,
  existsSync,
  mkdirSync,
  readFileSync,
  writeFileSync,
} from "fs";
import { dirname, join } from "path";

// ---------------------------------------------------------------------------
// Identifier classification
// ---------------------------------------------------------------------------

/**
 * Strict validator: does this string match a Sendblue group_id shape?
 *
 * Sendblue group ids in observed traffic are either:
 *   - A UUID (canonical 8-4-4-4-12 hex form), or
 *   - A `group_`-prefixed opaque identifier
 *
 * We accept ONLY those shapes (plus a conservative `[A-Za-z0-9_-]{8,64}` fall-
 * back for unknown but well-formed identifiers — logged separately by the
 * caller as a soft-degraded case). Anything containing path separators, `..`,
 * NUL bytes, whitespace, or other shell/filesystem-sensitive characters is
 * rejected. The strict form is the security boundary that prevents attacker-
 * controlled group_id values from escaping the per-group history directory
 * via path traversal in `groupDir()`.
 */
export function isGroupId(id: unknown): id is string {
  if (typeof id !== "string" || id.length === 0) {
    return false;
  }
  if (id.length > 128) {
    return false;
  }
  // Phone numbers are not group ids.
  if (id.startsWith("+")) {
    return false;
  }
  // Reject anything with filesystem-dangerous characters before any further
  // matching. This includes `/`, `\`, `..` segments, NUL, control chars, and
  // whitespace.
  if (/[/\\\x00-\x1f\s]/.test(id) || id.includes("..")) {
    return false;
  }
  // Canonical UUID v4-ish shape (8-4-4-4-12 hex).
  if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
    return true;
  }
  // `group_`-prefixed opaque id (Sendblue API variant).
  if (/^group_[A-Za-z0-9_-]{1,120}$/.test(id)) {
    return true;
  }
  // Conservative fall-back for other well-formed opaque ids. Restricted to
  // URL-safe characters and a length bound so the value remains safe to use
  // as a filesystem segment.
  if (/^[A-Za-z0-9_-]{8,64}$/.test(id)) {
    return true;
  }
  return false;
}

// ---------------------------------------------------------------------------
// Seen-group registry (soft outbound allowlist)
// ---------------------------------------------------------------------------

interface SeenGroupEntry {
  /** First time we observed this group (ISO). */
  first_seen: string;
  /** Last time we observed activity in this group (ISO). */
  last_seen: string;
  /** Phone numbers we have seen send to this group (any allowed sender qualifies). */
  participants: string[];
  /** Whether at least one allowed handle has participated (gates outbound). */
  allowed: boolean;
}

type SeenGroupMap = Record<string, SeenGroupEntry>;

let seenGroups: SeenGroupMap = {};
let seenGroupsPath: string | null = null;

export function initSeenGroups(stateDir: string): void {
  seenGroupsPath = join(stateDir, "seen-groups.json");
  try {
    if (existsSync(seenGroupsPath)) {
      const raw = readFileSync(seenGroupsPath, "utf-8");
      seenGroups = JSON.parse(raw) as SeenGroupMap;
    } else {
      seenGroups = {};
    }
  } catch {
    seenGroups = {};
  }
}

function persistSeenGroups(): void {
  if (!seenGroupsPath) {
    return;
  }
  try {
    mkdirSync(dirname(seenGroupsPath), { recursive: true, mode: 0o700 });
    writeFileSync(seenGroupsPath, JSON.stringify(seenGroups, null, 2) + "\n", {
      mode: 0o600,
    });
  } catch {
    // best effort — registry is a soft gate, not source of truth
  }
}

/**
 * Record an inbound group message. The group is marked `allowed` for outbound
 * replies ONLY when the inbound `sender` itself is on the trusted-handle
 * allowlist — we never trust the attacker-controlled `participants` field on
 * the webhook body to qualify a group. Participants are still merged into
 * the entry's `participants` list for observability, but they do not flip
 * the `allowed` flag.
 *
 * `groupId` must already have been validated by `isGroupId()` at the call
 * site; this function does NOT re-validate.
 */
export function recordGroupActivity(
  groupId: string,
  sender: string,
  participants: string[],
  isAllowedHandle: (handle: string) => boolean,
): void {
  if (!groupId) {
    return;
  }
  const now = new Date().toISOString();
  const existing = seenGroups[groupId];
  const merged = new Set(existing?.participants ?? []);
  if (sender) {
    merged.add(sender);
  }
  for (const p of participants) {
    if (p) {
      merged.add(p);
    }
  }
  // Allowlist gate: only the sender (whose number was verified against the
  // configured allowed-handles list before we got here) can promote a group
  // to `allowed`. Once a group has been promoted by a real allowed sender,
  // the bit is sticky.
  const senderAllowed = sender ? isAllowedHandle(sender) : false;
  const allowed = (existing?.allowed ?? false) || senderAllowed;
  seenGroups[groupId] = {
    first_seen: existing?.first_seen ?? now,
    last_seen: now,
    participants: Array.from(merged),
    allowed,
  };
  persistSeenGroups();
}

/**
 * Is this group_id allowed for outbound? True only if a prior inbound from
 * an allowed sender has been observed in this group.
 */
export function isGroupAllowed(groupId: string): boolean {
  if (!groupId) {
    return false;
  }
  return seenGroups[groupId]?.allowed === true;
}

// ---------------------------------------------------------------------------
// Per-group history
// ---------------------------------------------------------------------------

export interface GroupHistoryEntry {
  ts: string;
  dir: "inbound" | "outbound";
  from?: string;
  group_id: string;
  participants?: string[];
  content: string;
  handle?: string;
}

const GROUP_HISTORY_MAX = 50;

/**
 * Defense-in-depth: reject any groupId that contains characters which could
 * escape the `groups/` directory or otherwise misbehave as a filesystem
 * segment, even if `isGroupId()` was bypassed somehow. Callers SHOULD have
 * validated with `isGroupId()` already; this is the last line before `join`.
 */
function assertSafeGroupId(groupId: string): void {
  if (!isGroupId(groupId)) {
    throw new Error(
      `sendblue-channel: refusing unsafe group_id "${String(groupId).slice(0, 64)}"`,
    );
  }
}

function groupDir(harnessDir: string, groupId: string): string {
  assertSafeGroupId(groupId);
  return join(harnessDir, "groups", groupId);
}

export function ensureGroupDir(harnessDir: string, groupId: string): string {
  const dir = groupDir(harnessDir, groupId);
  mkdirSync(dir, { recursive: true });
  return dir;
}

export function appendGroupHistory(
  harnessDir: string,
  entry: GroupHistoryEntry,
): void {
  try {
    const dir = ensureGroupDir(harnessDir, entry.group_id);
    const histFile = join(dir, "history.jsonl");
    appendFileSync(histFile, JSON.stringify(entry) + "\n");
    // Rotate: keep last GROUP_HISTORY_MAX lines
    const lines = readFileSync(histFile, "utf-8").trim().split("\n").filter(Boolean);
    if (lines.length > GROUP_HISTORY_MAX) {
      writeFileSync(histFile, lines.slice(-GROUP_HISTORY_MAX).join("\n") + "\n");
    }
  } catch (err) {
    process.stderr.write(
      `sendblue-channel: group history write failed: ${err instanceof Error ? err.message : String(err)}\n`,
    );
  }
}

export function loadGroupContext(harnessDir: string, groupId: string): string {
  const dir = groupDir(harnessDir, groupId);
  const histFile = join(dir, "history.jsonl");
  if (!existsSync(histFile)) {
    return "";
  }
  try {
    const lines = readFileSync(histFile, "utf-8").trim().split("\n").filter(Boolean);
    const recent = lines.slice(-20).map((l) => {
      try {
        const e = JSON.parse(l) as GroupHistoryEntry;
        const who = e.dir === "inbound" ? (e.from ?? "unknown") : "me";
        return `[${e.ts}] ${who}: ${e.content}`;
      } catch {
        return l;
      }
    });
    if (recent.length === 0) {
      return "";
    }
    return `<group-context group_id="${groupId}">\n<history>\n${recent.join("\n")}\n</history>\n</group-context>\n\n`;
  } catch {
    return "";
  }
}
