import postgres from "postgres";

import type { Registry, TodoInstance, TodoItem } from "./types";

interface ReminderRepository {
  listInstances(): Promise<Registry>;
  getInstance(instanceId: string): Promise<TodoInstance>;
}

interface ReminderLogger {
  log(level: "info" | "warn" | "error", event: string, detail?: Record<string, unknown>): void;
}

type ReminderTargetType = "private" | "group";

export interface ReminderNotifierConfig {
  enabled: boolean;
  baseUrl: string;
  targetType: ReminderTargetType;
  userId: string;
  groupId: string;
  leadMs: number;
  scanIntervalMs: number;
  timeoutMs: number;
  sendAttempts: number;
}

interface ReminderCandidate {
  instanceId: string;
  instanceName: string;
  todoId: string;
  title: string;
  titlePath: string[];
  reminderAt: string;
  reminderAtMs: number;
}

interface ReminderStatus {
  enabled: boolean;
  target: string;
  baseUrl: string;
  leadMinutes: number;
  scanIntervalMs: number;
  timeoutMs: number;
  sendAttempts: number;
  running: boolean;
  lastScanAt: string | null;
  nextScanAt: string | null;
  lastError: string | null;
  sentCount: number;
  inFlightCount: number;
}

function envValue(name: string): string {
  const value = process.env[name];
  if (value === undefined) throw new Error(`${name} is required`);
  return value.trim();
}

function envBoolean(name: string): boolean {
  const value = envValue(name).toLowerCase();
  if (value === "true") return true;
  if (value === "false") return false;
  throw new Error(`${name} must be true or false`);
}

function envInteger(name: string, min: number, max: number): number {
  const value = Number(envValue(name));
  if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${name} must be an integer from ${min} to ${max}`);
  return value;
}

function nowIso(): string {
  return new Date().toISOString();
}

function safePreview(value: string, maxChars: number): string {
  return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`;
}

function errorDetail(error: unknown): Record<string, unknown> {
  if (error instanceof Error) return { name: error.name, message: error.message };
  return { name: "Error", message: String(error) };
}

function targetLabel(config: ReminderNotifierConfig): string {
  return config.targetType === "group" ? `group:${config.groupId || "-"}` : `private:${config.userId || "-"}`;
}

function targetConfigured(config: ReminderNotifierConfig): boolean {
  if (!config.enabled || !config.baseUrl) return false;
  return config.targetType === "group" ? config.groupId.length > 0 : config.userId.length > 0;
}

function collectReminderCandidates(instance: TodoInstance, nowMs: number, leadMs: number): ReminderCandidate[] {
  const candidates: ReminderCandidate[] = [];
  const visit = (todo: TodoItem, titlePath: string[]): void => {
    const nextPath = [...titlePath, todo.title || "Untitled"];
    if (!todo.completed && todo.reminderAt) {
      const reminderAtMs = Date.parse(todo.reminderAt);
      if (Number.isFinite(reminderAtMs) && nowMs >= reminderAtMs - leadMs && nowMs <= reminderAtMs) {
        candidates.push({
          instanceId: instance.id,
          instanceName: instance.name,
          todoId: todo.id,
          title: todo.title || "Untitled",
          titlePath: nextPath,
          reminderAt: todo.reminderAt,
          reminderAtMs,
        });
      }
    }
    for (const child of todo.children) visit(child, nextPath);
  };
  for (const todo of instance.todos) visit(todo, []);
  return candidates;
}

function messageForCandidate(candidate: ReminderCandidate, nowMs: number, leadMs: number): string {
  const remainingMinutes = Math.max(0, Math.ceil((candidate.reminderAtMs - nowMs) / 60_000));
  return [
    "Todo Note 时间提醒",
    `清单: ${candidate.instanceName}`,
    `任务: ${candidate.titlePath.join(" / ")}`,
    `提醒时间: ${new Date(candidate.reminderAtMs).toISOString().replace("T", " ").replace(/\.\d{3}Z$/u, " UTC")}`,
    `剩余: ${remainingMinutes} 分钟`,
    `默认提前: ${Math.ceil(leadMs / 60_000)} 分钟`,
  ].join("\n");
}

function claudeQqPayload(config: ReminderNotifierConfig, message: string): Record<string, string> {
  return config.targetType === "group"
    ? { targetType: "group", groupId: config.groupId, message }
    : { targetType: "private", userId: config.userId, message };
}

async function delay(ms: number): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, ms));
}

class ReminderNotificationStore {
  private readonly memorySent = new Set<string>();
  private readonly sql: postgres.Sql;
  private ready = false;

  constructor(databaseUrl: string) {
    this.sql = postgres(databaseUrl, { max: 2, idle_timeout: 20, connect_timeout: 10, connection: { application_name: "unidesk-todo-note-reminders" } });
  }

