// iMessage / SMS adapter for DewLink — your OWN phone via the Mac's Messages app (no Twilio, no creds).
// Emits the same @@DEWLINK@@ events as the other channels; each handle (phone/email) = a conversation.
//
// Receive: read ~/Library/Messages/chat.db (Apple's local Messages store) — REQUIRES the user to grant
//          DewLink "Full Disk Access" (System Settings → Privacy). We copy the db to a temp file first
//          so we never lock the live store, then poll for new messages.
// Send:    drive Messages.app via AppleScript (osascript) — REQUIRES "Automation → Messages" permission.
//          Uses the user's own iMessage/SMS, so the real number sends. Approval-gated upstream (HW#1).

import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import { execFileSync } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { emitEvent } from "./events.ts";

const HOME = process.env.HOME || "";
const CHAT_DB = path.join(HOME, "Library", "Messages", "chat.db");
// Adaptive poll: tight when messages are flowing, backs off when idle so we don't snapshot chat.db
// every 10s for nothing (each poll copies the DB + opens sqlite — real disk/CPU when repeated).
const POLL_MIN = 10000;
const POLL_MAX = 45000;
let pollInterval = POLL_MIN;
// Apple's CoreData epoch is 2001-01-01; modern macOS stores `date` in nanoseconds since then.
const APPLE_EPOCH_MS = 978307200000;

let running = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let lastRowId = 0;            // highest message ROWID we've emitted
const seen = new Set<string>();

function log(level: "info" | "warn" | "error", msg: string) {
  emitEvent({ type: "log", level, msg: `[imessage] ${msg}` });
}

function appleDateToMs(date: number | bigint): number {
  const n = Number(date);
  // nanoseconds (modern) vs seconds (legacy) since 2001.
  return n > 1e12 ? Math.round(n / 1e6) + APPLE_EPOCH_MS : n * 1000 + APPLE_EPOCH_MS;
}

/** Snapshot chat.db (+ WAL/SHM) to a temp dir so we read a consistent, unlocked copy. */
function snapshot(): string | null {
  if (!fs.existsSync(CHAT_DB)) { log("error", "chat.db not found"); return null; }
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dewlink-imsg-"));
  try {
    for (const ext of ["", "-wal", "-shm"]) {
      const src = CHAT_DB + ext;
      if (fs.existsSync(src)) fs.copyFileSync(src, path.join(dir, "chat.db" + ext));
    }
    return path.join(dir, "chat.db");
  } catch (e: any) {
    log("warn", `snapshot failed (Full Disk Access not granted?): ${e?.message}`);
    return null;
  }
}

export async function startIMessage() {
  running = true;
  emitEvent({ type: "status", state: "connecting" });
  // First read validates access; if it throws on auth, surface logged_out so the UI prompts for FDA.
  const ok = await poll(true);
  if (!ok) { emitEvent({ type: "status", state: "logged_out" }); return; }
  emitEvent({ type: "status", state: "open", user: null });
  loop();
}

function loop() {
  if (!running) return;
  timer = setTimeout(async () => {
    const before = lastRowId;
    await poll(false);
    // New message(s) → go tight; otherwise back off (×1.5, capped) to spare disk/CPU while idle.
    pollInterval = lastRowId > before ? POLL_MIN : Math.min(Math.round(pollInterval * 1.5), POLL_MAX);
    loop();
  }, pollInterval);
}

async function poll(initial: boolean): Promise<boolean> {
  const snap = snapshot();
  if (!snap) return false;
  try {
    const db = new DatabaseSync(snap, { readOnly: true } as any);
    // Recent text messages with the other party's handle (phone/email). On the first sweep we seed
    // lastRowId from the newest 200 (backfill) and don't re-emit on subsequent polls.
    const rows = db.prepare(`
      SELECT m.ROWID AS rowid, m.guid, m.text, m.date, m.is_from_me, m.service, h.id AS handle
      FROM message m LEFT JOIN handle h ON m.handle_id = h.ROWID
      WHERE m.text IS NOT NULL AND m.text != ''
        AND m.ROWID > ?
      ORDER BY m.ROWID ASC
      LIMIT 500
    `).all(initial ? 0 : lastRowId) as any[];
    db.close();
    try { fs.rmSync(path.dirname(snap), { recursive: true, force: true }); } catch { /* ignore */ }

    // On the initial sweep, keep only the most recent ~200 so we don't replay years of history.
    const slice = initial ? rows.slice(-200) : rows;
    let emitted = 0;
    for (const r of slice) {
      if (r.rowid > lastRowId) lastRowId = r.rowid;
      const handle = r.handle || "unknown";
      const guid = r.guid || `${r.rowid}`;
      if (seen.has(guid)) continue;
      seen.add(guid);
      emitEvent({
        type: "message",
        convExternalId: handle,                 // phone/email of the other party = conversation key
        convTitle: handle,
        direction: r.is_from_me ? "OUT" : "IN",
        body: r.text || "",
        sender: r.is_from_me ? null : handle,
        ts: appleDateToMs(r.date),
        providerMsgId: `im:${guid}`,
        historical: initial,
      });
      emitted++;
    }
    if (initial) log("info", `loaded ${emitted} recent message(s) from Messages`);
    return true;
  } catch (e: any) {
    log("warn", `read failed: ${e?.message}`);
    try { fs.rmSync(path.dirname(snap), { recursive: true, force: true }); } catch { /* ignore */ }
    return false;
  }
}

/** Send via Messages.app (iMessage, falling back to SMS). Drives the user's real account. */
export async function sendIMessage(toHandle: string, text: string) {
  const script = `on run argv
  set t to item 1 of argv
  set p to item 2 of argv
  tell application "Messages"
    try
      set svc to 1st service whose service type = iMessage
      send t to buddy p of svc
    on error
      try
        set svc to 1st service whose service type = SMS
        send t to buddy p of svc
      on error errMsg
        error errMsg
      end try
    end try
  end tell
end run`;
  try {
    execFileSync("osascript", ["-e", script, text, toHandle], { stdio: ["ignore", "ignore", "pipe"] });
  } catch (e: any) {
    throw new Error(`Messages send failed (grant Automation → Messages?): ${e?.message}`);
  }
}

export function stopIMessage() { running = false; if (timer) clearTimeout(timer); }
