import { getSelfIdSync, withSelfIdentity } from '../self-identity.ts';
﻿// ============================================================================
// lib/triage-core/mental-model/metadata-recompute.ts
//
// TASK-2D: Metadata Recompute (Full Span)
//
// Recomputes metadata signals from the FULL chat.db message span for all
// contacts, including text extracted from attributedBody blobs.
//
// Computes 7 signals per contact:
//   1. totalMessages, sentCount, receivedCount
//   2. avgMessageLength (sent + received)
//   3. emojiRate (messages containing emoji / total)
//   4. responseLatencyMedian (time between their msg and your reply)
//   5. initiativeRatio (who starts conversations more)
//   6. personalHoursRatio (msgs outside 9-5 weekday)
//   7. lateNightRatio (msgs between 10pm-6am)
//
// Stores as metadataSignalsV2 JSON on undirected KNOWS edges.
// ============================================================================

import { createRequire } from 'module';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { extractTextFromAttributedBody } from '../../../extensions/imessage-triage/imessage-bridge.ts';

// @ts-ignore — import.meta used in ESM context; tsc node16 treats .ts as CJS but runtime is ESM
const require = createRequire(import.meta.url);
import { median } from './utils.ts';

// ── Types ─────────────────────────────────────────────────────────────────────

interface MessageRow {
  date: number; // Mac epoch (nanoseconds since 2001-01-01)
  is_from_me: number; // 0 = received, 1 = sent
  text: string | null;
  attributedBody: Buffer | null;
}

interface MetadataSignalsV2 {
  totalMessages: number;
  sentCount: number;
  receivedCount: number;
  avgMessageLength: number;
  emojiRate: number;
  responseLatencyMedian: number; // hours
  initiativeRatio: number; // 0-1 (0=you always start, 1=they always start)
  personalHoursRatio: number; // 0-1 (outside 9-5 weekday)
  lateNightRatio: number; // 0-1 (10pm-6am)
  computedAt: string;
}

// ── Mac Epoch Conversion ──────────────────────────────────────────────────────

/**
 * Convert Mac epoch (nanoseconds since 2001-01-01) to JS timestamp (ms since Unix epoch).
 * Formula: (macNanos / 1e9 + 978307200) * 1000
 */
function macEpochToMs(macDate: number): number {
  return (macDate / 1e9 + 978307200) * 1000;
}

// ── Emoji Detection ───────────────────────────────────────────────────────────

const EMOJI_RE = /\p{Emoji_Presentation}|\p{Extended_Pictographic}/gu;

function containsEmoji(text: string): boolean {
  EMOJI_RE.lastIndex = 0;
  return EMOJI_RE.test(text);
}

// ── Extract Full Text (text + attributedBody) ─────────────────────────────────

function extractFullText(msg: MessageRow): string {
  let text = msg.text || '';

  // If attributedBody exists, try to extract text from it
  if (msg.attributedBody) {
    const extracted = extractTextFromAttributedBody(msg.attributedBody);
    if (extracted && extracted.trim().length > 0) {
      // Use attributedBody text if it's longer/richer than plain text
      if (extracted.length > text.length) {
        text = extracted;
      }
    }
  }

  return text;
}

// ── Compute Signals ───────────────────────────────────────────────────────────

const MAX_RESPONSE_GAP_MS = 24 * 3600_000; // 24 hours


