/**
 * Drizzle executor — the "dumb" local data layer.
 *
 * Receives structured `WsToolCall` frames from the backend WebSocket and maps
 * them to Drizzle ORM calls against the local SQLite database.
 *
 * Security: table name and action are validated against an allowlist before
 * any database operation is performed. The backend never generates IDs —
 * the executor generates UUID v4 + timestamps for all inserts.
 *
 * @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.4
 */

import * as fs from 'fs';
import * as path from 'path';
import { eq, and, or, like, isNull, asc, desc, gte, lte, sql, SQL } from 'drizzle-orm';
import { getDb } from '../db';
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
import type { WsToolCall } from '../../shared/api-types';

// ---------------------------------------------------------------------------
// Table registry — the only tables the backend may touch
// ---------------------------------------------------------------------------

const TABLE_REGISTRY = {
  tasks,
  projects,
  clients,
  notes,
  taskComments,
  timelineEvents,
  // Alias: the backend sends "timelines" as the table name
  timelines: timelineEvents,
  projectFolderFiles,
} as const;

type TableName = keyof typeof TABLE_REGISTRY;
type AnyTable = (typeof TABLE_REGISTRY)[TableName];

// ---------------------------------------------------------------------------
// Filesystem constants
// ---------------------------------------------------------------------------

/** Maximum file content size returned by read_file_content (500 KB). */
const MAX_READ_SIZE_BYTES = 500 * 1024;

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

export class ExecutorError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ExecutorError';
  }
}

// ---------------------------------------------------------------------------
// Filter builder
// ---------------------------------------------------------------------------

/** Keys that are handled explicitly and should not be treated as direct column matchers. */
const RESERVED_KEYS = new Set(['search', 'orderBy', 'orderDir', 'includeArchived', 'limit', 'offset']);

const RANGE_FROM_RE = /^(.+)From$/;
const RANGE_TO_RE = /^(.+)To$/;

function buildConditions(
  table: AnyTable,
  filters: Record<string, unknown>,
): SQL[] {
  const conditions: SQL[] = [];
  const tbl = table as unknown as Record<string, unknown>;

  for (const [key, value] of Object.entries(filters)) {
    if (RESERVED_KEYS.has(key)) continue;

    // Generic *From / *To range filters — e.g. dueDateFrom, createdAtFrom, dateFrom, completedAtTo
    const fromMatch = RANGE_FROM_RE.exec(key);
    if (fromMatch) {
      const colName = fromMatch[1]!;
      const col = tbl[colName];
      if (col && value != null) {
        conditions.push(gte(col as Parameters<typeof gte>[0], Number(value)));
      }
      continue;
    }
    const toMatch = RANGE_TO_RE.exec(key);
    if (toMatch) {
      const colName = toMatch[1]!;
      const col = tbl[colName];
      if (col && value != null) {
        conditions.push(lte(col as Parameters<typeof lte>[0], Number(value)));
      }
      continue;
    }

    const col = tbl[key];
    if (!col) continue; // Unknown column — skip silently

    if (value === null) {
      conditions.push(isNull(col as Parameters<typeof isNull>[0]));
    } else {
      conditions.push(eq(col as Parameters<typeof eq>[0], value as Parameters<typeof eq>[1]));
    }
  }

  // Search across title and/or content
  if (filters['search'] != null) {
    const pattern = `%${String(filters['search'])}%`;
    const titleCol = tbl['title'];
    const contentCol = tbl['content'];
    if (titleCol && contentCol) {
      conditions.push(
        or(
          like(titleCol as Parameters<typeof like>[0], pattern),
          like(contentCol as Parameters<typeof like>[0], pattern),
        )!,
      );
    } else if (titleCol) {
      conditions.push(like(titleCol as Parameters<typeof like>[0], pattern));
    } else if (contentCol) {
      conditions.push(like(contentCol as Parameters<typeof like>[0], pattern));
    }
  }

  // includeArchived: false → restrict to active status
  if (filters['includeArchived'] === false) {
    const statusCol = tbl['status'];
    if (statusCol) {
      conditions.push(eq(statusCol as Parameters<typeof eq>[0], 'active'));
    }
  }

  return conditions;
}

