import { initTRPC } from '@trpc/server';
import { z } from 'zod';
import { eq, asc, desc, inArray, and, or, like, sql, gte, lte } from 'drizzle-orm';
import { alias } from 'drizzle-orm/sqlite-core';
import { dialog, shell } from 'electron';
import { randomUUID } from 'node:crypto';
import { stat } from 'node:fs/promises';
import { getDb } from '../db';
import { clients, projects, tasks, timelineEvents, timelineEventDependencies, notes, noteEdits, taskComments, taskAttachments, agentRuns, agentRunActions, taskBriefings, taskBriefChats } from '../db/schema';
import { copyIntoTask, deleteStored, absolutePath, deleteTaskDir } from '../attachments/storage';
import { createHash } from 'crypto';
import { getStore, getDeviceId, getLocalAgents, getLocalAgent, saveLocalAgent, deleteLocalAgent, getFormatPrefs, setFormatPrefs, getUiLanguage, setUiLanguage, getTimelineZoom, setTimelineZoom } from '../store';
import type { LocalAgentLocalConfig } from '../store';
import { getBackendClient } from '../api/backend-client';
import type { AgentCatalogItem, CloudAgentConfig, AgentRunLog } from '../../shared/api-types';
import { orchestrate, orchestrateFloating, orchestrateTaskBriefResearch, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
import { getAuthManager, AuthError } from '../auth/auth-manager';
import { detectFormatPrefs, detectLanguage } from '../auth/locale-defaults';
import type { TRPCContext } from '../ipc';
import { projectFoldersRouter } from './projectFolders';

const t = initTRPC.context<TRPCContext>().create();

function isInCurrentWeek(ts: number | null | undefined): boolean {
  if (!ts) return false;
  const now = new Date();
  const start = new Date(now);
  const day = start.getDay();
  const daysSinceMonday = (day + 6) % 7;
  start.setDate(start.getDate() - daysSinceMonday);
  start.setHours(0, 0, 0, 0);

  const end = new Date(start);
  end.setDate(end.getDate() + 7);
  end.setMilliseconds(end.getMilliseconds() - 1);

  return ts >= start.getTime() && ts <= end.getTime();
}

function isBriefRelevantTask(dueDate: number | null | undefined, status: string | null | undefined, completedAt?: number | null): boolean {
  if (isInCurrentWeek(completedAt)) return true;
  if (!isInCurrentWeek(dueDate)) return false;
  return status !== 'done';
}

function isBriefRelevantTimeline(date: number | null | undefined, completedAt?: number | null): boolean {
  if (isInCurrentWeek(completedAt)) return true;
  return isInCurrentWeek(date);
}

const router = t.router;
const publicProcedure = t.procedure;

function hashTaskForBriefing(t: { title?: string | null; description?: string | null; dueDate?: number | null; status?: string | null; priority?: string | null; projectId?: string | null }): string {
  return createHash('sha1').update(JSON.stringify({
    title: t.title ?? null,
    description: t.description ?? null,
    dueDate: t.dueDate ?? null,
    status: t.status ?? null,
    priority: t.priority ?? null,
    projectId: t.projectId ?? null,
  })).digest('hex');
}

// Health router — ping/pong verification
const healthRouter = router({
  ping: publicProcedure.query(() => 'pong' as const),
});

const clientsRouter = router({
  list: publicProcedure.query(() => {
    return getDb().select().from(clients).orderBy(asc(clients.name)).all();
  }),

  create: publicProcedure
    .input(z.object({ name: z.string(), parentId: z.string().optional(), industry: z.string().optional() }))
    .mutation(({ input }) => {
      const id = crypto.randomUUID();
      const now = Date.now();
      getDb().insert(clients).values({
        id,
        name: input.name,
        parentId: input.parentId ?? null,
        industry: input.industry ?? null,
        createdAt: now,
      }).run();
      return { id };
    }),

  update: publicProcedure
    .input(z.object({ id: z.string(), name: z.string().optional(), industry: z.string().optional() }))
    .mutation(({ input }) => {
      const set: Partial<{ name: string; industry: string | null }> = {};
      if (input.name !== undefined) set.name = input.name;
      if (input.industry !== undefined) set.industry = input.industry;
      if (Object.keys(set).length > 0) {
        getDb().update(clients).set(set).where(eq(clients.id, input.id)).run();
      }
      return null;
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      const db = getDb();
      const childClients = db.select({ id: clients.id }).from(clients).where(eq(clients.parentId, input.id)).all();
      if (childClients.length > 0) {
        return { error: 'This client has sub-clients. Use cascade delete to remove all.' };
      }
      const childProjects = db.select({ id: projects.id }).from(projects).where(eq(projects.clientId, input.id)).all();
      if (childProjects.length > 0) {
        return { error: 'This client has projects. Use cascade delete to remove all.' };
      }
      db.delete(clients).where(eq(clients.id, input.id)).run();
      return { success: true as const };
    }),

  deleteWithCascade: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      const db = getDb();

      // Recursively collect all descendant client IDs (BFS)
      const allClientIds: string[] = [input.id];
      const queue: string[] = [input.id];
      while (queue.length > 0) {
        const currentId = queue.shift()!;
        const children = db.select({ id: clients.id }).from(clients).where(eq(clients.parentId, currentId)).all();
        for (const child of children) {
          allClientIds.push(child.id);
          queue.push(child.id);
        }
      }

      // Find all projects belonging to these clients
      const clientProjects = db.select({ id: projects.id }).from(projects).where(inArray(projects.clientId, allClientIds)).all();
      const projectIds = clientProjects.map((p) => p.id);

      if (projectIds.length > 0) {
        // Null out projectId on orphaned tasks
        db.update(tasks).set({ projectId: null }).where(inArray(tasks.projectId, projectIds)).run();
        // Delete the projects
        db.delete(projects).where(inArray(projects.id, projectIds)).run();
      }

      // Delete all collected clients
      db.delete(clients).where(inArray(clients.id, allClientIds)).run();

      return { success: true as const };
    }),
});

const projectsRouter = router({
  list: publicProcedure
    .input(z.object({ clientId: z.string().optional(), includeArchived: z.boolean().optional() }).optional())
    .query(({ input }) => {
      const where = and(
        input?.clientId !== undefined ? eq(projects.clientId, input.clientId) : undefined,
        !input?.includeArchived ? eq(projects.status, 'active') : undefined,
      );
      return getDb().select().from(projects).where(where).orderBy(asc(projects.name)).all();
    }),

  listAll: publicProcedure.query(() => {
    return getDb().select({ id: projects.id, name: projects.name }).from(projects).orderBy(asc(projects.name)).all();
  }),

  get: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => {
      const result = getDb().select().from(projects).where(eq(projects.id, input.id)).all();
      return result[0] ?? null;
    }),

  create: publicProcedure
    .input(z.object({ name: z.string(), clientId: z.string().optional() }))
    .mutation(({ input }) => {
      const id = crypto.randomUUID();
      const now = Date.now();
      getDb().insert(projects).values({
        id,
        name: input.name,
        clientId: input.clientId ?? null,
        status: 'active',
        createdAt: now,
      }).run();
      return { id };
    }),

  update: publicProcedure
    .input(z.object({
      id: z.string(),
      name: z.string().optional(),
      clientId: z.string().nullable().optional(),
      status: z.enum(['active', 'archived']).optional(),
      aiSummary: z.string().optional(),
    }))
    .mutation(({ input }) => {
      const set: Partial<{ name: string; clientId: string | null; status: 'active' | 'archived'; aiSummary: string | null }> = {};
      if (input.name !== undefined) set.name = input.name;
      if (input.clientId !== undefined) set.clientId = input.clientId;
      if (input.status !== undefined) set.status = input.status;
      if (input.aiSummary !== undefined) set.aiSummary = input.aiSummary;
      if (Object.keys(set).length > 0) {
        getDb().update(projects).set(set).where(eq(projects.id, input.id)).run();
      }
      return null;
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      const db = getDb();
      // Null out projectId on tasks belonging to this project
      db.update(tasks).set({ projectId: null }).where(eq(tasks.projectId, input.id)).run();
      // Delete the project
      db.delete(projects).where(eq(projects.id, input.id)).run();
      return { success: true as const };
    }),

  archiveByClient: publicProcedure
    .input(z.object({ clientId: z.string(), status: z.enum(['active', 'archived']) }))
    .mutation(({ input }) => {
      getDb().update(projects).set({ status: input.status }).where(eq(projects.clientId, input.clientId)).run();
      return { success: true as const };
    }),
});