  async ensureReady(): Promise<void> {
    if (this.ready) return;
    await this.sql`
      CREATE TABLE IF NOT EXISTS todo_note_reminder_notifications (
        id BIGSERIAL PRIMARY KEY,
        instance_id TEXT NOT NULL,
        todo_id TEXT NOT NULL,
        reminder_at TEXT NOT NULL,
        target TEXT NOT NULL,
        title TEXT NOT NULL,
        message TEXT NOT NULL,
        response_preview TEXT NOT NULL DEFAULT '',
        sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
        UNIQUE (instance_id, todo_id, reminder_at)
      )
    `;
    await this.sql`CREATE INDEX IF NOT EXISTS idx_todo_note_reminder_notifications_sent_at ON todo_note_reminder_notifications(sent_at DESC)`;
    this.ready = true;
  }

  async hasSent(candidate: ReminderCandidate): Promise<boolean> {
    const key = `${candidate.instanceId}:${candidate.todoId}:${candidate.reminderAt}`;
    if (this.memorySent.has(key)) return true;
    const rows = await this.sql<Array<{ exists: boolean }>>`
      SELECT EXISTS (
        SELECT 1 FROM todo_note_reminder_notifications
        WHERE instance_id = ${candidate.instanceId} AND todo_id = ${candidate.todoId} AND reminder_at = ${candidate.reminderAt}
      ) AS exists
    `;
    if (Boolean(rows[0]?.exists)) this.memorySent.add(key);
    return Boolean(rows[0]?.exists);
  }

  async markSent(candidate: ReminderCandidate, target: string, message: string, responsePreview: string): Promise<void> {
    this.memorySent.add(`${candidate.instanceId}:${candidate.todoId}:${candidate.reminderAt}`);
    await this.sql`
      INSERT INTO todo_note_reminder_notifications (instance_id, todo_id, reminder_at, target, title, message, response_preview)
      VALUES (${candidate.instanceId}, ${candidate.todoId}, ${candidate.reminderAt}, ${target}, ${candidate.title}, ${message}, ${responsePreview})
      ON CONFLICT (instance_id, todo_id, reminder_at) DO NOTHING
    `;
  }

  async close(): Promise<void> {
    await this.sql.end({ timeout: 5 });
  }
}

export function loadReminderNotifierConfig(): ReminderNotifierConfig {
  const targetType = envValue("TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE").toLowerCase();
  if (targetType !== "private" && targetType !== "group") throw new Error("TODO_NOTE_REMINDER_CLAUDEQQ_TARGET_TYPE must be private or group");
  const config: ReminderNotifierConfig = {
    enabled: envBoolean("TODO_NOTE_REMINDER_CLAUDEQQ_ENABLED"),
    baseUrl: envValue("TODO_NOTE_REMINDER_CLAUDEQQ_BASE_URL").replace(/\/+$/u, ""),
    targetType,
    userId: envValue("TODO_NOTE_REMINDER_CLAUDEQQ_USER_ID"),
    groupId: envValue("TODO_NOTE_REMINDER_CLAUDEQQ_GROUP_ID"),
    leadMs: envInteger("TODO_NOTE_REMINDER_LEAD_MINUTES", 1, 24 * 60) * 60_000,
    scanIntervalMs: envInteger("TODO_NOTE_REMINDER_SCAN_INTERVAL_MS", 5_000, 10 * 60_000),
    timeoutMs: envInteger("TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS", 1_000, 60_000),
    sendAttempts: envInteger("TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS", 1, 10),
  };
  if (config.enabled && !targetConfigured(config)) throw new Error("Todo Note reminder target is not configured");
  return config;
}

export class TodoReminderNotifier {
  private readonly store: ReminderNotificationStore;
  private readonly inFlight = new Set<string>();
  private readonly failureRetryAfter = new Map<string, number>();
  private timer: ReturnType<typeof setTimeout> | null = null;
  private running = false;
  private scanning = false;
  private lastScanAt: string | null = null;
  private nextScanAt: string | null = null;
  private lastError: string | null = null;
  private sentCount = 0;

  constructor(
    private readonly repository: ReminderRepository,
    private readonly logger: ReminderLogger,
    private readonly config: ReminderNotifierConfig,
    databaseUrl: string,
    private readonly persistNotification: (writeNotification: () => Promise<void>) => Promise<void>,
  ) {
    this.store = new ReminderNotificationStore(databaseUrl);
  }

  start(): void {
    if (!targetConfigured(this.config)) {
      this.logger.log("info", "todo_reminder_notifier_disabled", { target: targetLabel(this.config), baseUrl: this.config.baseUrl });
      return;
    }
    this.running = true;
    this.logger.log("info", "todo_reminder_notifier_started", this.status());
    this.schedule(1_000);
  }

  async stop(): Promise<void> {
    this.running = false;
    if (this.timer !== null) clearTimeout(this.timer);
    this.timer = null;
    this.nextScanAt = null;
    await this.store.close();
  }