/** @public consumed by lib/triage-core/__tests__/p9-enrichment-throttle.test.ts through a door Knip does not follow (CommonJS namespace require, require() from TypeScript, or a compiled twin) — verified 2026-09-07 */
function computeSignalsV2(messages: MessageRow[]): MetadataSignalsV2 {
  if (messages.length === 0) {
    return {
      totalMessages: 0,
      sentCount: 0,
      receivedCount: 0,
      avgMessageLength: 0,
      emojiRate: 0,
      responseLatencyMedian: 0,
      initiativeRatio: 0.5,
      personalHoursRatio: 0,
      lateNightRatio: 0,
      computedAt: new Date().toISOString(),
    };
  }

  // Sort by date ascending
  const sorted = [...messages].sort((a, b) => a.date - b.date);

  // Basic counts
  const totalMessages = sorted.length;
  const sentCount = sorted.filter(m => m.is_from_me === 1).length;
  const receivedCount = sorted.filter(m => m.is_from_me === 0).length;

  // Message length and emoji rate
  let totalLength = 0;
  let messagesWithEmoji = 0;

  for (const msg of sorted) {
    const text = extractFullText(msg);
    totalLength += text.length;
    if (containsEmoji(text)) {
      messagesWithEmoji++;
    }
  }

  const avgMessageLength = totalMessages > 0 ? totalLength / totalMessages : 0;
  const emojiRate = totalMessages > 0 ? messagesWithEmoji / totalMessages : 0;

  // Response latency (time between their message and your reply)
  const yourReplies: number[] = [];
  let lastFromThem: number | null = null;

  for (const msg of sorted) {
    const tsMs = macEpochToMs(msg.date);

    if (msg.is_from_me === 1) {
      // Your outgoing message
      if (lastFromThem !== null) {
        const gapMs = tsMs - lastFromThem;
        if (gapMs > 0 && gapMs <= MAX_RESPONSE_GAP_MS) {
          yourReplies.push(gapMs / 3600_000); // convert to hours
        }
      }
      lastFromThem = null; // Reset after you reply
    } else {
      // Their incoming message
      lastFromThem = tsMs;
    }
  }

  const responseLatencyMedian = median(yourReplies);

  // Initiative ratio (who starts conversations)
  // Define session gap as 4 hours
  const SESSION_GAP_MS = 4 * 3600_000;
  let sessionsStartedByThem = 0;
  let sessionsStartedByYou = 0;
  let prevTsMs = -Infinity;
  let firstInSession = true;

  for (const msg of sorted) {
    const tsMs = macEpochToMs(msg.date);
    const isNewSession = prevTsMs < 0 || tsMs - prevTsMs > SESSION_GAP_MS;

    if (isNewSession) {
      firstInSession = true;
    }

    if (firstInSession) {
      if (msg.is_from_me === 1) {
        sessionsStartedByYou++;
      } else {
        sessionsStartedByThem++;
      }
      firstInSession = false;
    }

    prevTsMs = tsMs;
  }

  const totalSessions = sessionsStartedByYou + sessionsStartedByThem;
  // initiativeRatio: 0 = you initiate all conversations, 1 = they initiate all
  const initiativeRatio = totalSessions > 0
    ? sessionsStartedByThem / totalSessions
    : 0.5;

  // Time patterns: personal hours (outside 9-5 weekday) and late night (10pm-6am)
  let personalHoursCount = 0;
  let lateNightCount = 0;

  for (const msg of sorted) {
    const d = new Date(macEpochToMs(msg.date));
    const hour = d.getHours(); // Local time
    const day = d.getDay(); // 0=Sunday, 6=Saturday

    // Personal hours: before 9am, after 5pm, or weekend
    const isWeekend = day === 0 || day === 6;
    const isOutsideWorkHours = hour < 9 || hour >= 17;
    if (isWeekend || isOutsideWorkHours) {
      personalHoursCount++;
    }

    // Late night: 10pm (22) to 6am
    if (hour >= 22 || hour < 6) {
      lateNightCount++;
    }
  }

  const personalHoursRatio = totalMessages > 0 ? personalHoursCount / totalMessages : 0;
  const lateNightRatio = totalMessages > 0 ? lateNightCount / totalMessages : 0;

  return {
    totalMessages,
    sentCount,
    receivedCount,
    avgMessageLength,
    emojiRate,
    responseLatencyMedian,
    initiativeRatio,
    personalHoursRatio,
    lateNightRatio,
    computedAt: new Date().toISOString(),
  };
}

// ── SQLite Reader ─────────────────────────────────────────────────────────────

function getSqliteReader() {
  const Database = require('better-sqlite3');
  const chatDbPath = join(homedir(), 'Library', 'Messages', 'chat.db');
  let db: any;

  try {
    db = new Database(chatDbPath, { readonly: true });
  } catch (err: any) {
    throw new Error(
      `Cannot open chat.db at ${chatDbPath} — ensure Full Disk Access is granted: ${err.message}`,
    );
  }

  // Query ALL messages for a handle, including attributedBody
  const stmt = db.prepare(
    `SELECT m.date, m.is_from_me, m.text, m.attributedBody
     FROM message m
     JOIN handle h ON m.handle_id = h.ROWID
     WHERE h.id = ?
     ORDER BY m.date ASC`,
  );

  return {
    db,
    readMessages: (handle: string): MessageRow[] => {
      try {
        return stmt.all(handle) as MessageRow[];
      } catch (err: any) {
        process.stderr.write(`[metadata-recompute] SQLite query error for ${handle}: ${err.message}\n`);
        return [];
      }
    },
  };
}

// ── Batch Store ───────────────────────────────────────────────────────────────