function buildOrderBy(
  table: AnyTable,
  filters: Record<string, unknown>,
): SQL | undefined {
  const field = filters['orderBy'];
  if (!field || typeof field !== 'string') return undefined;

  const tbl = table as unknown as Record<string, unknown>;
  const col = tbl[field];
  if (!col) return undefined;

  const dir = filters['orderDir'];
  return dir === 'desc'
    ? desc(col as Parameters<typeof desc>[0])
    : asc(col as Parameters<typeof asc>[0]);
}

// ---------------------------------------------------------------------------
// Executor
// ---------------------------------------------------------------------------

export class DrizzleExecutor {
  private getTable(name: string): AnyTable {
    if (!(name in TABLE_REGISTRY)) {
      throw new ExecutorError(`Unknown table: "${name}". Allowed: ${Object.keys(TABLE_REGISTRY).join(', ')}`);
    }
    return TABLE_REGISTRY[name as TableName];
  }

  async execute(payload: WsToolCall): Promise<Record<string, unknown>> {
    const { action } = payload;

    switch (action) {
      case 'select':
        return this.handleSelect(payload);
      case 'get':
        return this.handleGet(payload);
      case 'insert':
        return this.handleInsert(payload);
      case 'update':
        return this.handleUpdate(payload);
      case 'delete':
        return this.handleDelete(payload);
      case 'count':
        return this.handleCount(payload);
      case 'propose_note_edit':
        return this.handleProposeNoteEdit(payload);
      case 'list_directory':
        return this.handleListDirectory(payload);
      case 'read_file_content':
        return this.handleReadFileContent(payload);
      case 'get_file_metadata':
        return this.handleGetFileMetadata(payload);
      case 'read_project_folder_manifest':
        return this.handleReadProjectFolderManifest(payload);
      case 'read_project_folder_file':
        return this.handleReadProjectFolderFile(payload);
      case 'list_projects_with_folder_manifests':
        return this.handleListProjectsWithFolderManifests();
      default:
        throw new ExecutorError(`Unknown action: "${action as string}"`);
    }
  }

  // -------------------------------------------------------------------------
  // Action handlers
  // -------------------------------------------------------------------------

  private handleSelect(payload: WsToolCall): Record<string, unknown> {
    const table = this.getTable(payload.table!);
    const filters = (payload.filters ?? {}) as Record<string, unknown>;
    const conditions = buildConditions(table, filters);
    const orderBy = buildOrderBy(table, filters);

    const query = getDb().select().from(table);
    const withWhere = conditions.length > 0
      ? query.where(and(...conditions.filter(Boolean as unknown as (x: SQL) => x is SQL))!)
      : query;
    const withOrder = orderBy ? withWhere.orderBy(orderBy) : withWhere;

    // Default limit of 50 prevents context flooding for AI tool calls
    const limit = filters['limit'] != null ? Number(filters['limit']) : 50;
    const offset = filters['offset'] != null ? Number(filters['offset']) : 0;
    const rows = withOrder.limit(limit).offset(offset).all();

    return { rows };
  }

  private handleCount(payload: WsToolCall): Record<string, unknown> {
    const table = this.getTable(payload.table!);
    const filters = (payload.filters ?? {}) as Record<string, unknown>;
    const conditions = buildConditions(table, filters);

    const query = getDb().select({ count: sql<number>`count(*)` }).from(table);
    const withWhere = conditions.length > 0
      ? query.where(and(...conditions.filter(Boolean as unknown as (x: SQL) => x is SQL))!)
      : query;

    const result = withWhere.get();
    return { count: Number((result as { count: number } | undefined)?.count ?? 0) };
  }