  status(): ReminderStatus {
    return {
      enabled: targetConfigured(this.config),
      target: targetLabel(this.config),
      baseUrl: this.config.baseUrl,
      leadMinutes: this.config.leadMs / 60_000,
      scanIntervalMs: this.config.scanIntervalMs,
      timeoutMs: this.config.timeoutMs,
      sendAttempts: this.config.sendAttempts,
      running: this.running,
      lastScanAt: this.lastScanAt,
      nextScanAt: this.nextScanAt,
      lastError: this.lastError,
      sentCount: this.sentCount,
      inFlightCount: this.inFlight.size,
    };
  }

  async scanNow(): Promise<ReminderStatus> {
    if (targetConfigured(this.config)) await this.scanOnce();
    return this.status();
  }

  private schedule(delayMs: number): void {
    if (!this.running) return;
    this.nextScanAt = new Date(Date.now() + delayMs).toISOString();
    this.timer = setTimeout(() => void this.tick(), delayMs);
    this.timer.unref?.();
  }

  private async tick(): Promise<void> {
    try {
      await this.scanOnce();
    } finally {
      this.schedule(this.config.scanIntervalMs);
    }
  }

  private async scanOnce(): Promise<void> {
    if (this.scanning) return;
    this.scanning = true;
    const nowMs = Date.now();
    this.lastScanAt = nowIso();
    this.nextScanAt = null;
    try {
      await this.store.ensureReady();
      const registry = await this.repository.listInstances();
      let candidates = 0;
      for (const summary of registry.instances) {
        const instance = await this.repository.getInstance(summary.id);
        for (const candidate of collectReminderCandidates(instance, nowMs, this.config.leadMs)) {
          candidates += 1;
          await this.maybeSend(candidate, nowMs);
        }
      }
      this.lastError = null;
      this.logger.log("info", "todo_reminder_scan_complete", { checked: registry.instances.length, candidates, sentCount: this.sentCount });
    } catch (error) {
      this.lastError = error instanceof Error ? error.message : String(error);
      this.logger.log("warn", "todo_reminder_scan_failed", errorDetail(error));
    } finally {
      this.scanning = false;
    }
  }

  private async maybeSend(candidate: ReminderCandidate, nowMs: number): Promise<void> {
    const key = `${candidate.instanceId}:${candidate.todoId}:${candidate.reminderAt}`;
    if (this.inFlight.has(key) || (this.failureRetryAfter.get(key) ?? 0) > nowMs || await this.store.hasSent(candidate)) return;
    this.inFlight.add(key);
    try {
      const message = messageForCandidate(candidate, nowMs, this.config.leadMs);
      const responsePreview = await this.postClaudeQqText(message);
      try {
        await this.persistNotification(() => this.store.markSent(candidate, targetLabel(this.config), message, responsePreview));
      } catch (error) {
        this.logger.log("warn", "todo_reminder_persist_failed", { instanceId: candidate.instanceId, todoId: candidate.todoId, reminderAt: candidate.reminderAt, error: errorDetail(error) });
        return;
      }
      this.sentCount += 1;
      this.failureRetryAfter.delete(key);
      this.logger.log("info", "todo_reminder_sent", { instanceId: candidate.instanceId, todoId: candidate.todoId, reminderAt: candidate.reminderAt, target: targetLabel(this.config) });
    } catch (error) {
      this.failureRetryAfter.set(key, Date.now() + Math.min(60_000, this.config.scanIntervalMs * 2));
      this.logger.log("warn", "todo_reminder_send_failed", { instanceId: candidate.instanceId, todoId: candidate.todoId, reminderAt: candidate.reminderAt, error: errorDetail(error) });
    } finally {
      this.inFlight.delete(key);
    }
  }

  private async postClaudeQqText(message: string): Promise<string> {
    let lastError: unknown = null;
    for (let attempt = 1; attempt <= this.config.sendAttempts; attempt += 1) {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
      try {
        const response = await fetch(`${this.config.baseUrl}/api/push/text`, {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify(claudeQqPayload(this.config, message)),
          signal: controller.signal,
        });
        const responseText = await response.text();
        const responsePreview = safePreview(responseText, 500);
        if (!response.ok) throw new Error(`ClaudeQQ proxy returned HTTP ${response.status}: ${responsePreview}`);
        try {
          const parsed = JSON.parse(responseText) as Record<string, unknown>;
          if (parsed.ok === false || parsed.success === false || parsed.status === "napcat_offline") throw new Error(`ClaudeQQ push failed: ${responsePreview}`);
        } catch (error) {
          if (!(error instanceof SyntaxError)) throw error;
        }
        return responsePreview;
      } catch (error) {
        lastError = error;
        if (attempt < this.config.sendAttempts) await delay(Math.min(30_000, 1_000 * (2 ** (attempt - 1))));
      } finally {
        clearTimeout(timer);
      }
    }
    throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "ClaudeQQ push failed"));
  }
}