async function batchStoreSignalsV2(
  signalsMap: Map<string, MetadataSignalsV2>,
  mePersonId: string,
): Promise<{ stored: number; errors: number }> {
  const { rawWrite } = require('../../graph/graph-transport.js');

  let stored = 0;
  let errors = 0;
  const entries = Array.from(signalsMap.entries());

  // Batch in chunks of 100
  for (let i = 0; i < entries.length; i += 100) {
    const chunk = entries.slice(i, i + 100);
    const items = chunk.map(([personId, signals]) => ({
      personId,
      signalsJson: JSON.stringify(signals),
      updatedAt: signals.computedAt,
    }));

    try {
      // CRITICAL: Use undirected KNOWS pattern: -[k:KNOWS]-
      await rawWrite(
        `UNWIND $items AS item
         MATCH (me:Person {id: $meId})-[k:KNOWS]-(p:Person {id: item.personId})
         SET k.metadataSignalsV2 = item.signalsJson,
             k.metadataSignalsV2UpdatedAt = item.updatedAt`,
        { meId: mePersonId, items },
      );
      stored += chunk.length;
      process.stderr.write(`[metadata-recompute] Stored batch ${Math.floor(i / 100) + 1}: ${chunk.length} items\n`);
    } catch (err: unknown) {
      const msg = err instanceof Error ? err.message : String(err);
      process.stderr.write(`[metadata-recompute] Batch store error: ${msg}\n`);
      errors += chunk.length;
    }
  }

  return { stored, errors };
}

// ── Main Runner ───────────────────────────────────────────────────────────────


async function main() {
  return withSelfIdentity({}, async () => {
  const startMs = Date.now();

  // Initialize graph reader
  const { rawRead } = require('../../graph/graph-transport.js');

  // Initialize SQLite reader
  const { db, readMessages } = getSqliteReader();

  try {
    // 1. Fetch all persons with iMessage handles
    process.stderr.write('[metadata-recompute] Fetching persons with iMessage handles...\n');
    const rows = await rawRead(
      `MATCH (p:Person)-[:HAS_IDENTITY]->(i:Identity)
       WHERE p.id <> $meId AND i.platform IN ['imessage', 'email', 'sms']
       RETURN p.id AS personId, i.handle AS handle
       ORDER BY p.id`,
      { meId: getSelfIdSync() },
    );

    const items = rows
      .map((r: any) => ({
        personId: String(r.personId ?? ''),
        handle: String(r.handle ?? '')
      }))
      .filter((r: any) => r.personId && r.handle);

    // Deduplicate by personId
    const seen = new Set<string>();
    const uniqueItems = items.filter((item: any) => {
      if (seen.has(item.personId)) return false;
      seen.add(item.personId);
      return true;
    });

    process.stderr.write(`[metadata-recompute] Found ${uniqueItems.length} unique persons\n`);

    // 2. Compute signals for each person
    let computed = 0;
    let errors = 0;
    const signalsMap = new Map<string, MetadataSignalsV2>();

    for (const { personId, handle } of uniqueItems) {
      try {
        const messages = readMessages(handle);
        const signals = computeSignalsV2(messages);
        signalsMap.set(personId, signals);
        computed++;

        if (computed % 50 === 0) {
          process.stderr.write(`[metadata-recompute] Progress: ${computed}/${uniqueItems.length}\n`);
        }
      } catch (err: any) {
        process.stderr.write(`[metadata-recompute] Error for ${personId}: ${err?.message}\n`);
        errors++;
      }
    }

    process.stderr.write(`[metadata-recompute] Computed ${computed} signals, storing...\n`);

    // 3. Batch-store all signals
    const storeResult = await batchStoreSignalsV2(signalsMap, getSelfIdSync());

    const elapsed = Date.now() - startMs;

    const result = {
      computed,
      stored: storeResult.stored,
      errors: errors + storeResult.errors,
      elapsed,
    };

    process.stderr.write(
      `[metadata-recompute] Complete — ${result.stored} stored, ${result.errors} errors, ${(elapsed / 1000).toFixed(1)}s\n`,
    );

    console.log(JSON.stringify(result, null, 2));
  } finally {
    db.close();
  }
  });
}

// ── Entry Point ───────────────────────────────────────────────────────────────

const isMain = process.argv[1]?.endsWith('metadata-recompute.ts')
  || process.argv[1]?.endsWith('metadata-recompute.js');

if (isMain) {
  main()
    .then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); })
    .catch((err) => {
      process.stderr.write(`[metadata-recompute] Fatal: ${err.message}\n${err.stack}\n`);
      process.exit(1);
    });
}

export { computeSignalsV2 };
