/**
 * Pulse & Cron Scheduler
 * Runs inside the supervisor, checks timing every 60 seconds,
 * and fires startBlobyAgentQuery for autonomous agent actions.
 */

import fs from 'fs';
import path from 'path';
import { CronExpressionParser } from 'cron-parser';
import { WORKSPACE_DIR } from '../shared/paths.js';
import { log } from '../shared/logger.js';
import { startBlobyAgentQuery } from './bloby-agent.js';

const PULSE_FILE = path.join(WORKSPACE_DIR, 'PULSE.json');
const CRONS_FILE = path.join(WORKSPACE_DIR, 'CRONS.json');

interface PulseConfig {
  enabled: boolean;
  intervalMinutes: number;
  quietHours: { start: string; end: string };
}

interface CronConfig {
  id: string;
  schedule: string;
  task: string;
  enabled: boolean;
  oneShot?: boolean;
  paused?: boolean; // user-controlled via /api/crons/pause; scheduler skips when true.
}

interface SchedulerOpts {
  broadcastBloby: (type: string, data: any) => void;
  workerApi: (path: string, method?: string, body?: any) => Promise<any>;
  restartBackend: () => void;
  getModel: () => string;
  /** Fired after a pulse/cron turn ends — the supervisor uses it to flush a queued self-update. */
  onTurnComplete?: () => void;
}

// State
let lastPulseTime = 0;
const lastCronRuns = new Map<string, number>();
let intervalHandle: ReturnType<typeof setInterval> | null = null;
let schedulerOpts: SchedulerOpts | null = null;

export function readPulseConfig(): PulseConfig {
  try {
    const raw = fs.readFileSync(PULSE_FILE, 'utf-8');
    const parsed = JSON.parse(raw);
    return {
      enabled: !!parsed.enabled,
      intervalMinutes: parsed.intervalMinutes || 30,
      quietHours: parsed.quietHours || { start: '23:00', end: '07:00' },
    };
  } catch {
    return { enabled: false, intervalMinutes: 30, quietHours: { start: '23:00', end: '07:00' } };
  }
}