  private handleGet(payload: WsToolCall): Record<string, unknown> {
    const table = this.getTable(payload.table!);
    const data = (payload.data ?? {}) as Record<string, unknown>;
    const id = data['id'];
    if (!id) throw new ExecutorError('"data.id" is required for get');

    const tbl = table as unknown as Record<string, unknown>;
    const idCol = tbl['id'] as Parameters<typeof eq>[0];
    const row = getDb().select().from(table).where(eq(idCol, id as string)).get() ?? null;
    return { row };
  }

  private handleInsert(payload: WsToolCall): Record<string, unknown> {
    const table = this.getTable(payload.table!);
    const data = (payload.data ?? {}) as Record<string, unknown>;
    const now = Date.now();

    // Auto-set completedAt for tables that have the column
    const completedAtPatch =
      ('completedAt' in table && !('completedAt' in data) &&
        (data['status'] === 'done' || data['isCompleted'] === 1))
        ? { completedAt: now }
        : {};

    const values = {
      id: crypto.randomUUID(),
      ...data,
      createdAt: now,
      ...(('updatedAt' in table) ? { updatedAt: now } : {}),
      ...completedAtPatch,
    };

    const row = getDb().insert(table).values(values).returning().get() ?? null;
    return { row };
  }

  private handleUpdate(payload: WsToolCall): Record<string, unknown> {
    const table = this.getTable(payload.table!);
    const data = (payload.data ?? {}) as Record<string, unknown>;
    const id = data['id'];
    if (!id) throw new ExecutorError('"data.id" is required for update');

    const updates = (data['updates'] ?? {}) as Record<string, unknown>;
    const now = Date.now();

    const baseTimestamp = ('updatedAt' in table)
      ? { ...updates, updatedAt: now }
      : updates;

    // Auto-set completedAt when status/isCompleted changes, unless caller provided it explicitly
    const completedAtPatch: Record<string, unknown> = {};
    if ('completedAt' in table && !('completedAt' in updates)) {
      if (updates['status'] === 'done' || updates['isCompleted'] === 1) {
        completedAtPatch['completedAt'] = now;
      } else if (updates['status'] !== undefined || updates['isCompleted'] !== undefined) {
        completedAtPatch['completedAt'] = null;
      }
    }

    const withTimestamp = { ...baseTimestamp, ...completedAtPatch };

    const tbl = table as unknown as Record<string, unknown>;
    const idCol = tbl['id'] as Parameters<typeof eq>[0];
    const row = getDb()
      .update(table)
      .set(withTimestamp)
      .where(eq(idCol, id as string))
      .returning()
      .get() ?? null;

    return { row };
  }

  private handleDelete(payload: WsToolCall): Record<string, unknown> {
    const table = this.getTable(payload.table!);
    const data = (payload.data ?? {}) as Record<string, unknown>;
    const id = data['id'];
    if (!id) throw new ExecutorError('"data.id" is required for delete');

    const tbl = table as unknown as Record<string, unknown>;
    const idCol = tbl['id'] as Parameters<typeof eq>[0];
    getDb().delete(table).where(eq(idCol, id as string)).run();

    return { deleted: true };
  }

  private handleProposeNoteEdit(payload: WsToolCall): Record<string, unknown> {
    const data = (payload.data ?? {}) as Record<string, unknown>;
    const noteId = data['noteId'] as string | undefined;
    const type = data['type'] as string | undefined;
    const proposedContent = data['proposedContent'] as string | undefined;

    if (!noteId) throw new ExecutorError('"data.noteId" is required for propose_note_edit');
    if (!type) throw new ExecutorError('"data.type" is required for propose_note_edit');
    if (!proposedContent) throw new ExecutorError('"data.proposedContent" is required for propose_note_edit');

    const values = {
      id: crypto.randomUUID(),
      noteId,
      type,
      proposedContent,
      anchorBefore: (data['anchorBefore'] as string | null) ?? null,
      anchorText: (data['anchorText'] as string | null) ?? null,
      reasoning: (data['reasoning'] as string | null) ?? null,
      agentId: (data['agentId'] as string | null) ?? null,
      runId: (data['runId'] as string | null) ?? null,
      status: 'pending' as const,
      createdAt: Date.now(),
      resolvedAt: null,
    };

    const row = getDb().insert(noteEdits).values(values).returning().get() ?? null;
    return { id: values.id, row };
  }