const tasksRouter = router({
  list: publicProcedure
    .input(z.object({
      projectId: z.string().optional(),
      status: z.enum(['todo', 'in_progress', 'done']).optional(),
      priority: z.enum(['high', 'medium', 'low']).optional(),
      assignee: z.string().optional(),
      search: z.string().optional(),
      dueDateFrom: z.number().optional(),
      dueDateTo: z.number().optional(),
      createdAtFrom: z.number().optional(),
      createdAtTo: z.number().optional(),
      completedAtFrom: z.number().optional(),
      completedAtTo: z.number().optional(),
      isAiSuggested: z.number().optional(),
      orderBy: z.enum(['dueDate', 'priority', 'createdAt', 'completedAt']).optional(),
      orderDir: z.enum(['asc', 'desc']).optional(),
      limit: z.number().int().min(1).optional(),
      offset: z.number().int().min(0).optional(),
    }).optional())
    .query(({ input }) => {
      const db = getDb();
      const parentClients = alias(clients, 'parent_clients');

      const searchTerm = input?.search?.trim();
      const dir = input?.orderDir === 'desc' ? desc : asc;
      const priorityExpr = sql`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;

      const conditions = and(
        input?.projectId !== undefined ? eq(tasks.projectId, input.projectId) : undefined,
        input?.status !== undefined ? eq(tasks.status, input.status) : undefined,
        input?.priority !== undefined ? eq(tasks.priority, input.priority) : undefined,
        input?.isAiSuggested !== undefined ? eq(tasks.isAiSuggested, input.isAiSuggested) : undefined,
        input?.assignee ? like(tasks.assignee, `%${input.assignee}%`) : undefined,
        searchTerm
          ? or(
              like(tasks.title, `%${searchTerm}%`),
              like(tasks.description, `%${searchTerm}%`),
            )
          : undefined,
        input?.dueDateFrom !== undefined ? gte(tasks.dueDate, input.dueDateFrom) : undefined,
        input?.dueDateTo !== undefined ? lte(tasks.dueDate, input.dueDateTo) : undefined,
        input?.createdAtFrom !== undefined ? gte(tasks.createdAt, input.createdAtFrom) : undefined,
        input?.createdAtTo !== undefined ? lte(tasks.createdAt, input.createdAtTo) : undefined,
        input?.completedAtFrom !== undefined ? gte(tasks.completedAt, input.completedAtFrom) : undefined,
        input?.completedAtTo !== undefined ? lte(tasks.completedAt, input.completedAtTo) : undefined,
      );

      const orderByClauses =
        input?.orderBy === 'dueDate'
          ? [dir(tasks.dueDate), asc(priorityExpr)]
          : input?.orderBy === 'priority'
          ? [asc(priorityExpr), dir(tasks.dueDate)]
          : input?.orderBy === 'createdAt'
          ? [dir(tasks.createdAt), asc(priorityExpr)]
          : input?.orderBy === 'completedAt'
          ? [dir(tasks.completedAt), asc(priorityExpr)]
          : [asc(tasks.dueDate), asc(priorityExpr)];

      const baseQuery = db
        .select({
          id: tasks.id,
          projectId: tasks.projectId,
          title: tasks.title,
          description: tasks.description,
          status: tasks.status,
          priority: tasks.priority,
          assignee: tasks.assignee,
          dueDate: tasks.dueDate,
          estimate: tasks.estimate,
          isAiSuggested: tasks.isAiSuggested,
          createdAt: tasks.createdAt,
          completedAt: tasks.completedAt,
          projectName: projects.name,
          clientName: sql<string | null>`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${parentClients.name} ELSE ${clients.name} END`,
          subClientName: sql<string | null>`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${clients.name} ELSE NULL END`,
        })
        .from(tasks)
        .leftJoin(projects, eq(tasks.projectId, projects.id))
        .leftJoin(clients, eq(projects.clientId, clients.id))
        .leftJoin(parentClients, eq(clients.parentId, parentClients.id))
        .where(conditions)
        .orderBy(...orderByClauses);

      if (input?.limit != null) {
        return baseQuery.limit(input.limit).offset(input.offset ?? 0).all();
      }
      return baseQuery.all();
    }),

  count: publicProcedure
    .input(z.object({
      projectId: z.string().optional(),
      status: z.enum(['todo', 'in_progress', 'done']).optional(),
      priority: z.enum(['high', 'medium', 'low']).optional(),
      assignee: z.string().optional(),
      search: z.string().optional(),
      dueDateFrom: z.number().optional(),
      dueDateTo: z.number().optional(),
      createdAtFrom: z.number().optional(),
      createdAtTo: z.number().optional(),
      completedAtFrom: z.number().optional(),
      completedAtTo: z.number().optional(),
      isAiSuggested: z.number().optional(),
    }).optional())
    .query(({ input }) => {
      const db = getDb();
      const searchTerm = input?.search?.trim();
      const conditions = and(
        input?.projectId !== undefined ? eq(tasks.projectId, input.projectId) : undefined,
        input?.status !== undefined ? eq(tasks.status, input.status) : undefined,
        input?.priority !== undefined ? eq(tasks.priority, input.priority) : undefined,
        input?.isAiSuggested !== undefined ? eq(tasks.isAiSuggested, input.isAiSuggested) : undefined,
        input?.assignee ? like(tasks.assignee, `%${input.assignee}%`) : undefined,
        searchTerm
          ? or(
              like(tasks.title, `%${searchTerm}%`),
              like(tasks.description, `%${searchTerm}%`),
            )
          : undefined,
        input?.dueDateFrom !== undefined ? gte(tasks.dueDate, input.dueDateFrom) : undefined,
        input?.dueDateTo !== undefined ? lte(tasks.dueDate, input.dueDateTo) : undefined,
        input?.createdAtFrom !== undefined ? gte(tasks.createdAt, input.createdAtFrom) : undefined,
        input?.createdAtTo !== undefined ? lte(tasks.createdAt, input.createdAtTo) : undefined,
        input?.completedAtFrom !== undefined ? gte(tasks.completedAt, input.completedAtFrom) : undefined,
        input?.completedAtTo !== undefined ? lte(tasks.completedAt, input.completedAtTo) : undefined,
      );
      const result = db.select({ count: sql<number>`count(*)` }).from(tasks).where(conditions).get();
      return { count: Number(result?.count ?? 0) };
    }),

  listAssignees: publicProcedure.query(() => {
    const rows = getDb()
      .select({ assignee: tasks.assignee })
      .from(tasks)
      .all();
    const names = new Set<string>();
    for (const row of rows) {
      if (!row.assignee) continue;
      try {
        const parsed = JSON.parse(row.assignee) as unknown;
        if (Array.isArray(parsed)) {
          for (const n of parsed) {
            if (typeof n === 'string' && n) names.add(n);
          }
        } else {
          names.add(row.assignee);
        }
      } catch {
        names.add(row.assignee);
      }
    }
    return [...names].sort();
  }),

  byIds: publicProcedure
    .input(z.object({ ids: z.array(z.string()) }))
    .query(({ input }) => {
      if (!input.ids.length) return [];
      const db = getDb();
      const parentClients = alias(clients, 'parent_clients');
      return db
        .select({
          id: tasks.id,
          projectId: tasks.projectId,
          title: tasks.title,
          description: tasks.description,
          status: tasks.status,
          priority: tasks.priority,
          assignee: tasks.assignee,
          dueDate: tasks.dueDate,
          estimate: tasks.estimate,
          isAiSuggested: tasks.isAiSuggested,
          createdAt: tasks.createdAt,
          completedAt: tasks.completedAt,
          projectName: projects.name,
          clientName: sql<string | null>`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${parentClients.name} ELSE ${clients.name} END`,
          subClientName: sql<string | null>`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${clients.name} ELSE NULL END`,
        })
        .from(tasks)
        .leftJoin(projects, eq(tasks.projectId, projects.id))
        .leftJoin(clients, eq(projects.clientId, clients.id))
        .leftJoin(parentClients, eq(clients.parentId, parentClients.id))
        .where(inArray(tasks.id, input.ids))
        .all();
    }),

  create: publicProcedure
    .input(z.object({
      title: z.string(),
      description: z.string().optional(),
      status: z.string().optional(),
      priority: z.string().optional(),
      assignees: z.array(z.string()).optional(),
      dueDate: z.number().optional(),
      estimate: z.number().int().nullable().optional(),
      projectId: z.string().optional(),
      isAiSuggested: z.number().optional(),
    }))
    .mutation(({ input }) => {
      const id = crypto.randomUUID();
      const now = Date.now();
      const nextStatus = input.status ?? 'todo';
      getDb().insert(tasks).values({
        id,
        title: input.title,
        description: input.description ?? null,
        status: nextStatus,
        priority: input.priority ?? 'medium',
        assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null,
        dueDate: input.dueDate ?? null,
        estimate: input.estimate ?? null,
        projectId: input.projectId ?? null,
        isAiSuggested: input.isAiSuggested ?? 0,
        createdAt: now,
        completedAt: nextStatus === 'done' ? now : null,
      }).run();
      if (isBriefRelevantTask(input.dueDate, nextStatus)) invalidateBriefCache();
      return { id };
    }),

  update: publicProcedure
    .input(z.object({
      id: z.string(),
      title: z.string().optional(),
      description: z.string().optional(),
      status: z.string().optional(),
      priority: z.string().optional(),
      assignees: z.array(z.string()).optional(),
      dueDate: z.number().optional(),
      estimate: z.number().int().nullable().optional(),
      projectId: z.string().optional(),
    }))
    .mutation(({ input }) => {
      const now = Date.now();
      const prev = getDb()
        .select({ dueDate: tasks.dueDate, status: tasks.status, completedAt: tasks.completedAt })
        .from(tasks)
        .where(eq(tasks.id, input.id))
        .all()[0];

      const set: Partial<{
        title: string;
        description: string | null;
        status: string;
        priority: string;
        assignee: string | null;
        dueDate: number | null;
        estimate: number | null;
        projectId: string | null;
        completedAt: number | null;
      }> = {};
      if (input.title !== undefined) set.title = input.title;
      if (input.description !== undefined) set.description = input.description;
      if (input.priority !== undefined) set.priority = input.priority;
      if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null;
      if (input.dueDate !== undefined) set.dueDate = input.dueDate;
      if (input.estimate !== undefined) set.estimate = input.estimate;
      if (input.projectId !== undefined) set.projectId = input.projectId;
      if (input.status !== undefined) {
        set.status = input.status;
        set.completedAt = input.status === 'done' ? now : null;
      }
      if (Object.keys(set).length > 0) {
        getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run();
      }

      const nextStatus = input.status !== undefined ? input.status : prev?.status;
      const nextDueDate = input.dueDate !== undefined ? input.dueDate : prev?.dueDate;
      const nextCompletedAt = set.completedAt !== undefined ? set.completedAt : prev?.completedAt;
      const prevRelevant = isBriefRelevantTask(prev?.dueDate, prev?.status, prev?.completedAt);
      const nextRelevant = isBriefRelevantTask(nextDueDate, nextStatus, nextCompletedAt);
      if (prevRelevant || nextRelevant) invalidateBriefCache();

      // Invalidate task briefing + chat history so next open re-researches
      getDb().delete(taskBriefings).where(eq(taskBriefings.taskId, input.id)).run();
      getDb().delete(taskBriefChats).where(eq(taskBriefChats.taskId, input.id)).run();

      return null;
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      const db = getDb();
      const prev = db
        .select({ dueDate: tasks.dueDate, status: tasks.status, completedAt: tasks.completedAt })
        .from(tasks)
        .where(eq(tasks.id, input.id))
        .all()[0];

      // Cascade attachments: unlink files, delete rows, remove task directory
      const atts = db.select().from(taskAttachments).where(eq(taskAttachments.taskId, input.id)).all();
      for (const a of atts) {
        await deleteStored(a.storedPath);
      }
      db.delete(taskAttachments).where(eq(taskAttachments.taskId, input.id)).run();
      await deleteTaskDir(input.id);

      db.delete(tasks).where(eq(tasks.id, input.id)).run();
      db.delete(taskBriefings).where(eq(taskBriefings.taskId, input.id)).run();
      db.delete(taskBriefChats).where(eq(taskBriefChats.taskId, input.id)).run();
      if (isBriefRelevantTask(prev?.dueDate, prev?.status, prev?.completedAt)) invalidateBriefCache();
      return { success: true as const };
    }),

  dueToday: publicProcedure.query(() => {
    const now = new Date();
    const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();

    return getDb()
      .select({
        id: tasks.id,
        title: tasks.title,
        priority: tasks.priority,
        dueDate: tasks.dueDate,
        projectId: tasks.projectId,
      })
      .from(tasks)
      .where(
        and(
          sql`${tasks.dueDate} IS NOT NULL`,
          sql`${tasks.dueDate} <= ${endOfToday}`,
          sql`${tasks.status} != 'done'`,
        )
      )
      .orderBy(asc(tasks.dueDate))
      .all();
  }),
});

const timelineEventsRouter = router({
  list: publicProcedure
    .input(z.object({
      projectId: z.string().optional(),
      type: z.enum(['milestone', 'checkpoint', 'activity']).optional(),
      isCompleted: z.number().optional(),
      isAiSuggested: z.number().optional(),
      dateFrom: z.number().optional(),
      dateTo: z.number().optional(),
      createdAtFrom: z.number().optional(),
      createdAtTo: z.number().optional(),
      completedAtFrom: z.number().optional(),
      completedAtTo: z.number().optional(),
      orderBy: z.enum(['date', 'createdAt', 'completedAt']).optional(),
      orderDir: z.enum(['asc', 'desc']).optional(),
      limit: z.number().int().min(1).optional(),
      offset: z.number().int().min(0).optional(),
    }).optional())
    .query(({ input }) => {
      const db = getDb();
      const dir = input?.orderDir === 'desc' ? desc : asc;

      const conditions = and(
        input?.projectId !== undefined ? eq(timelineEvents.projectId, input.projectId) : undefined,
        input?.type !== undefined ? eq(timelineEvents.type, input.type) : undefined,
        input?.isCompleted !== undefined ? eq(timelineEvents.isCompleted, input.isCompleted) : undefined,
        input?.isAiSuggested !== undefined ? eq(timelineEvents.isAiSuggested, input.isAiSuggested) : undefined,
        input?.dateFrom !== undefined ? gte(timelineEvents.date, input.dateFrom) : undefined,
        input?.dateTo !== undefined ? lte(timelineEvents.date, input.dateTo) : undefined,
        input?.createdAtFrom !== undefined ? gte(timelineEvents.createdAt, input.createdAtFrom) : undefined,
        input?.createdAtTo !== undefined ? lte(timelineEvents.createdAt, input.createdAtTo) : undefined,
        input?.completedAtFrom !== undefined ? gte(timelineEvents.completedAt, input.completedAtFrom) : undefined,
        input?.completedAtTo !== undefined ? lte(timelineEvents.completedAt, input.completedAtTo) : undefined,
      );

      const orderByCol =
        input?.orderBy === 'createdAt' ? timelineEvents.createdAt
        : input?.orderBy === 'completedAt' ? timelineEvents.completedAt
        : timelineEvents.date;

      const baseQuery = db.select().from(timelineEvents).where(conditions).orderBy(dir(orderByCol));

      if (input?.limit != null) {
        return baseQuery.limit(input.limit).offset(input.offset ?? 0).all();
      }
      return baseQuery.all();
    }),

  count: publicProcedure
    .input(z.object({
      projectId: z.string().optional(),
      type: z.enum(['milestone', 'checkpoint', 'activity']).optional(),
      isCompleted: z.number().optional(),
      isAiSuggested: z.number().optional(),
      dateFrom: z.number().optional(),
      dateTo: z.number().optional(),
      createdAtFrom: z.number().optional(),
      createdAtTo: z.number().optional(),
      completedAtFrom: z.number().optional(),
      completedAtTo: z.number().optional(),
    }).optional())
    .query(({ input }) => {
      const db = getDb();
      const conditions = and(
        input?.projectId !== undefined ? eq(timelineEvents.projectId, input.projectId) : undefined,
        input?.type !== undefined ? eq(timelineEvents.type, input.type) : undefined,
        input?.isCompleted !== undefined ? eq(timelineEvents.isCompleted, input.isCompleted) : undefined,
        input?.isAiSuggested !== undefined ? eq(timelineEvents.isAiSuggested, input.isAiSuggested) : undefined,
        input?.dateFrom !== undefined ? gte(timelineEvents.date, input.dateFrom) : undefined,
        input?.dateTo !== undefined ? lte(timelineEvents.date, input.dateTo) : undefined,
        input?.createdAtFrom !== undefined ? gte(timelineEvents.createdAt, input.createdAtFrom) : undefined,
        input?.createdAtTo !== undefined ? lte(timelineEvents.createdAt, input.createdAtTo) : undefined,
        input?.completedAtFrom !== undefined ? gte(timelineEvents.completedAt, input.completedAtFrom) : undefined,
        input?.completedAtTo !== undefined ? lte(timelineEvents.completedAt, input.completedAtTo) : undefined,
      );
      const result = db.select({ count: sql<number>`count(*)` }).from(timelineEvents).where(conditions).get();
      return { count: Number(result?.count ?? 0) };
    }),

  create: publicProcedure
    .input(z.object({
      id: z.string().optional(),
      projectId: z.string().optional(),
      title: z.string(),
      date: z.number(),
      endDate: z.number().optional(),
      type: z.enum(['milestone', 'checkpoint', 'activity']).default('milestone'),
      isAiSuggested: z.number().optional(),
      isCompleted: z.number().optional(),
    }))
    .mutation(({ input }) => {
      const id = input.id ?? crypto.randomUUID();
      const now = Date.now();
      const resolvedIsCompleted = input.isCompleted ?? 0;
      const resolvedEndDate = input.type === 'activity' ? (input.endDate ?? null) : null;
      getDb().insert(timelineEvents).values({
        id,
        projectId: input.projectId ?? null,
        title: input.title,
        date: input.date,
        endDate: resolvedEndDate,
        type: input.type,
        isCompleted: resolvedIsCompleted,
        isAiSuggested: input.isAiSuggested ?? 0,
        createdAt: now,
        completedAt: resolvedIsCompleted === 1 ? now : null,
      }).run();
      if (isBriefRelevantTimeline(input.date)) invalidateBriefCache();
      return { id };
    }),

  update: publicProcedure
    .input(z.object({
      id: z.string(),
      title: z.string().optional(),
      date: z.number().optional(),
      endDate: z.number().nullable().optional(),
      type: z.enum(['milestone', 'checkpoint', 'activity']).optional(),
      isCompleted: z.number().optional(),
    }))
    .mutation(({ input }) => {
      const now = Date.now();
      const prev = getDb()
        .select({ date: timelineEvents.date, isCompleted: timelineEvents.isCompleted, completedAt: timelineEvents.completedAt })
        .from(timelineEvents)
        .where(eq(timelineEvents.id, input.id))
        .all()[0];

      const set: Partial<{ title: string; date: number; endDate: number | null; type: 'milestone' | 'checkpoint' | 'activity'; isCompleted: number; completedAt: number | null }> = {};
      if (input.title !== undefined) set.title = input.title;
      if (input.date !== undefined) set.date = input.date;
      if (input.endDate !== undefined) set.endDate = input.endDate;
      if (input.type !== undefined) set.type = input.type;
      if (input.isCompleted !== undefined) {
        set.isCompleted = input.isCompleted;
        set.completedAt = input.isCompleted === 1 ? now : null;
      }
      if (Object.keys(set).length > 0) {
        getDb().update(timelineEvents).set(set).where(eq(timelineEvents.id, input.id)).run();
      }

      const nextDate = input.date !== undefined ? input.date : prev?.date;
      const nextCompletedAt = set.completedAt !== undefined ? set.completedAt : prev?.completedAt;
      const prevRelevant = isBriefRelevantTimeline(prev?.date, prev?.completedAt);
      const nextRelevant = isBriefRelevantTimeline(nextDate, nextCompletedAt);
      if (prevRelevant || nextRelevant) invalidateBriefCache();

      return null;
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      const prev = getDb()
        .select({ date: timelineEvents.date, completedAt: timelineEvents.completedAt })
        .from(timelineEvents)
        .where(eq(timelineEvents.id, input.id))
        .all()[0];

      getDb().delete(timelineEvents).where(eq(timelineEvents.id, input.id)).run();
      if (isBriefRelevantTimeline(prev?.date, prev?.completedAt)) invalidateBriefCache();
      return { success: true as const };
    }),
});

const timelineEventDependenciesRouter = router({
  list: publicProcedure
    .input(z.object({ projectId: z.string().optional() }).optional())
    .query(({ input }) => {
      if (!input?.projectId) {
        return getDb().select().from(timelineEventDependencies).all();
      }
      // Return deps where either endpoint belongs to this project
      const projectEvents = getDb()
        .select({ id: timelineEvents.id })
        .from(timelineEvents)
        .where(eq(timelineEvents.projectId, input.projectId))
        .all()
        .map((e) => e.id);
      if (projectEvents.length === 0) return [];
      return getDb()
        .select()
        .from(timelineEventDependencies)
        .where(
          or(
            inArray(timelineEventDependencies.fromEventId, projectEvents),
            inArray(timelineEventDependencies.toEventId, projectEvents),
          ),
        )
        .all();
    }),

  create: publicProcedure
    .input(z.object({ fromEventId: z.string(), toEventId: z.string() }))
    .mutation(({ input }) => {
      // Cycle guard: BFS from toEventId; reject if fromEventId is reachable
      const visited = new Set<string>();
      const queue = [input.toEventId];
      while (queue.length > 0) {
        const current = queue.shift()!;
        if (current === input.fromEventId) {
          throw new Error('Cycle detected: this dependency would create a circular chain.');
        }
        if (visited.has(current)) continue;
        visited.add(current);
        const deps = getDb()
          .select({ toEventId: timelineEventDependencies.toEventId })
          .from(timelineEventDependencies)
          .where(eq(timelineEventDependencies.fromEventId, current))
          .all();
        for (const d of deps) queue.push(d.toEventId);
      }
      const id = crypto.randomUUID();
      getDb().insert(timelineEventDependencies).values({
        id, fromEventId: input.fromEventId, toEventId: input.toEventId, createdAt: Date.now(),
      }).run();
      return { id };
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      getDb().delete(timelineEventDependencies).where(eq(timelineEventDependencies.id, input.id)).run();
      return { success: true as const };
    }),
});

const notesRouter = router({
  list: publicProcedure
    .input(z.object({ projectId: z.string().optional() }).optional())
    .query(({ input }) => {
      const where = input?.projectId !== undefined ? eq(notes.projectId, input.projectId) : undefined;
      return getDb()
        .select({ id: notes.id, projectId: notes.projectId, title: notes.title, createdAt: notes.createdAt, updatedAt: notes.updatedAt })
        .from(notes)
        .where(where)
        .orderBy(asc(notes.createdAt))
        .all();
    }),

  get: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => {
      const result = getDb().select().from(notes).where(eq(notes.id, input.id)).all();
      return result[0] ?? null;
    }),

  create: publicProcedure
    .input(z.object({ title: z.string(), content: z.string(), projectId: z.string().optional() }))
    .mutation(({ input }) => {
      const id = crypto.randomUUID();
      const now = Date.now();
      getDb().insert(notes).values({
        id,
        title: input.title,
        content: input.content,
        projectId: input.projectId ?? null,
        createdAt: now,
        updatedAt: now,
      }).run();
      return { id };
    }),

  update: publicProcedure
    .input(z.object({ id: z.string(), title: z.string().optional(), content: z.string().optional() }))
    .mutation(({ input }) => {
      const set: Partial<{ title: string; content: string; updatedAt: number }> = {};
      if (input.title !== undefined) set.title = input.title;
      if (input.content !== undefined) set.content = input.content;
      set.updatedAt = Date.now();
      getDb().update(notes).set(set).where(eq(notes.id, input.id)).run();
      return null;
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      getDb().delete(notes).where(eq(notes.id, input.id)).run();
      return { success: true as const };
    }),
});

const taskCommentsRouter = router({
  list: publicProcedure
    .input(z.object({ taskId: z.string() }))
    .query(({ input }) => {
      return getDb()
        .select()
        .from(taskComments)
        .where(eq(taskComments.taskId, input.taskId))
        .orderBy(asc(taskComments.createdAt))
        .all();
    }),

  create: publicProcedure
    .input(z.object({ taskId: z.string(), author: z.string(), content: z.string() }))
    .mutation(({ input }) => {
      const id = crypto.randomUUID();
      const now = Date.now();
      getDb().insert(taskComments).values({
        id,
        taskId: input.taskId,
        author: input.author,
        content: input.content,
        createdAt: now,
      }).run();
      return { id, taskId: input.taskId, author: input.author, content: input.content, createdAt: now };
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      getDb().delete(taskComments).where(eq(taskComments.id, input.id)).run();
      return { success: true as const };
    }),
});

const settingsRouter = router({
  getSidebarCollapsed: publicProcedure.query(() => getStore().get('sidebarCollapsed')),
  setSidebarCollapsed: publicProcedure
    .input(z.object({ collapsed: z.boolean() }))
    .mutation(({ input }) => {
      getStore().set('sidebarCollapsed', input.collapsed);
      return null;
    }),
  /** Returns the stable device ID for this machine (UUID v4, persisted). */
  deviceId: publicProcedure.query(() => getDeviceId()),

  getFormatPrefs: publicProcedure.query(() => {
    return getFormatPrefs();
  }),

  setFormatPrefs: publicProcedure
    .input(z.object({
      timezone: z.string(),
      dateFormat: z.string(),
      timeFormat: z.enum(['12h', '24h']),
    }))
    .mutation(({ input }) => {
      setFormatPrefs(input);
      return input;
    }),

  getUiLanguage: publicProcedure.query(() => getUiLanguage()),

  setUiLanguage: publicProcedure
    .input(z.object({ lang: z.string() }))
    .mutation(({ input }) => {
      setUiLanguage(input.lang);
      return input.lang;
    }),

  getTimelineZoom: publicProcedure.query(() => getTimelineZoom()),

  setTimelineZoom: publicProcedure
    .input(z.object({ level: z.enum(['day', 'week', 'month']) }))
    .mutation(({ input }) => {
      setTimelineZoom(input.level);
      return input.level;
    }),
});

const aiRouter = router({
  chat: publicProcedure
    .input(z.object({
      requestId: z.string().optional(),
      message: z.string(),
      conversationHistory: z.array(z.object({
        role: z.enum(['user', 'assistant', 'system']),
        content: z.string(),
      })).optional(),
      sessionId: z.string().optional(),
      mode: z.enum(['home', 'floating']).optional(),
      scope: z.object({
        type: z.enum(['task', 'project', 'note', 'timeline']),
        id: z.string().optional(),
      }).optional(),
      briefMode: z.boolean().optional(),
      briefingContext: z.string().optional(),
    }))
    .mutation(async ({ input, ctx }) => {
      try {
        if (input.mode === 'floating' && input.scope) {
          return await orchestrateFloating({
            message: input.message,
            requestId: input.requestId,
            sessionId: input.sessionId,
            scope: input.scope,
            conversationHistory: input.conversationHistory,
            briefMode: input.briefMode,
            briefingContext: input.briefingContext,
            sender: ctx.sender,
          });
        }
        return await orchestrate({
          message: input.message,
          requestId: input.requestId,
          sessionId: input.sessionId,
          conversationHistory: input.conversationHistory,
          sender: ctx.sender,
        });
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Unknown error';
        return { response: '', error: msg };
      }
    }),
  getBrief: publicProcedure
    .query(() => getCachedBrief()),

  dailyBrief: publicProcedure
    .input(z.object({ requestId: z.string().optional() }).optional())
    .mutation(async ({ ctx, input }) => {
      try {
        return await dailyBrief(ctx.sender, input?.requestId);
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Unknown error';
        return { response: '', error: msg };
      }
    }),

  getTaskBriefing: publicProcedure
    .input(z.object({ taskId: z.string() }))
    .query(({ input }) => {
      const task = getDb().select().from(tasks).where(eq(tasks.id, input.taskId)).get();
      if (!task) return null;
      const briefing = getDb().select().from(taskBriefings).where(eq(taskBriefings.taskId, input.taskId)).get();
      if (!briefing) return null;
      const currentHash = hashTaskForBriefing(task);
      if (briefing.sourceTaskHash !== currentHash) {
        getDb().delete(taskBriefings).where(eq(taskBriefings.taskId, input.taskId)).run();
        return null;
      }
      return briefing;
    }),

  invalidateTaskBriefing: publicProcedure
    .input(z.object({ taskId: z.string() }))
    .mutation(({ input }) => {
      getDb().delete(taskBriefings).where(eq(taskBriefings.taskId, input.taskId)).run();
      getDb().delete(taskBriefChats).where(eq(taskBriefChats.taskId, input.taskId)).run();
      return { ok: true };
    }),

  getTaskBriefChats: publicProcedure
    .input(z.object({ taskId: z.string() }))
    .query(({ input }) => {
      return getDb()
        .select()
        .from(taskBriefChats)
        .where(eq(taskBriefChats.taskId, input.taskId))
        .orderBy(asc(taskBriefChats.createdAt))
        .all();
    }),

  saveTaskBriefChat: publicProcedure
    .input(z.object({
      id: z.string(),
      taskId: z.string(),
      role: z.enum(['user', 'assistant']),
      content: z.string(),
      isError: z.boolean().optional(),
      createdAt: z.number(),
    }))
    .mutation(({ input }) => {
      getDb().insert(taskBriefChats).values({
        id: input.id,
        taskId: input.taskId,
        role: input.role,
        content: input.content,
        isError: input.isError ?? false,
        createdAt: input.createdAt,
      }).onConflictDoUpdate({
        target: taskBriefChats.id,
        set: { content: input.content },
      }).run();
      return { ok: true };
    }),

  clearTaskBriefChats: publicProcedure
    .input(z.object({ taskId: z.string() }))
    .mutation(({ input }) => {
      getDb().delete(taskBriefChats).where(eq(taskBriefChats.taskId, input.taskId)).run();
      return { ok: true };
    }),

  taskBriefResearch: publicProcedure
    .input(z.object({
      taskId: z.string(),
      requestId: z.string().optional(),
    }))
    .mutation(async ({ input, ctx }) => {
      try {
        const task = getDb().select().from(tasks).where(eq(tasks.id, input.taskId)).get();
        if (!task) return { response: '', error: 'Task not found' };
        const result = await orchestrateTaskBriefResearch({
          taskId: input.taskId,
          requestId: input.requestId,
          sender: ctx.sender,
          onComplete: ({ briefingMarkdown, canvasDraft, canvasKind }) => {
            const hash = hashTaskForBriefing(task);
            // Clear stale chat history before persisting fresh briefing
            getDb().delete(taskBriefChats).where(eq(taskBriefChats.taskId, input.taskId)).run();
            getDb().insert(taskBriefings).values({
              taskId: input.taskId,
              briefingMarkdown,
              canvasDraft: canvasDraft ?? null,
              canvasKind: canvasKind ?? null,
              citations: null,
              sourceTaskHash: hash,
              generatedAt: Date.now(),
              modelVersion: null,
            }).onConflictDoUpdate({
              target: taskBriefings.taskId,
              set: {
                briefingMarkdown,
                canvasDraft: canvasDraft ?? null,
                canvasKind: canvasKind ?? null,
                sourceTaskHash: hash,
                generatedAt: Date.now(),
              },
            }).run();
          },
        });
        return result;
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Unknown error';
        return { response: '', error: msg };
      }
    }),
});

// ---------------------------------------------------------------------------
// Agent router — proxy to backend agent management API
// ---------------------------------------------------------------------------

const agentLocalRouter = router({
  list: publicProcedure.query(() => {
    return getLocalAgents();
  }),

  create: publicProcedure
    .input(z.object({
      name: z.string(),
      directory: z.string(),
      dataTypes: z.array(z.string()),
      agentConfig: z.record(z.string(), z.unknown()).nullable().optional(),
      scheduleCron: z.string(),
    }))
    .mutation(({ input }) => {
      const agent: LocalAgentLocalConfig = {
        id: crypto.randomUUID(),
        name: input.name,
        directory: input.directory,
        dataTypes: input.dataTypes,
        agentConfig: input.agentConfig ?? null,
        scheduleCron: input.scheduleCron,
        enabled: true,
        lastRunAt: null,
      };
      saveLocalAgent(agent);
      return { data: agent, error: null };
    }),

  update: publicProcedure
    .input(z.object({
      id: z.string(),
      name: z.string().optional(),
      directory: z.string().optional(),
      dataTypes: z.array(z.string()).optional(),
      agentConfig: z.record(z.string(), z.unknown()).nullable().optional(),
      scheduleCron: z.string().optional(),
      enabled: z.boolean().optional(),
    }))
    .mutation(({ input }) => {
      const existing = getLocalAgent(input.id);
      if (!existing) {
        return { data: null, error: 'Agent not found' };
      }
      const updated: LocalAgentLocalConfig = {
        ...existing,
        ...(input.name !== undefined && { name: input.name }),
        ...(input.directory !== undefined && { directory: input.directory }),
        ...(input.dataTypes !== undefined && { dataTypes: input.dataTypes }),
        ...('agentConfig' in input && { agentConfig: input.agentConfig ?? null }),
        ...(input.scheduleCron !== undefined && { scheduleCron: input.scheduleCron }),
        ...(input.enabled !== undefined && { enabled: input.enabled }),
      };
      saveLocalAgent(updated);
      return { data: updated, error: null };
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      deleteLocalAgent(input.id);
      return { success: true as const, error: null };
    }),
});

const agentCloudRouter = router({
  list: publicProcedure.query(async () => {
    try {
      return await getBackendClient().proxyGet<CloudAgentConfig[]>('/api/v1/agents/cloud');
    } catch (err) {
      const msg = err instanceof Error ? err.message : 'Failed to list cloud agents';
      console.error('[Agent] cloud.list error:', msg);
      return [];
    }
  }),

  create: publicProcedure
    .input(z.object({
      name: z.string(),
      provider: z.enum(['gmail', 'teams', 'outlook']),
      dataTypes: z.array(z.string()),
      promptTemplate: z.string(),
      scheduleCron: z.string(),
      filterConfig: z.record(z.string(), z.unknown()).optional(),
    }))
    .mutation(async ({ input }) => {
      try {
        const result = await getBackendClient().proxyPost<CloudAgentConfig>(
          '/api/v1/agents/cloud',
          input as Record<string, unknown>,
        );
        return { data: result, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Failed to create cloud agent';
        return { data: null, error: msg };
      }
    }),

  update: publicProcedure
    .input(z.object({
      id: z.string(),
      name: z.string().optional(),
      dataTypes: z.array(z.string()).optional(),
      promptTemplate: z.string().optional(),
      scheduleCron: z.string().optional(),
      filterConfig: z.record(z.string(), z.unknown()).optional(),
      enabled: z.boolean().optional(),
    }))
    .mutation(async ({ input }) => {
      const { id, ...updates } = input;
      try {
        const result = await getBackendClient().proxyPut<CloudAgentConfig>(
          `/api/v1/agents/cloud/${id}`,
          updates as Record<string, unknown>,
        );
        return { data: result, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Failed to update cloud agent';
        return { data: null, error: msg };
      }
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      try {
        await getBackendClient().proxyDelete<{ ok: boolean }>(`/api/v1/agents/cloud/${input.id}`);
        return { success: true as const, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Failed to delete cloud agent';
        return { success: false as const, error: msg };
      }
    }),
});

const agentJourneyRouter = router({
  start: publicProcedure
    .input(z.object({
      agentType: z.enum(['local_directory', 'gmail', 'teams', 'outlook']),
      dataTypes: z.array(z.string()),
      directory: z.string().optional(),
      existingConfig: z.string().optional(),
    }))
    .mutation(async ({ input }) => {
      try {
        const sessionId = crypto.randomUUID();
        const result = await getBackendClient().sendJourneyStart(
          sessionId,
          input.agentType,
          input.dataTypes,
          input.directory,
          input.existingConfig,
        );
        return { data: { sessionId: result.sessionId, message: result.message, done: result.done, agentConfig: result.agentConfig ?? undefined }, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Failed to start journey';
        return { data: null, error: msg };
      }
    }),

  message: publicProcedure
    .input(z.object({ sessionId: z.string(), message: z.string() }))
    .mutation(async ({ input }) => {
      try {
        const result = await getBackendClient().sendJourneyMessage(input.sessionId, input.message);
        return { data: { sessionId: result.sessionId, message: result.message, done: result.done, agentConfig: result.agentConfig ?? undefined }, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Failed to send journey message';
        return { data: null, error: msg };
      }
    }),
});

const agentRouter = router({
  /** Agent catalog — available agent types from the backend. */
  catalog: publicProcedure.query(async () => {
    try {
      return await getBackendClient().proxyGet<AgentCatalogItem[]>('/api/v1/agents/catalog');
    } catch (err) {
      const msg = err instanceof Error ? err.message : 'Failed to load catalog';
      console.error('[Agent] catalog error:', msg);
      return [];
    }
  }),

  local: agentLocalRouter,
  cloud: agentCloudRouter,

  /** Run history — queries local SQLite (data written by backend-client on tool_call/run_complete). */
  runs: publicProcedure
    .input(z.object({
      agentId: z.string(),
      limit: z.number().int().min(1).max(100).optional(),
      offset: z.number().int().min(0).optional(),
    }))
    .query(async ({ input }) => {
      try {
        const db = getDb();
        const limit = input.limit ?? 20;
        const offset = input.offset ?? 0;
        const rows = await db
          .select()
          .from(agentRuns)
          .where(eq(agentRuns.agentId, input.agentId))
          .orderBy(desc(agentRuns.startedAt))
          .limit(limit)
          .offset(offset);

        // Compute per-run action counts in one query
        const runIds = rows.map(r => r.id);
        const actionRows = runIds.length > 0
          ? await db.select({ runId: agentRunActions.runId, verb: agentRunActions.verb, entityType: agentRunActions.entityType })
              .from(agentRunActions)
              .where(inArray(agentRunActions.runId, runIds))
          : [];

        type ActionCounts = { created: number; updated: number; deleted: number };
        const countsByRun = new Map<string, ActionCounts>();
        for (const a of actionRows) {
          if (!countsByRun.has(a.runId)) countsByRun.set(a.runId, { created: 0, updated: 0, deleted: 0 });
          const c = countsByRun.get(a.runId)!;
          if (a.verb === 'created') c.created++;
          else if (a.verb === 'updated') c.updated++;
          else if (a.verb === 'deleted') c.deleted++;
        }

        return rows.map(r => ({
          ...r,
          actionCounts: countsByRun.get(r.id) ?? { created: 0, updated: 0, deleted: 0 },
        }));
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Failed to load run history';
        console.error('[Agent] runs error:', msg);
        return [];
      }
    }),

  /** Individual actions for a specific run — loaded lazily when a run is expanded. */
  runActions: publicProcedure
    .input(z.object({ runId: z.string() }))
    .query(async ({ input }) => {
      try {
        const db = getDb();
        return await db
          .select()
          .from(agentRunActions)
          .where(eq(agentRunActions.runId, input.runId))
          .orderBy(asc(agentRunActions.createdAt));
      } catch (err) {
        console.error('[Agent] runActions error:', err);
        return [];
      }
    }),

  /** Check whether the user's plan allows creating a new agent. */
  canCreate: publicProcedure.mutation(async () => {
    try {
      const activeAgents = getLocalAgents().length;
      const result = await getBackendClient().proxyPost<{ allowed: boolean; tier: string; activeAgents: number; limit: number }>(
        '/api/v1/agents/can-create',
        { activeAgents },
      );
      return { data: result, error: null };
    } catch (err) {
      const msg = err instanceof Error ? err.message : 'Failed to check agent quota';
      return { data: null, error: msg };
    }
  }),

  /** Manually trigger a local agent run via the BE two-phase runner. */
  runNow: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      try {
        const agent = getLocalAgent(input.id);
        if (!agent) return { data: null, error: 'Agent not found' };
        const activeAgents = getLocalAgents().length;
        console.log(
          `[agents.runNow] Triggering agent "${agent.name}" (id=${agent.id}) with lastRunAt=${agent.lastRunAt} (${agent.lastRunAt ? new Date(agent.lastRunAt).toISOString() : 'null'})`,
        );
        const result = await getBackendClient().proxyPost<{ id: string }>(
          '/api/v1/agents/trigger',
          {
            directory: agent.directory,
            deviceId: getDeviceId(),
            agentId: agent.id,
            whatToExtract: agent.dataTypes,
            batchInterval: agent.scheduleCron,
            agentConfig: agent.agentConfig ?? undefined,
            activeAgents,
            lastRunAt: agent.lastRunAt ?? undefined,
          },
        );
        // Create the run row so it appears in history even with zero mutations
        if (result?.id) {
          try {
            await getDb().insert(agentRuns).values({
              id: result.id,
              agentId: agent.id,
              status: 'running',
              startedAt: Date.now(),
            }).onConflictDoNothing();
          } catch { /* ignore */ }
        }
        return { data: result, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Failed to trigger agent run';
        return { data: null, error: msg };
      }
    }),

  journey: agentJourneyRouter,
});

// ---------------------------------------------------------------------------
// Auth router — backend authentication
// ---------------------------------------------------------------------------

const authRouter = router({
  register: publicProcedure
    .input(z.object({
      email: z.string().email(),
      password: z.string().min(1),
      name: z.string().optional(),
      surname: z.string().optional(),
    }))
    .mutation(async ({ input }) => {
      try {
        const auth = getAuthManager();
        await auth.register(input.email, input.password, input.name, input.surname);
        return { success: true as const, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Registration failed';
        return { success: false as const, error: msg };
      }
    }),

  login: publicProcedure
    .input(z.object({ email: z.string().email(), password: z.string().min(1) }))
    .mutation(async ({ input }) => {
      try {
        const auth = getAuthManager();
        await auth.login(input.email, input.password);
        // Connect persistent device WS now that we have a valid token
        void getBackendClient().connectPersistent();
        return { success: true as const, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'Login failed';
        return { success: false as const, error: msg };
      }
    }),

  logout: publicProcedure.mutation(async () => {
    const auth = getAuthManager();
    await auth.logout();
    // Disconnect persistent WS
    getBackendClient().disconnectPersistent();
    return { success: true as const };
  }),

  /** Returns current auth status + profile (if authenticated). */
  status: publicProcedure.query(async () => {
    const auth = getAuthManager();
    const authenticated = await auth.isAuthenticated();
    if (!authenticated) {
      return { authenticated: false as const, profile: null };
    }
    try {
      const profile = await auth.getProfile();

      // Auto-seed format prefs on first auth.status call
      if (getFormatPrefs() === null) {
        setFormatPrefs(detectFormatPrefs());
      }

      // Auto-seed language in MemoryCore if missing (fire-and-forget)
      if (profile && (!profile.memory?.language)) {
        // eslint-disable-next-line @typescript-eslint/no-empty-function
        auth.updateMemory({ language: detectLanguage() }).catch(() => {});
      }

      // Auto-seed user_name in MemoryCore if missing (fire-and-forget)
      if (profile && !profile.memory?.user_name) {
        const fullName = [profile.name, profile.surname].filter(Boolean).join(' ');
        if (fullName) {
          // eslint-disable-next-line @typescript-eslint/no-empty-function
          auth.updateMemory({ user_name: fullName }).catch(() => {});
        }
      }

      return { authenticated: true as const, profile };
    } catch (err) {
      // Token stored but backend unreachable or token invalid
      if (err instanceof AuthError && err.statusCode === 401) {
        await auth.logout();
        // Also tear down persistent WS
        getBackendClient().disconnectPersistent();
        return { authenticated: false as const, profile: null };
      }
      return { authenticated: true as const, profile: null };
    }
  }),

  setBackendUrl: publicProcedure
    .input(z.object({ url: z.string().url() }))
    .mutation(({ input }) => {
      getStore().set('backendUrl', input.url);
      return { success: true as const };
    }),

  getBackendUrl: publicProcedure.query(() => {
    return getStore().get('backendUrl');
  }),

  updateProfile: publicProcedure
    .input(z.object({ name: z.string().optional(), surname: z.string().optional() }))
    .mutation(async ({ input }) => {
      const auth = getAuthManager();
      const profile = await auth.updateProfile(input);
      return profile;
    }),

  updateMemory: publicProcedure
    .input(z.object({
      memory: z.record(z.string(), z.string()),
      markOnboarded: z.boolean().optional().default(false),
    }))
    .mutation(async ({ input }) => {
      const auth = getAuthManager();
      return auth.updateMemory(input.memory, input.markOnboarded);
    }),

  normalizeOnboarding: publicProcedure
    .input(z.object({
      inputs: z.record(z.string(), z.string()),
    }))
    .mutation(async ({ input }) => {
      const auth = getAuthManager();
      return auth.normalizeOnboarding(input.inputs);
    }),

  resetOnboarding: publicProcedure
    .mutation(async () => {
      const auth = getAuthManager();
      return auth.resetOnboarding();
    }),

  changePassword: publicProcedure
    .input(z.object({ currentPassword: z.string(), newPassword: z.string().min(8) }))
    .mutation(async ({ input }) => {
      const auth = getAuthManager();
      await auth.changePassword(input.currentPassword, input.newPassword);
      return { success: true as const };
    }),

  listOAuthAccounts: publicProcedure.query(async () => {
    const auth = getAuthManager();
    return auth.listOAuthAccounts();
  }),

  unlinkOAuthAccount: publicProcedure
    .input(z.object({ provider: z.string() }))
    .mutation(async ({ input }) => {
      const auth = getAuthManager();
      await auth.unlinkOAuthAccount(input.provider);
      return { success: true as const };
    }),

  updateAvatar: publicProcedure
    .input(z.object({ avatarUrl: z.string().min(1) }))
    .mutation(async ({ input }) => {
      const auth = getAuthManager();
      return auth.updateAvatar(input.avatarUrl);
    }),

  deleteAccount: publicProcedure.mutation(async () => {
    const auth = getAuthManager();
    await auth.deleteAccount();
    await auth.logout();
    getBackendClient().disconnectPersistent();
    return { success: true as const };
  }),

  // ── Billing (proxied to backend) ──────────────────────────────────

  getSubscription: publicProcedure.query(async () => {
    const auth = getAuthManager();
    return auth.getSubscription();
  }),

  createCheckout: publicProcedure
    .input(z.object({ tier: z.string() }))
    .mutation(async ({ input }) => {
      const auth = getAuthManager();
      return auth.createCheckout(input.tier);
    }),

  cancelSubscription: publicProcedure.mutation(async () => {
    const auth = getAuthManager();
    await auth.cancelSubscription();
    return { success: true as const };
  }),

  listInvoices: publicProcedure.query(async () => {
    const auth = getAuthManager();
    return auth.listInvoices();
  }),

  /**
   * Start an OAuth social login flow.
   *
   * Opens the system browser at the provider's consent screen and waits
   * (up to 5 min) for the adiuvai:// deep-link callback to arrive.
   * On success the tokens are persisted and the persistent device WS is
   * connected, matching the behaviour of a normal password login.
   */
  loginWithOAuth: publicProcedure
    .input(z.object({ provider: z.enum(['google']) }))
    .mutation(async ({ input }) => {
      try {
        const auth = getAuthManager();
        await auth.loginWithOAuth(input.provider);
        void getBackendClient().connectPersistent();
        return { success: true as const, error: null };
      } catch (err) {
        const msg = err instanceof Error ? err.message : 'OAuth login failed';
        return { success: false as const, error: msg };
      }
    }),
});

const memoryRouter = router({
  getCore: publicProcedure.query(async () => {
    const auth = getAuthManager();
    return auth.getCoreMemory();
  }),

  addCore: publicProcedure
    .input(z.object({ key: z.string().min(1), value: z.string().min(1) }))
    .mutation(async ({ input }) => {
      const auth = getAuthManager();
      return auth.addCoreKey(input.key, input.value);
    }),

  deleteCore: publicProcedure
    .input(z.object({ key: z.string().min(1) }))
    .mutation(async ({ input }) => {
      const auth = getAuthManager();
      await auth.deleteCoreKey(input.key);
      return { success: true as const };
    }),

  listRelational: publicProcedure.query(async () => {
    const auth = getAuthManager();
    return auth.getRelationalMemory();
  }),

  patchRelation: publicProcedure
    .input(z.object({
      id: z.string(),
      subjectLabel: z.string().optional(),
      objectLabel: z.string().optional(),
      predicate: z.string().optional(),
      confidence: z.number().min(0).max(1).optional(),
    }))
    .mutation(async ({ input }) => {
      const { id, ...patch } = input;
      const auth = getAuthManager();
      return auth.patchRelation(id, patch);
    }),

  deleteRelation: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      const auth = getAuthManager();
      await auth.deleteRelation(input.id);
      return { success: true as const };
    }),

  forgetAll: publicProcedure.mutation(async () => {
    const auth = getAuthManager();
    await auth.forgetAll();
    return { success: true as const };
  }),
});

const noteEditsRouter = router({
  list: publicProcedure
    .input(z.object({ noteId: z.string() }))
    .query(({ input }) => {
      return getDb()
        .select()
        .from(noteEdits)
        .where(eq(noteEdits.noteId, input.noteId))
        .orderBy(asc(noteEdits.createdAt))
        .all();
    }),

  listPending: publicProcedure
    .input(z.object({ noteId: z.string() }))
    .query(({ input }) => {
      return getDb()
        .select()
        .from(noteEdits)
        .where(and(eq(noteEdits.noteId, input.noteId), eq(noteEdits.status, 'pending')))
        .orderBy(asc(noteEdits.createdAt))
        .all();
    }),

  approve: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      const edit = getDb().select().from(noteEdits).where(eq(noteEdits.id, input.id)).get();
      if (!edit) throw new Error(`NoteEdit ${input.id} not found`);
      if (edit.status !== 'pending') throw new Error(`NoteEdit ${input.id} is not pending`);

      const note = getDb().select().from(notes).where(eq(notes.id, edit.noteId)).get();
      if (!note) throw new Error(`Note ${edit.noteId} not found`);

      let newContent = note.content;
      switch (edit.type) {
        case 'append':
          newContent = `${note.content}\n\n${edit.proposedContent}`;
          break;
        case 'insert': {
          const anchor = edit.anchorBefore;
          if (anchor && note.content.includes(anchor)) {
            newContent = note.content.replace(anchor, `${anchor}\n${edit.proposedContent}`);
          } else {
            // Fallback: append when anchor not found
            newContent = `${note.content}\n\n${edit.proposedContent}`;
          }
          break;
        }
        case 'replace': {
          const target = edit.anchorText;
          if (target && note.content.includes(target)) {
            newContent = note.content.replace(target, edit.proposedContent);
          } else {
            // Anchor missing — reject instead of corrupting the note
            getDb().update(noteEdits)
              .set({ status: 'rejected', resolvedAt: Date.now() })
              .where(eq(noteEdits.id, input.id))
              .run();
            return { approved: false, reason: 'anchor_not_found' };
          }
          break;
        }
      }

      const now = Date.now();
      getDb().update(notes).set({ content: newContent, updatedAt: now }).where(eq(notes.id, edit.noteId)).run();
      getDb().update(noteEdits).set({ status: 'approved', resolvedAt: now }).where(eq(noteEdits.id, input.id)).run();

      return { approved: true, noteId: edit.noteId };
    }),

  reject: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(({ input }) => {
      const now = Date.now();
      getDb().update(noteEdits)
        .set({ status: 'rejected', resolvedAt: now })
        .where(eq(noteEdits.id, input.id))
        .run();
      return { rejected: true };
    }),
});

const taskAttachmentsRouter = router({
  list: publicProcedure
    .input(z.object({ taskId: z.string() }))
    .query(({ input }) => {
      return getDb()
        .select()
        .from(taskAttachments)
        .where(eq(taskAttachments.taskId, input.taskId))
        .orderBy(asc(taskAttachments.createdAt))
        .all();
    }),

  pick: publicProcedure.mutation(async () => {
    const result = await dialog.showOpenDialog({
      properties: ['openFile', 'multiSelections'],
    });
    if (result.canceled) return [];
    const out: Array<{ path: string; name: string; size: number }> = [];
    for (const p of result.filePaths) {
      const s = await stat(p);
      out.push({ path: p, name: p.split(/[\\/]/).pop() ?? p, size: s.size });
    }
    return out;
  }),

  create: publicProcedure
    .input(z.object({
      taskId: z.string(),
      sourcePath: z.string(),
      filename: z.string(),
      sizeBytes: z.number().int().nonnegative(),
      mimeType: z.string().optional(),
    }))
    .mutation(async ({ input }) => {
      const db = getDb();
      const { storedPath } = await copyIntoTask(input.taskId, input.sourcePath, input.filename);
      const row = {
        id: randomUUID(),
        taskId: input.taskId,
        filename: input.filename,
        mimeType: input.mimeType ?? null,
        sizeBytes: input.sizeBytes,
        storedPath,
        createdAt: Date.now(),
      };
      db.insert(taskAttachments).values(row).run();
      return row;
    }),

  delete: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      const db = getDb();
      const row = db
        .select()
        .from(taskAttachments)
        .where(eq(taskAttachments.id, input.id))
        .get();
      if (!row) return { ok: false };
      await deleteStored(row.storedPath);
      db.delete(taskAttachments).where(eq(taskAttachments.id, input.id)).run();
      return { ok: true };
    }),

  open: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      const db = getDb();
      const row = db
        .select()
        .from(taskAttachments)
        .where(eq(taskAttachments.id, input.id))
        .get();
      if (!row) return { ok: false };
      const err = await shell.openPath(absolutePath(row.storedPath));
      return { ok: err === '' };
    }),
});

export const appRouter = router({
  health: healthRouter,
  settings: settingsRouter,
  clients: clientsRouter,
  projects: projectsRouter,
  tasks: tasksRouter,
  timelineEvents: timelineEventsRouter,
  timelineEventDependencies: timelineEventDependenciesRouter,
  notes: notesRouter,
  noteEdits: noteEditsRouter,
  taskComments: taskCommentsRouter,
  taskAttachments: taskAttachmentsRouter,
  ai: aiRouter,
  auth: authRouter,
  agent: agentRouter,
  memory: memoryRouter,
  projectFolders: projectFoldersRouter,
});

export type AppRouter = typeof appRouter;