export function readCronsConfig(): CronConfig[] {
  try {
    const raw = fs.readFileSync(CRONS_FILE, 'utf-8');
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

/** Write crons back to disk (for oneShot cleanup) */
function writeCronsConfig(crons: CronConfig[]) {
  try {
    fs.writeFileSync(CRONS_FILE, JSON.stringify(crons, null, 2) + '\n', 'utf-8');
  } catch (err: any) {
    log.warn(`[scheduler] Failed to write CRONS.json: ${err.message}`);
  }
}

function isInQuietHours(quietHours: { start: string; end: string }): boolean {
  const now = new Date();
  const [startH, startM] = quietHours.start.split(':').map(Number);
  const [endH, endM] = quietHours.end.split(':').map(Number);
  const currentMinutes = now.getHours() * 60 + now.getMinutes();
  const startMinutes = startH * 60 + startM;
  const endMinutes = endH * 60 + endM;

  if (startMinutes <= endMinutes) {
    return currentMinutes >= startMinutes && currentMinutes < endMinutes;
  } else {
    return currentMinutes >= startMinutes || currentMinutes < endMinutes;
  }
}

/** Check if a cron schedule has no future occurrences (expired one-shot) */
function cronIsExpired(schedule: string): boolean {
  try {
    const interval = CronExpressionParser.parse(schedule);
    interval.next(); // throws if no future occurrence
    return false;
  } catch {
    return true;
  }
}

function cronMatchesNow(schedule: string): boolean {
  try {
    const interval = CronExpressionParser.parse(schedule);
    const prev = interval.prev().toDate();
    const now = new Date();
    return (
      prev.getFullYear() === now.getFullYear() &&
      prev.getMonth() === now.getMonth() &&
      prev.getDate() === now.getDate() &&
      prev.getHours() === now.getHours() &&
      prev.getMinutes() === now.getMinutes()
    );
  } catch {
    return false;
  }
}

function triggerAgent(prompt: string, label: string, onComplete?: () => void) {
  if (!schedulerOpts) return;
  const { broadcastBloby, workerApi, restartBackend, getModel, onTurnComplete } = schedulerOpts;
  const timestamp = Date.now();
  const convId = label.startsWith('pulse') ? `pulse-${timestamp}` : `cron-${label}-${timestamp}`;
  const model = getModel();

  log.info(`[scheduler] ${label} triggered — starting agent query`);

  (async () => {
    // Use the user's current conversation (or create one)
    let dbConvId: string | undefined;
    try {
      const ctx = await workerApi('/api/context/current');
      if (ctx.conversationId) {
        dbConvId = ctx.conversationId;
      } else {
        const conv = await workerApi('/api/conversations', 'POST', {
          title: `Chat`,
          model,
        });
        dbConvId = conv.id;
        await workerApi('/api/context/set', 'POST', { conversationId: dbConvId });
      }
    } catch (err: any) {
      log.warn(`[scheduler] Failed to get/create conversation for ${label}: ${err.message}`);
    }

    // Fetch bot name for push notification title
    let botName = 'Bloby';
    try {
      const status = await workerApi('/api/onboard/status');
      if (status.agentName) botName = status.agentName;
    } catch {}

    let fullResponse = '';

    startBlobyAgentQuery(convId, prompt, model, (type, eventData) => {
      if (type === 'bot:response') {
        fullResponse = eventData.content || '';
      }

      if (type === 'bot:done') {
        // ── Mac channel push (server-initiated) ──
        // When the `mac` skill is installed, it instructs the agent to wrap any
        // Mac-bound pulse/cron output in <mac_push>…</mac_push> (a spoken line +
        // an optional <notch_card>/<notch_html>). Forward each block's inner
        // content over the chat WebSocket as an unsolicited `mac:push` frame —
        // the Morphy Mac app renders it (notch card + TTS) without the user
        // having pushed to talk. If the skill isn't installed the agent never
        // emits this tag, so nothing is pushed and non-Mac users carry no weight.
        if (fullResponse) {
          const macPushRegex = /<mac_push>([\s\S]*?)<\/mac_push>/g;
          let macMatch;
          while ((macMatch = macPushRegex.exec(fullResponse)) !== null) {
            const macContent = macMatch[1].trim();
            if (macContent) {
              broadcastBloby('mac:push', { content: macContent });
              log.info(`[scheduler] Mac push broadcast (${macContent.length} chars)`);
            }
          }
        }

        // Extract <Message> blocks after agent turn completes
        if (fullResponse) {
          const messageRegex = /<Message(?:\s+([^>]*))?>(([\s\S]*?))<\/Message>/g;
          let match;
          while ((match = messageRegex.exec(fullResponse)) !== null) {
            const attrs = match[1] || '';
            const messageContent = match[2].trim();
            const titleMatch = attrs.match(/title="([^"]*)"/);

            log.info(`[scheduler] Agent message: ${messageContent.slice(0, 80)}...`);

            const msgTimestamp = new Date().toISOString();

            // Save to the user's conversation in DB
            if (dbConvId) {
              workerApi(`/api/conversations/${dbConvId}/messages`, 'POST', {
                role: 'assistant',
                content: messageContent,
                meta: { model },
              }).catch((err: any) => {
                log.warn(`[scheduler] DB persist error: ${err.message}`);
              });
            }

            // Broadcast as a regular message to all connected clients
            broadcastBloby('chat:sync', {
              conversationId: dbConvId,
              message: { role: 'assistant', content: messageContent, timestamp: msgTimestamp },
            });

            // Send push notification for closed tabs / locked devices
            workerApi('/api/push/send', 'POST', {
              title: titleMatch?.[1] || botName,
              body: messageContent.slice(0, 200),
              tag: `bloby-${label}`,
              url: '/',
            }).then((r: any) => {
              log.info(`[scheduler] Push sent: ${r.sent}/${r.total}`);
            }).catch((err: any) => {
              log.warn(`[scheduler] Push send failed: ${err.message}`);
            });
          }
        }

        log.info(`[scheduler] ${label} agent query complete`);
        if (eventData.usedFileTools) {
          log.info(`[scheduler] File tools used — restarting backend`);
          restartBackend();
        }
        onTurnComplete?.(); // flush a queued self-update now this pulse/cron turn has ended
        onComplete?.();
      }

      if (type === 'bot:error') {
        log.warn(`[scheduler] ${label} agent error: ${eventData.error}`);
      }
    });
  })();
}

function tick() {
  const now = Date.now();

  // ── Pulse check ──
  const pulse = readPulseConfig();
  if (pulse.enabled && !isInQuietHours(pulse.quietHours)) {
    const elapsed = now - lastPulseTime;
    const intervalMs = pulse.intervalMinutes * 60 * 1000;
    if (elapsed >= intervalMs) {
      lastPulseTime = now;
      triggerAgent('<PULSE/>', 'pulse');
    }
  }

  // ── Cron check ──
  const crons = readCronsConfig();

  for (const cron of crons) {
    // `paused === true` (strict) so a missing/false flag from pre-pause CRONS.json never skips.
    // Checked before cronMatchesNow/lastCronRuns so a paused cron neither fires nor advances state.
    if (!cron.enabled || cron.paused === true || !cron.id || !cron.schedule) continue;

    const matches = cronMatchesNow(cron.schedule);
    const nowDate = new Date();
    log.info(`[scheduler] Cron "${cron.id}" schedule="${cron.schedule}" matches=${matches} now=${nowDate.getHours()}:${String(nowDate.getMinutes()).padStart(2,'0')}`);

    if (matches) {
      const lastRun = lastCronRuns.get(cron.id) || 0;
      const oneMinuteAgo = now - 60_000;
      if (lastRun < oneMinuteAgo) {
        lastCronRuns.set(cron.id, now);
        // One-shots: defer removal until agent completes (so agent can still read CRONS.json)
        const onComplete = cron.oneShot ? () => {
          log.info(`[scheduler] Removing fired one-shot cron: ${cron.id}`);
          const fresh = readCronsConfig().filter((c) => c.id !== cron.id);
          writeCronsConfig(fresh);
          // Also remove the task file if it exists
          try {
            fs.unlinkSync(path.join(WORKSPACE_DIR, 'tasks', `${cron.id}.md`));
          } catch {}
        } : undefined;
        // Inject task file content if tasks/{id}.md exists
        let cronPrompt = `<CRON>${cron.id}</CRON>`;
        try {
          const taskFile = path.join(WORKSPACE_DIR, 'tasks', `${cron.id}.md`);
          const taskContent = fs.readFileSync(taskFile, 'utf-8').trim();
          if (taskContent) {
            cronPrompt += `\n<CRON_TASK_DETAIL>\n${taskContent}\n</CRON_TASK_DETAIL>`;
          }
        } catch {}
        triggerAgent(cronPrompt, cron.id, onComplete);
      }
    }
  }

  // ── Cleanup: remove expired one-shots (schedule in the past, never gonna fire) ──
  if (crons.length > 0) {
    const cleaned = crons.filter((c) => {
      if (c.oneShot && cronIsExpired(c.schedule)) {
        log.info(`[scheduler] cronIsExpired=true for "${c.id}" schedule="${c.schedule}"`);
        log.info(`[scheduler] Removing expired one-shot cron: ${c.id}`);
        try {
          fs.unlinkSync(path.join(WORKSPACE_DIR, 'tasks', `${c.id}.md`));
        } catch {}
        return false;
      }
      return true;
    });

    if (cleaned.length !== crons.length) {
      writeCronsConfig(cleaned);
    }
  }
}

/** ISO of the next occurrence, or null if the schedule is unparseable / has no future occurrence. */
export function nextRunISO(schedule: string): string | null {
  try {
    // .next() throws both on a parse error and when there is no future occurrence — single parse.
    return CronExpressionParser.parse(schedule).next().toISOString();
  } catch {
    return null;
  }
}

/** Humanize a cron expression for the settings UI; falls back to the raw expression when a field
 *  is out of range or the pattern isn't one we can describe accurately (so the label never lies). */
export function describeCron(schedule: string): string {
  const parts = schedule.trim().split(/\s+/);
  if (parts.length !== 5) return schedule; // 6-field / @macro — show raw, don't lie
  const [min, hr, dom, mon, dow] = parts;
  const star = (s: string) => s === '*';
  const two = (s: string) => String(s).padStart(2, '0');
  const inRange = (s: string, lo: number, hi: number) => /^\d+$/.test(s) && Number(s) >= lo && Number(s) <= hi;
  const at = (inRange(hr, 0, 23) && inRange(min, 0, 59)) ? `${two(hr)}:${two(min)}` : null;

  // `*/N` only yields an even interval when N divides the field's range (60 min / 24 hr); otherwise
  // it wraps at the top of the range and the "every N" label would be wrong — fall through to raw.
  const everyMin = min.match(/^\*\/(\d+)$/);
  if (everyMin && Number(everyMin[1]) >= 1 && 60 % Number(everyMin[1]) === 0 && star(hr) && star(dom) && star(mon) && star(dow)) {
    return `Every ${everyMin[1]} minute${everyMin[1] === '1' ? '' : 's'}`;
  }
  const everyHr = hr.match(/^\*\/(\d+)$/);
  if (everyHr && Number(everyHr[1]) >= 1 && 24 % Number(everyHr[1]) === 0 && inRange(min, 0, 59) && star(dom) && star(mon) && star(dow)) {
    return `Every ${everyHr[1]} hour${everyHr[1] === '1' ? '' : 's'}${min === '0' ? '' : ` at :${two(min)}`}`;
  }
  if (at && star(dom) && star(mon) && star(dow)) return `Every day at ${at}`;
  const DOW = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
  if (at && star(dom) && star(mon) && /^[0-7]$/.test(dow)) return `Every ${DOW[Number(dow) % 7]} at ${at}`; // 7 = Sunday alias
  if (at && inRange(dom, 1, 31) && star(mon) && star(dow)) return `Monthly on day ${dom} at ${at}`;
  const MON = ['', 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  if (at && inRange(dom, 1, 31) && inRange(mon, 1, 12) && star(dow)) return `On ${MON[Number(mon)]} ${dom} at ${at}`;
  return schedule;
}

export function startScheduler(opts: SchedulerOpts) {
  schedulerOpts = opts;
  lastPulseTime = Date.now();
  intervalHandle = setInterval(tick, 60_000);
  log.info('[scheduler] Started — checking every 60s');
}

export function stopScheduler() {
  if (intervalHandle) {
    clearInterval(intervalHandle);
    intervalHandle = null;
  }
  schedulerOpts = null;
  log.info('[scheduler] Stopped');
}