  // -------------------------------------------------------------------------
  // Filesystem handlers
  // -------------------------------------------------------------------------

  private async handleListDirectory(payload: WsToolCall): Promise<Record<string, unknown>> {
    const data = (payload.data ?? {}) as Record<string, unknown>;
    const dirPath = data['path'] as string | undefined;
    if (!dirPath) throw new ExecutorError('"data.path" is required for list_directory');

    const resolved = await fs.promises.realpath(path.resolve(dirPath));

    let dirents: fs.Dirent[];
    try {
      dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
    } catch (err) {
      throw new ExecutorError(
        `Cannot read directory ${dirPath}: ${err instanceof Error ? err.message : String(err)}`,
      );
    }

    const entries = dirents.map((d) => ({
      name: d.name,
      type: d.isDirectory() ? 'directory' : 'file',
      path: path.join(resolved, d.name),
    }));

    return { entries };
  }

  private async handleReadFileContent(payload: WsToolCall): Promise<Record<string, unknown>> {
    const data = (payload.data ?? {}) as Record<string, unknown>;
    const filePath = data['path'] as string | undefined;
    if (!filePath) throw new ExecutorError('"data.path" is required for read_file_content');

    const resolved = await fs.promises.realpath(path.resolve(filePath));

    let stat: fs.Stats;
    try {
      stat = await fs.promises.stat(resolved);
    } catch (err) {
      throw new ExecutorError(
        `Cannot stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
      );
    }

    if (!stat.isFile()) {
      throw new ExecutorError(`Not a file: ${filePath}`);
    }

    let content: string;
    if (stat.size > MAX_READ_SIZE_BYTES) {
      // Read only the first MAX_READ_SIZE_BYTES to prevent context saturation
      const buf = Buffer.alloc(MAX_READ_SIZE_BYTES);
      const fd = await fs.promises.open(resolved, 'r');
      try {
        await fd.read(buf, 0, MAX_READ_SIZE_BYTES, 0);
      } finally {
        await fd.close();
      }
      content = buf.toString('utf8') + '\n[…truncated]';
    } else {
      content = await fs.promises.readFile(resolved, 'utf8');
    }

    return { content };
  }

  private async handleGetFileMetadata(payload: WsToolCall): Promise<Record<string, unknown>> {
    const data = (payload.data ?? {}) as Record<string, unknown>;
    const filePath = data['path'] as string | undefined;
    if (!filePath) throw new ExecutorError('"data.path" is required for get_file_metadata');

    const resolved = await fs.promises.realpath(path.resolve(filePath));

    let stat: fs.Stats;
    try {
      stat = await fs.promises.stat(resolved);
    } catch (err) {
      throw new ExecutorError(
        `Cannot stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
      );
    }

    return {
      name: path.basename(resolved),
      extension: path.extname(resolved).toLowerCase(),
      size: stat.size,
      createdAt: stat.birthtime.toISOString(),
      modifiedAt: stat.mtime.toISOString(),
    };
  }

  // -------------------------------------------------------------------------
  // Project folder handlers
  // -------------------------------------------------------------------------

  private handleReadProjectFolderManifest(payload: WsToolCall): Record<string, unknown> {
    const { projectId } = (payload.data ?? {}) as { projectId: string };
    const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
    if (!proj?.folderPath) return { folderPath: null, lastScannedAt: null, files: [] };

    const files = getDb()
      .select({
        relPath: projectFolderFiles.relativePath,
        kind: projectFolderFiles.kind,
        summary: projectFolderFiles.summary,
        mtimeMs: projectFolderFiles.mtimeMs,
      })
      .from(projectFolderFiles)
      .where(eq(projectFolderFiles.projectId, projectId))
      .all();

    // On-demand mtime check: if not currently scanning, fire-and-forget rescan when deltas exist.
    // Returns the current (possibly stale) manifest immediately; the rescan updates rows
    // for the next call.
    if (proj.folderLastScanStatus !== 'scanning') {
      void import('../files/scanner')
        .then(async ({ scanFolder }) => {
          const delta = await scanFolder(projectId, proj.folderPath!);
          if (
            delta.newFiles.length > 0 ||
            delta.changedFiles.length > 0 ||
            delta.deletedRelPaths.length > 0
          ) {
            const { startIndexSession } = await import('../files/indexer');
            // eslint-disable-next-line @typescript-eslint/no-empty-function
            void startIndexSession(projectId, () => {});
          }
        })
        // eslint-disable-next-line @typescript-eslint/no-empty-function
        .catch(() => {});
    }

    return {
      folderPath: proj.folderPath,
      lastScannedAt: proj.folderLastScannedAt,
      files,
    };
  }

  private async handleReadProjectFolderFile(payload: WsToolCall): Promise<Record<string, unknown>> {
    const { projectId, relativePath, offset, length } = (payload.data ?? {}) as {
      projectId: string;
      relativePath: string;
      offset?: number;
      length?: number;
    };

    if (!relativePath || relativePath.includes('..') || path.isAbsolute(relativePath)) {
      throw new ExecutorError('Access denied');
    }

    const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
    if (!proj?.folderPath) return { content: '', kind: 'missing', totalSize: 0 };

    const abs = path.join(proj.folderPath, relativePath);
    if (!path.resolve(abs).startsWith(path.resolve(proj.folderPath))) {
      throw new ExecutorError('Access denied');
    }

    try {
      const stat = await fs.promises.stat(abs);
      const ext = path.extname(relativePath).toLowerCase();

      if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
        const buf = await fs.promises.readFile(abs);
        return { content: buf.toString('base64'), kind: 'image', totalSize: stat.size };
      }

      // PDF + DOCX: return full base64; backend extracts text + slices.
      if (ext === '.pdf' || ext === '.docx') {
        const buf = await fs.promises.readFile(abs);
        return {
          content: buf.toString('base64'),
          kind: ext === '.pdf' ? 'pdf' : 'docx',
          totalSize: stat.size,
        };
      }

      // Text: slice at offset/length on Electron side to keep WS payload small.
      const start = Math.max(0, offset ?? 0);
      const want = Math.max(1, Math.min(length ?? MAX_READ_SIZE_BYTES, MAX_READ_SIZE_BYTES));
      const end = Math.min(start + want, stat.size);
      const len = Math.max(0, end - start);
      if (len === 0) {
        return { content: '', kind: 'text', totalSize: stat.size };
      }
      const buf = Buffer.alloc(len);
      const fd = await fs.promises.open(abs, 'r');
      try {
        await fd.read(buf, 0, len, start);
      } finally {
        await fd.close();
      }
      return { content: buf.toString('utf8'), kind: 'text', totalSize: stat.size };
    } catch {
      return { content: '', kind: 'error', totalSize: 0 };
    }
  }

  private handleListProjectsWithFolderManifests(): Record<string, unknown> {
    const projs = getDb()
      .select()
      .from(projects)
      .where(sql`${projects.folderPath} IS NOT NULL`)
      .all();

    const out: Array<unknown> = [];
    for (const p of projs) {
      const files = getDb()
        .select({
          relPath: projectFolderFiles.relativePath,
          kind: projectFolderFiles.kind,
          summary: projectFolderFiles.summary,
          mtimeMs: projectFolderFiles.mtimeMs,
        })
        .from(projectFolderFiles)
        .where(eq(projectFolderFiles.projectId, p.id))
        .all();
      out.push({
        projectId: p.id,
        projectName: p.name,
        folderPath: p.folderPath,
        lastScannedAt: p.folderLastScannedAt,
        files,
      });
    }

    return { projects: out };
  }
}
