// src/index.ts
import { readFileSync } from "fs";
import { join } from "path";

// src/schedules.entity.ts
import { useIdField, useTextField, useTextareaField, useSelectField, useNumberField, useJsonField, useDatetimeField, useDescriptionField, useEnabledField, useNameField } from "@gzl10/nexus-sdk/fields";
var getService = (ctx) => ctx.services.get("schedules");
var scheduleEntity = {
  type: "collection",
  realtime: "sync",
  table: "schedules",
  routePrefix: "/",
  label: { en: "Schedules", es: "Programaciones" },
  labelPlural: { en: "Schedules", es: "Programaciones" },
  labelField: "name",
  timestamps: true,
  audit: true,
  fields: {
    id: useIdField(),
    name: useNameField(),
    description: useDescriptionField(),
    cron_expression: useTextField({ label: { en: "Cron Expression", es: "Expresi\xF3n Cron" }, required: true, size: 50 }),
    timezone: {
      label: { en: "Timezone", es: "Zona horaria" },
      input: "select",
      db: { type: "string", size: 50, nullable: false, default: "UTC" },
      options: {
        static: [
          { value: "UTC", label: { en: "UTC", es: "UTC" } },
          { value: "Europe/Madrid", label: { en: "Europe/Madrid (CET/CEST)", es: "Europe/Madrid (CET/CEST)" } },
          { value: "Europe/London", label: { en: "Europe/London (GMT/BST)", es: "Europe/London (GMT/BST)" } },
          { value: "Europe/Paris", label: { en: "Europe/Paris (CET/CEST)", es: "Europe/Paris (CET/CEST)" } },
          { value: "Europe/Berlin", label: { en: "Europe/Berlin (CET/CEST)", es: "Europe/Berlin (CET/CEST)" } },
          { value: "America/New_York", label: { en: "America/New_York (EST/EDT)", es: "America/New_York (EST/EDT)" } },
          { value: "America/Chicago", label: { en: "America/Chicago (CST/CDT)", es: "America/Chicago (CST/CDT)" } },
          { value: "America/Denver", label: { en: "America/Denver (MST/MDT)", es: "America/Denver (MST/MDT)" } },
          { value: "America/Los_Angeles", label: { en: "America/Los_Angeles (PST/PDT)", es: "America/Los_Angeles (PST/PDT)" } },
          { value: "America/Sao_Paulo", label: { en: "America/Sao_Paulo (BRT)", es: "America/Sao_Paulo (BRT)" } },
          { value: "Asia/Tokyo", label: { en: "Asia/Tokyo (JST)", es: "Asia/Tokyo (JST)" } },
          { value: "Asia/Shanghai", label: { en: "Asia/Shanghai (CST)", es: "Asia/Shanghai (CST)" } },
          { value: "Asia/Singapore", label: { en: "Asia/Singapore (SGT)", es: "Asia/Singapore (SGT)" } },
          { value: "Australia/Sydney", label: { en: "Australia/Sydney (AEST/AEDT)", es: "Australia/Sydney (AEST/AEDT)" } }
        ],
        allowCreate: true
      }
    },
    target_type: useSelectField({
      label: { en: "Target Type", es: "Tipo de destino" },
      options: [
        { value: "service_action", label: { en: "Service Action", es: "Acci\xF3n de servicio" } },
        { value: "http_internal", label: { en: "HTTP Internal", es: "HTTP Interno" } },
        { value: "http_external", label: { en: "HTTP External", es: "HTTP Externo" } },
        { value: "function_registry", label: { en: "Function Registry", es: "Registro de funciones" } }
      ],
      required: true,
      size: 20
    }),
    target_config: useJsonField({ label: { en: "Target Configuration", es: "Configuraci\xF3n del destino" }, required: true }),
    enabled: useEnabledField(),
    status: useSelectField({
      label: { en: "Status", es: "Estado" },
      options: [
        { value: "idle", label: { en: "Idle", es: "Inactivo" } },
        { value: "running", label: { en: "Running", es: "Ejecut\xE1ndose" } },
        { value: "paused", label: { en: "Paused", es: "Pausado" } },
        { value: "error", label: { en: "Error", es: "Error" } },
        { value: "retrying", label: { en: "Retrying", es: "Reintentando" } }
      ],
      defaultValue: "idle",
      required: true,
      size: 20
    }),
    last_run_at: useDatetimeField({ label: { en: "Last Run", es: "\xDAltima ejecuci\xF3n" } }),
    last_error: useTextareaField({ label: { en: "Last Error", es: "\xDAltimo error" } }),
    run_count: useNumberField({ label: { en: "Run Count", es: "N\xFAmero de ejecuciones" }, required: true, defaultValue: 0 }),
    max_retries: useNumberField({ label: { en: "Max Retries", es: "Reintentos m\xE1ximos" }, defaultValue: 0, validation: { min: 0, max: 10 } }),
    retry_count: useNumberField({ label: { en: "Retry Count", es: "Reintentos actuales" }, defaultValue: 0 }),
    retry_delay_ms: useNumberField({ label: { en: "Retry Delay (ms)", es: "Delay reintento (ms)" }, defaultValue: 5e3, validation: { min: 1e3, max: 3e5 } }),
    retry_backoff: useSelectField({
      label: { en: "Retry Backoff", es: "Estrategia reintento" },
      options: [
        { value: "exponential", label: { en: "Exponential", es: "Exponencial" } },
        { value: "fixed", label: { en: "Fixed", es: "Fijo" } },
        { value: "linear", label: { en: "Linear", es: "Lineal" } }
      ],
      defaultValue: "exponential",
      size: 20
    })
  },
  actions: [
    {
      key: "pause",
      label: { en: "Pause Schedule", es: "Pausar programaci\xF3n" },
      icon: "mdi:pause",
      method: "POST",
      select: ["id", "name", "status", "enabled"],
      handler: async (ctx, input, req) => {
        const { _record } = input;
        const service = getService(ctx);
        await service.pause(_record.id);
        ctx.events.notify("audit.log", {
          source: "plugin:schedules",
          action: "schedule.paused",
          actorId: input._authUserId,
          resourceType: "schedule",
          resourceId: _record.id,
          ip: req?.ip,
          userAgent: req?.headers?.["user-agent"],
          metadata: { name: _record.name }
        });
        return { success: true, status: "paused" };
      },
      casl: { action: "update" }
    },
    {
      key: "resume",
      label: { en: "Resume Schedule", es: "Reanudar programaci\xF3n" },
      icon: "mdi:play",
      method: "POST",
      select: ["id", "name", "status", "enabled", "cron_expression", "timezone"],
      handler: async (ctx, input, req) => {
        const { _record } = input;
        const service = getService(ctx);
        await service.resume(_record.id);
        ctx.events.notify("audit.log", {
          source: "plugin:schedules",
          action: "schedule.resumed",
          actorId: input._authUserId,
          resourceType: "schedule",
          resourceId: _record.id,
          ip: req?.ip,
          userAgent: req?.headers?.["user-agent"],
          metadata: { name: _record.name }
        });
        return { success: true, status: "idle" };
      },
      casl: { action: "update" }
    },
    {
      key: "trigger",
      label: { en: "Run Now", es: "Ejecutar ahora" },
      icon: "mdi:play-circle",
      method: "POST",
      select: ["id", "name", "status", "target_type", "target_config"],
      output: {},
      confirm: {
        type: "simple",
        message: { en: "This will execute the schedule immediately.", es: "Esto ejecutar\xE1 la programaci\xF3n inmediatamente." },
        severity: "info"
      },
      handler: async (ctx, input, req) => {
        const { _record } = input;
        const service = getService(ctx);
        const result = await service.trigger(_record.id);
        ctx.events.notify("audit.log", {
          source: "plugin:schedules",
          action: "schedule.triggered",
          actorId: input._authUserId,
          resourceType: "schedule",
          resourceId: _record.id,
          ip: req?.ip,
          userAgent: req?.headers?.["user-agent"],
          metadata: { name: _record.name }
        });
        return result;
      },
      casl: { action: "execute" }
    }
  ],
  casl: {
    subject: "Schedule",
    permissions: {
      SUPPORT: { actions: ["read"] },
      SERVICE: { actions: ["read", "execute"] }
    }
  },
  hooks: (ctx) => {
    const getService2 = () => ctx.services.get("schedules");
    return {
      afterCreate: async (record) => {
        try {
          const r = record;
          if (r["id"]) await getService2().syncJob(r["id"]);
        } catch (error) {
          ctx.core.logger.error(`Failed to sync schedule after create: ${error instanceof Error ? error.message : error}`);
        }
      },
      afterUpdate: async (record) => {
        try {
          const r = record;
          if (r["id"]) await getService2().syncJob(r["id"]);
        } catch (error) {
          ctx.core.logger.error(`Failed to sync schedule after update: ${error instanceof Error ? error.message : error}`);
        }
      },
      afterDelete: async (id) => {
        getService2().cancelJobById(id);
      }
    };
  }
};
var scheduleLogEntity = {
  type: "collection",
  realtime: "sync",
  immutable: true,
  table: "schedule_logs",
  label: { en: "Schedule Logs", es: "Registros de programaciones" },
  labelField: "schedule_id",
  timestamps: true,
  retention: { days: 30 },
  calendarFrom: "started_at",
  calendarTo: "finished_at",
  fields: {
    id: useIdField(),
    schedule_id: useTextField({ label: { en: "Schedule ID", es: "ID de programaci\xF3n" }, required: true, size: 26, hidden: true }),
    started_at: useDatetimeField({ label: { en: "Started At", es: "Iniciado en" }, required: true }),
    finished_at: useDatetimeField({ label: { en: "Finished At", es: "Finalizado en" } }),
    duration_ms: useNumberField({ label: { en: "Duration (ms)", es: "Duraci\xF3n (ms)" } }),
    status: useSelectField({
      label: { en: "Status", es: "Estado" },
      options: [
        { value: "success", label: { en: "Success", es: "\xC9xito" } },
        { value: "error", label: { en: "Error", es: "Error" } },
        { value: "skipped", label: { en: "Skipped", es: "Omitido" } },
        { value: "retrying", label: { en: "Retrying", es: "Reintentando" } },
        { value: "exhausted", label: { en: "Exhausted", es: "Agotado" } }
      ],
      required: true,
      size: 20
    }),
    result: useJsonField({ label: { en: "Result", es: "Resultado" } }),
    error: useTextareaField({ label: { en: "Error", es: "Error" } })
  },
  casl: {
    subject: "ScheduleLog",
    permissions: {
      SUPPORT: { actions: ["read"] },
      SERVICE: { actions: ["read"] }
    }
  }
};
var dashboardEntity = {
  type: "computed",
  realtime: "live",
  label: { en: "Dashboard", es: "Panel" },
  icon: "mdi:chart-box-outline",
  labelField: "total_schedules",
  isSingle: true,
  routePrefix: "/dashboard",
  fields: {
    total_schedules: useNumberField({ label: { en: "Total Schedules", es: "Total programaciones" } }),
    active: useNumberField({ label: { en: "Active", es: "Activos" } }),
    paused: useNumberField({ label: { en: "Paused", es: "Pausados" } }),
    errored: useNumberField({ label: { en: "In Error", es: "Con error" } }),
    retrying: useNumberField({ label: { en: "Retrying", es: "Reintentando" } }),
    executions_24h: useNumberField({ label: { en: "Executions (24h)", es: "Ejecuciones (24h)" } }),
    success_rate_24h: useNumberField({ label: { en: "Success Rate % (24h)", es: "Tasa \xE9xito % (24h)" } }),
    avg_duration_ms: useNumberField({ label: { en: "Avg Duration (ms)", es: "Duraci\xF3n media (ms)" } }),
    total_retries_24h: useNumberField({ label: { en: "Retries (24h)", es: "Reintentos (24h)" } }),
    exhausted_24h: useNumberField({ label: { en: "Exhausted (24h)", es: "Agotados (24h)" } }),
    next_run: useTextField({ label: { en: "Next Run", es: "Pr\xF3xima ejecuci\xF3n" } }),
    next_run_name: useTextField({ label: { en: "Next Run Name", es: "Nombre pr\xF3x. ejecuci\xF3n" } })
  },
  compute: async (ctx) => {
    const { knex, t } = ctx.db;
    const now = /* @__PURE__ */ new Date();
    const h24ago = new Date(now.getTime() - 24 * 60 * 60 * 1e3).toISOString();
    const statusCounts = await knex(t("schedules")).select("status").count("* as count").groupBy("status");
    const total = statusCounts.reduce((sum, r) => sum + Number(r.count), 0);
    const byStatus = Object.fromEntries(statusCounts.map((r) => [r.status, Number(r.count)]));
    const logStats = await knex(t("schedule_logs")).where("started_at", ">=", h24ago).select(
      knex.raw("COUNT(*) as total"),
      knex.raw("SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successes"),
      knex.raw("SUM(CASE WHEN status = 'retrying' THEN 1 ELSE 0 END) as retries"),
      knex.raw("SUM(CASE WHEN status = 'exhausted' THEN 1 ELSE 0 END) as exhausted"),
      knex.raw("AVG(duration_ms) as avg_duration")
    ).first();
    const totalExecs = Number(logStats?.total ?? 0);
    const successes = Number(logStats?.successes ?? 0);
    const successRate = totalExecs > 0 ? Math.round(successes / totalExecs * 100) : 100;
    const service = getService(ctx);
    const nextRuns = await service.getNextRuns(1);
    const nextRun = nextRuns[0];
    return [{
      total_schedules: total,
      active: (byStatus["idle"] ?? 0) + (byStatus["running"] ?? 0),
      paused: byStatus["paused"] ?? 0,
      errored: byStatus["error"] ?? 0,
      retrying: byStatus["retrying"] ?? 0,
      executions_24h: totalExecs,
      success_rate_24h: successRate,
      avg_duration_ms: Math.round(Number(logStats?.avg_duration ?? 0)),
      total_retries_24h: Number(logStats?.retries ?? 0),
      exhausted_24h: Number(logStats?.exhausted ?? 0),
      next_run: nextRun?.next_run ?? null,
      next_run_name: nextRun?.name ?? null
    }];
  },
  casl: {
    subject: "ScheduleDashboard",
    permissions: {
      SUPPORT: { actions: ["read"] },
      SERVICE: { actions: ["read"] }
    }
  },
  cache: {
    ttl: 30,
    invalidateOn: ["db.schedules.created", "db.schedules.updated", "db.schedules.deleted", "db.schedule_logs.created"]
  }
};
function requireAdmin(ctx) {
  const { UnauthorizedError, ForbiddenError } = ctx.core.errors;
  return (req, _res, next) => {
    const authReq = req;
    if (!authReq.user) throw new UnauthorizedError("AUTH_REQUIRED");
    if (!authReq.ability?.can("manage", "all")) throw new ForbiddenError("FORBIDDEN");
    next();
  };
}
var nextRunsAction = {
  key: "next-runs",
  label: { en: "Get Next Runs", es: "Obtener pr\xF3ximas ejecuciones" },
  icon: "mdi:calendar-clock",
  scope: "module",
  method: "GET",
  output: {},
  middleware: (ctx) => [ctx.core.middleware["auth"], requireAdmin(ctx)],
  input: {
    limit: useNumberField({ label: { en: "Limit", es: "L\xEDmite" }, validation: { min: 1, max: 100 } })
  },
  handler: async (ctx, input, req) => {
    const { limit: inputLimit } = input;
    const queryLimit = parseInt(req?.query?.["limit"]) || 10;
    const limit = Math.min(inputLimit ?? queryLimit, 100);
    const service = getService(ctx);
    const nextRuns = await service.getNextRuns(limit);
    return {
      items: nextRuns,
      total: nextRuns.length
    };
  }
};
var functionsAction = {
  key: "functions",
  label: { en: "Get Registered Functions", es: "Obtener funciones registradas" },
  icon: "mdi:function",
  scope: "module",
  method: "GET",
  output: {},
  middleware: (ctx) => [ctx.core.middleware["auth"], requireAdmin(ctx)],
  handler: async (ctx) => {
    const service = getService(ctx);
    const functions = service.getRegisteredFunctions();
    return {
      items: functions,
      total: functions.length
    };
  }
};

// src/schedules.service.ts
import cron from "node-cron";
import { CronExpressionParser } from "cron-parser";

// src/schedules.executor.ts
import { resolve as dnsResolve } from "dns/promises";
var functionRegistry = /* @__PURE__ */ new Map();
function registerFunction(key, fn) {
  functionRegistry.set(key, fn);
}
function unregisterFunction(key) {
  functionRegistry.delete(key);
}
function listRegisteredFunctions() {
  return Array.from(functionRegistry.keys());
}
function clearFunctionRegistry() {
  functionRegistry.clear();
}
var SAFE_PATH_RE = /^\/[a-zA-Z0-9/_\-.~%]+$/;
function assertSafeInternalPath(path) {
  if (!path.startsWith("/")) {
    throw new Error(`Invalid internal path: must start with /`);
  }
  if (path.includes("..") || path.includes("//")) {
    throw new Error(`Invalid internal path: path traversal detected`);
  }
  if (!SAFE_PATH_RE.test(path)) {
    throw new Error(`Invalid internal path: contains unsafe characters`);
  }
}
function isPrivateIP(ip) {
  if (/^127\./.test(ip)) return true;
  if (/^10\./.test(ip)) return true;
  if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
  if (/^192\.168\./.test(ip)) return true;
  if (/^169\.254\./.test(ip)) return true;
  if (/^0\./.test(ip)) return true;
  if (ip === "255.255.255.255") return true;
  if (ip === "::1" || ip === "::") return true;
  if (/^fe80:/i.test(ip)) return true;
  if (/^f[cd]/i.test(ip)) return true;
  return false;
}
async function assertSafeExternalUrl(urlStr) {
  let parsed;
  try {
    parsed = new URL(urlStr);
  } catch {
    throw new Error(`Invalid URL: ${urlStr}`);
  }
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
    throw new Error(`Invalid URL protocol: ${parsed.protocol} (only http/https allowed)`);
  }
  const hostname = parsed.hostname;
  if (hostname === "169.254.169.254" || hostname === "metadata.google.internal") {
    throw new Error("Access to cloud metadata endpoints is blocked");
  }
  const lowerHost = hostname.toLowerCase();
  if (lowerHost === "localhost" || lowerHost === "ip6-localhost" || lowerHost === "ip6-loopback") {
    throw new Error("Access to localhost is blocked");
  }
  if (isPrivateIP(hostname)) {
    throw new Error(`Access to private IP ${hostname} is blocked`);
  }
  try {
    const addresses = await dnsResolve(hostname);
    for (const addr of addresses) {
      if (isPrivateIP(addr)) {
        throw new Error(`URL resolves to private IP ${addr} \u2014 blocked to prevent SSRF`);
      }
    }
  } catch (err) {
    if (err instanceof Error && err.message.includes("blocked")) throw err;
  }
}
async function executeServiceAction(ctx, config) {
  const { service, action, input, recordId } = config;
  const targetService = ctx.services.get(service);
  if (typeof targetService.executeAction !== "function") {
    throw new Error(`Service '${service}' does not support executeAction()`);
  }
  const result = await targetService.executeAction(action, input ?? {}, recordId);
  return result;
}
async function executeHttpInternal(_ctx, config) {
  const { method, path, body, headers } = config;
  assertSafeInternalPath(path);
  const internalToken = process.env["SCHEDULE_INTERNAL_TOKEN"];
  const isDev = process.env["NODE_ENV"] === "development";
  if (!internalToken && !isDev) {
    throw new Error("SCHEDULE_INTERNAL_TOKEN is required for http_internal targets in production");
  }
  const port = process.env["PORT"] ?? 3e3;
  const baseUrl = `http://127.0.0.1:${port}`;
  const url = `${baseUrl}${path}`;
  const reqHeaders = {
    "Content-Type": "application/json",
    ...headers
  };
  if (internalToken && !reqHeaders["Authorization"]) {
    reqHeaders["Authorization"] = `Bearer ${internalToken}`;
  }
  const response = await fetch(url, {
    method,
    headers: reqHeaders,
    body: body ? JSON.stringify(body) : void 0
  });
  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`HTTP ${response.status}: ${errorText}`);
  }
  const contentType = response.headers.get("content-type");
  if (contentType?.includes("application/json")) {
    return response.json();
  }
  return response.text();
}
async function executeHttpExternal(config) {
  const { method, url, body, headers, timeout = 3e4 } = config;
  await assertSafeExternalUrl(url);
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  try {
    const response = await fetch(url, {
      method,
      headers: {
        "Content-Type": "application/json",
        ...headers
      },
      body: body ? JSON.stringify(body) : void 0,
      signal: controller.signal
    });
    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(`HTTP ${response.status}: ${errorText}`);
    }
    const contentType = response.headers.get("content-type");
    if (contentType?.includes("application/json")) {
      return response.json();
    }
    return response.text();
  } finally {
    clearTimeout(timeoutId);
  }
}
async function executeFunctionRegistry(config) {
  const { functionKey, args } = config;
  const fn = functionRegistry.get(functionKey);
  if (!fn) {
    throw new Error(`Function '${functionKey}' not found in registry`);
  }
  return fn(args);
}
async function executeTarget(ctx, schedule) {
  const startTime = Date.now();
  try {
    let data;
    switch (schedule.target_type) {
      case "service_action":
        data = await executeServiceAction(ctx, schedule.target_config);
        break;
      case "http_internal":
        data = await executeHttpInternal(ctx, schedule.target_config);
        break;
      case "http_external":
        data = await executeHttpExternal(schedule.target_config);
        break;
      case "function_registry":
        data = await executeFunctionRegistry(schedule.target_config);
        break;
      default:
        throw new Error(`Unknown target_type: ${schedule.target_type}`);
    }
    return {
      success: true,
      data,
      duration_ms: Date.now() - startTime
    };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error.message : String(error),
      duration_ms: Date.now() - startTime
    };
  }
}

// src/schedules.schemas.ts
import { z } from "zod";
var httpMethods = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]);
var serviceActionSchema = z.object({
  service: z.string().min(1),
  action: z.string().min(1),
  input: z.record(z.unknown()).optional(),
  recordId: z.string().optional()
});
var httpInternalSchema = z.object({
  method: httpMethods,
  path: z.string().min(1).startsWith("/"),
  body: z.record(z.unknown()).optional(),
  headers: z.record(z.string()).optional()
});
var httpExternalSchema = z.object({
  method: httpMethods,
  url: z.string().url(),
  body: z.record(z.unknown()).optional(),
  headers: z.record(z.string()).optional(),
  timeout: z.number().min(1e3).max(12e4).optional()
});
var functionRegistrySchema = z.object({
  functionKey: z.string().min(1),
  args: z.record(z.unknown()).optional()
});
var schemas = {
  service_action: serviceActionSchema,
  http_internal: httpInternalSchema,
  http_external: httpExternalSchema,
  function_registry: functionRegistrySchema
};
function validateTargetConfig(targetType, config) {
  const schema = schemas[targetType];
  if (!schema) {
    throw new Error(`Unknown target_type: ${targetType}`);
  }
  return schema.parse(config);
}

// src/schedules.service.ts
var schedulesService = null;
var SchedulesService = class {
  ctx;
  jobs = /* @__PURE__ */ new Map();
  retryTimeouts = /* @__PURE__ */ new Set();
  initialized = false;
  initPromise = null;
  generateId;
  safeJsonParse;
  logger;
  t;
  constructor(ctx) {
    this.ctx = ctx;
    this.generateId = ctx.core.generateId;
    this.safeJsonParse = ctx.core.safeJsonParse;
    this.logger = ctx.core.logger.child({ service: "schedules" });
    this.t = ctx.db.t;
  }
  /**
   * Initializes the service by loading active schedules from the DB.
   * Safe to call multiple times - concurrent calls share the same initialization.
   */
  async init() {
    if (this.initialized) return;
    if (this.initPromise) return this.initPromise;
    this.initPromise = this.doInit();
    return this.initPromise;
  }
  async doInit() {
    this.ctx.core.logger.debug("Initializing schedules service...");
    const schedules = await this.ctx.db.knex(this.t("schedules")).where("enabled", true).whereNot("status", "paused").select("*");
    for (const schedule of schedules) {
      this.scheduleJob(schedule);
    }
    this.initialized = true;
    this.ctx.core.logger.info(`Schedules service initialized with ${schedules.length} active jobs`);
  }
  /**
   * Stops all jobs
   */
  shutdown() {
    this.ctx.core.logger.debug("Shutting down schedules service...");
    for (const timeout of this.retryTimeouts) {
      clearTimeout(timeout);
    }
    this.retryTimeouts.clear();
    for (const [id, job] of this.jobs) {
      job.task.stop();
      this.ctx.core.logger.debug(`Stopped job: ${id}`);
    }
    this.jobs.clear();
    this.initialized = false;
    this.initPromise = null;
    this.ctx.core.logger.info("Schedules service shut down");
  }
  /**
   * Creates a new schedule
   */
  async create(data, userId) {
    if (!cron.validate(data.cron_expression)) {
      throw new this.ctx.core.errors.ValidationError(`Invalid cron expression: ${data.cron_expression}`);
    }
    if (data.timezone && data.timezone !== "UTC") {
      try {
        Intl.DateTimeFormat(void 0, { timeZone: data.timezone });
      } catch {
        throw new this.ctx.core.errors.ValidationError(`Invalid timezone: ${data.timezone}`);
      }
    }
    try {
      validateTargetConfig(data.target_type, data.target_config);
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      throw new this.ctx.core.errors.ValidationError(`Invalid target configuration: ${message}`);
    }
    const now = (/* @__PURE__ */ new Date()).toISOString();
    const schedule = {
      id: this.generateId(),
      name: data.name,
      description: data.description ?? null,
      cron_expression: data.cron_expression,
      timezone: data.timezone ?? "UTC",
      target_type: data.target_type,
      target_config: data.target_config,
      enabled: data.enabled ?? true,
      status: "idle",
      last_run_at: null,
      last_error: null,
      run_count: 0,
      max_retries: data.max_retries ?? 0,
      retry_count: 0,
      retry_delay_ms: data.retry_delay_ms ?? 5e3,
      retry_backoff: data.retry_backoff ?? "exponential",
      created_at: now,
      updated_at: now,
      created_by: userId ?? null,
      updated_by: userId ?? null
    };
    await this.ctx.db.knex(this.t("schedules")).insert({
      ...schedule,
      target_config: JSON.stringify(schedule.target_config)
    });
    if (schedule.enabled) {
      this.scheduleJob(schedule);
    }
    this.ctx.core.logger.info(`Schedule created: ${schedule.name} (${schedule.id})`);
    return schedule;
  }
  /**
   * Updates an existing schedule
   */
  async update(id, data, userId) {
    const existing = await this.findById(id);
    if (!existing) {
      throw new this.ctx.core.errors.NotFoundError(`Schedule not found: ${id}`);
    }
    if (data.cron_expression && !cron.validate(data.cron_expression)) {
      throw new this.ctx.core.errors.ValidationError(`Invalid cron expression: ${data.cron_expression}`);
    }
    if (data.timezone && data.timezone !== "UTC") {
      try {
        Intl.DateTimeFormat(void 0, { timeZone: data.timezone });
      } catch {
        throw new this.ctx.core.errors.ValidationError(`Invalid timezone: ${data.timezone}`);
      }
    }
    if (data.target_config) {
      const targetType = data.target_type ?? existing.target_type;
      try {
        validateTargetConfig(targetType, data.target_config);
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        throw new this.ctx.core.errors.ValidationError(`Invalid target configuration: ${message}`);
      }
    }
    const updated = {
      ...data,
      updated_at: (/* @__PURE__ */ new Date()).toISOString(),
      updated_by: userId ?? null
    };
    await this.ctx.db.knex(this.t("schedules")).where("id", id).update({
      ...updated,
      target_config: data.target_config ? JSON.stringify(data.target_config) : void 0
    });
    const schedule = await this.findById(id);
    if (!schedule) {
      throw new this.ctx.core.errors.NotFoundError(`Schedule not found after update: ${id}`);
    }
    this.cancelJob(id);
    if (schedule.enabled && schedule.status !== "paused") {
      this.scheduleJob(schedule);
    }
    this.ctx.core.logger.info(`Schedule updated: ${schedule.name} (${id})`);
    return schedule;
  }
  /**
   * Deletes a schedule
   */
  async delete(id) {
    const existing = await this.findById(id);
    if (!existing) {
      throw new this.ctx.core.errors.NotFoundError(`Schedule not found: ${id}`);
    }
    this.cancelJob(id);
    await this.ctx.db.knex(this.t("schedules")).where("id", id).delete();
    this.ctx.core.logger.info(`Schedule deleted: ${existing.name} (${id})`);
  }
  /**
   * Finds a schedule by ID
   */
  async findById(id) {
    const row = await this.ctx.db.knex(this.t("schedules")).where("id", id).first();
    if (!row) return null;
    return {
      ...row,
      target_config: typeof row.target_config === "string" ? this.safeJsonParse(row.target_config, {}, { scheduleId: row.id, context: "getById" }) : row.target_config
    };
  }
  /**
   * Lists all schedules
   */
  async findAll() {
    const rows = await this.ctx.db.knex(this.t("schedules")).orderBy("created_at", "desc").select("*");
    return rows.map((row) => ({
      ...row,
      target_config: typeof row.target_config === "string" ? this.safeJsonParse(row.target_config, {}, { scheduleId: row.id, context: "list" }) : row.target_config
    }));
  }
  /**
   * Pauses a schedule
   */
  async pause(id) {
    const schedule = await this.findById(id);
    if (!schedule) {
      throw new this.ctx.core.errors.NotFoundError(`Schedule not found: ${id}`);
    }
    this.cancelJob(id);
    await this.ctx.db.knex(this.t("schedules")).where("id", id).update({ status: "paused", updated_at: (/* @__PURE__ */ new Date()).toISOString() });
    this.ctx.core.logger.info(`Schedule paused: ${schedule.name} (${id})`);
  }
  /**
   * Resumes a paused schedule
   */
  async resume(id) {
    const schedule = await this.findById(id);
    if (!schedule) {
      throw new this.ctx.core.errors.NotFoundError(`Schedule not found: ${id}`);
    }
    if (!schedule.enabled) {
      throw new this.ctx.core.errors.ValidationError(`Cannot resume disabled schedule: ${id}`);
    }
    await this.ctx.db.knex(this.t("schedules")).where("id", id).update({ status: "idle", updated_at: (/* @__PURE__ */ new Date()).toISOString() });
    const updated = await this.findById(id);
    if (updated) {
      this.scheduleJob(updated);
    }
    this.ctx.core.logger.info(`Schedule resumed: ${schedule.name} (${id})`);
  }
  /**
   * Runs a schedule immediately (manual trigger)
   */
  async trigger(id) {
    const schedule = await this.findById(id);
    if (!schedule) {
      throw new this.ctx.core.errors.NotFoundError(`Schedule not found: ${id}`);
    }
    this.ctx.core.logger.info(`Manual trigger: ${schedule.name} (${id})`);
    return this.executeSchedule(schedule);
  }
  /**
   * Gets upcoming runs
   */
  async getNextRuns(limit = 10) {
    const schedules = await this.ctx.db.knex(this.t("schedules")).where("enabled", true).whereNot("status", "paused").select("id", "name", "cron_expression", "timezone").limit(limit);
    const result = [];
    for (const schedule of schedules) {
      const nextDate = this.getNextRunDate(schedule.cron_expression, schedule.timezone);
      if (nextDate) {
        result.push({
          id: schedule.id,
          name: schedule.name,
          cron_expression: schedule.cron_expression,
          next_run: nextDate.toISOString()
        });
      }
    }
    return result.sort((a, b) => new Date(a.next_run).getTime() - new Date(b.next_run).getTime());
  }
  /**
   * Syncs a job with the DB (for auto-mounted CRUD events)
   */
  async syncJob(id) {
    const schedule = await this.findById(id);
    if (!schedule) {
      this.cancelJob(id);
      return;
    }
    this.cancelJob(id);
    if (schedule.enabled && schedule.status !== "paused") {
      this.scheduleJob(schedule);
    }
  }
  /**
   * Cancels a job by ID (public for use from events)
   */
  cancelJobById(id) {
    this.cancelJob(id);
  }
  /**
   * Registers a function for function_registry
   */
  registerFunction(key, fn) {
    registerFunction(key, fn);
    this.ctx.core.logger.debug(`Function registered: ${key}`);
  }
  /**
   * Unregisters a function
   */
  unregisterFunction(key) {
    unregisterFunction(key);
    this.ctx.core.logger.debug(`Function unregistered: ${key}`);
  }
  /**
   * Lists registered functions
   */
  getRegisteredFunctions() {
    return listRegisteredFunctions();
  }
  // --- Private methods ---
  /**
   * Schedules a job with node-cron
   */
  scheduleJob(schedule) {
    if (this.jobs.has(schedule.id)) {
      this.ctx.core.logger.warn(`Job already scheduled: ${schedule.id}`);
      return;
    }
    const task = cron.schedule(
      schedule.cron_expression,
      async () => {
        await this.executeSchedule(schedule);
      },
      {
        timezone: schedule.timezone
      }
    );
    this.jobs.set(schedule.id, { id: schedule.id, task, schedule });
    this.logger.info(`Job scheduled: ${schedule.name} (${schedule.cron_expression})`);
  }
  /**
   * Cancels a job
   */
  cancelJob(id) {
    const job = this.jobs.get(id);
    if (job) {
      job.task.stop();
      this.jobs.delete(id);
      this.logger.info(`Job cancelled: ${id}`);
    }
  }
  /**
   * Executes a schedule with retry support
   */
  async executeSchedule(schedule) {
    const claimed = await this.ctx.db.knex(this.t("schedules")).where("id", schedule.id).where("status", "!=", "running").update({ status: "running" });
    if (!claimed || claimed === 0) {
      this.logger.warn(`Skipping execution, already running: ${schedule.name}`);
      await this.logExecution(schedule.id, {
        success: false,
        error: "Skipped: previous execution still running",
        duration_ms: 0
      }, "skipped");
      return { success: false, error: "Skipped: already running", duration_ms: 0 };
    }
    const current = await this.findById(schedule.id);
    const result = await executeTarget(this.ctx, current ?? schedule);
    if (result.success) {
      await this.ctx.db.knex(this.t("schedules")).where("id", schedule.id).update({
        status: "idle",
        last_run_at: (/* @__PURE__ */ new Date()).toISOString(),
        last_error: null,
        run_count: this.ctx.db.knex.raw("run_count + 1"),
        retry_count: 0
      });
      await this.logExecution(schedule.id, result, "success");
      this.logger.info(`Schedule executed: ${schedule.name} (${result.duration_ms}ms)`);
      return result;
    }
    const maxRetries = schedule.max_retries ?? 0;
    const currentRetryCount = current?.retry_count ?? 0;
    if (maxRetries > 0 && currentRetryCount < maxRetries) {
      const newRetryCount = currentRetryCount + 1;
      const delay = this.calculateRetryDelay(schedule, newRetryCount);
      await this.ctx.db.knex(this.t("schedules")).where("id", schedule.id).update({
        status: "retrying",
        retry_count: newRetryCount,
        last_error: result.error ?? null,
        last_run_at: (/* @__PURE__ */ new Date()).toISOString(),
        run_count: this.ctx.db.knex.raw("run_count + 1")
      });
      await this.logExecution(schedule.id, result, "retrying");
      this.logger.warn(`Schedule retry ${newRetryCount}/${maxRetries}: ${schedule.name} in ${delay}ms`);
      const timeoutId = setTimeout(async () => {
        this.retryTimeouts.delete(timeoutId);
        const fresh = await this.findById(schedule.id);
        if (fresh && fresh.status === "retrying" && fresh.enabled) {
          await this.executeSchedule(fresh);
        }
      }, delay);
      this.retryTimeouts.add(timeoutId);
      return result;
    }
    const logStatus = maxRetries > 0 ? "exhausted" : "error";
    await this.ctx.db.knex(this.t("schedules")).where("id", schedule.id).update({
      status: "error",
      last_run_at: (/* @__PURE__ */ new Date()).toISOString(),
      last_error: result.error ?? null,
      run_count: this.ctx.db.knex.raw("run_count + 1"),
      retry_count: 0
    });
    await this.logExecution(schedule.id, result, logStatus);
    if (logStatus === "exhausted") {
      this.logger.error(`Schedule exhausted after ${maxRetries} retries: ${schedule.name} - ${result.error}`);
    } else {
      this.logger.error(`Schedule failed: ${schedule.name} - ${result.error}`);
    }
    return result;
  }
  /**
   * Calculates retry delay based on backoff strategy
   */
  calculateRetryDelay(schedule, attempt) {
    const baseDelay = schedule.retry_delay_ms ?? 5e3;
    const backoff = schedule.retry_backoff ?? "exponential";
    switch (backoff) {
      case "fixed":
        return baseDelay;
      case "linear":
        return baseDelay * attempt;
      case "exponential":
        return baseDelay * Math.pow(2, attempt - 1);
      default:
        return baseDelay;
    }
  }
  /**
   * Records an execution log
   */
  async logExecution(scheduleId, result, status) {
    const now = /* @__PURE__ */ new Date();
    const startedAt = new Date(now.getTime() - result.duration_ms);
    await this.ctx.db.knex(this.t("schedule_logs")).insert({
      id: this.generateId(),
      schedule_id: scheduleId,
      started_at: startedAt.toISOString(),
      finished_at: now.toISOString(),
      duration_ms: result.duration_ms,
      status,
      result: result.data ? JSON.stringify(result.data) : null,
      error: result.error ?? null,
      created_at: now.toISOString()
    });
  }
  /**
   * Calculates the next run date using cron-parser
   */
  getNextRunDate(cronExpression, timezone) {
    try {
      const interval = CronExpressionParser.parse(cronExpression, {
        tz: timezone
      });
      return interval.next().toDate();
    } catch {
      return null;
    }
  }
};
function initSchedulesService(ctx) {
  schedulesService = new SchedulesService(ctx);
  return schedulesService;
}
function getSchedulesService() {
  if (!schedulesService) {
    throw new Error("Schedules service not initialized. Call initSchedulesService() first.");
  }
  return schedulesService;
}

// src/schedules.routes.ts
function createSchedulesRoutes(ctx) {
  return ctx.core.createRouter();
}

// src/schedules-module.ts
var shutdownHandler = null;
var schedulesModule = {
  name: "schedules",
  label: { en: "Schedules", es: "Programaciones" },
  icon: "mdi:clock-outline",
  description: {
    en: "Scheduled tasks with cron expressions",
    es: "Tareas programadas con expresiones cron"
  },
  category: "jobs",
  definitions: [scheduleEntity, scheduleLogEntity, dashboardEntity],
  actions: [nextRunsAction, functionsAction],
  pages: [
    {
      id: "schedules-monitor",
      label: { en: "Job Monitor", es: "Monitor de tareas" },
      icon: "mdi:monitor-dashboard",
      type: "dashboard",
      order: 0,
      widgets: [
        { id: "stats", type: "stat", endpoint: "/schedules/dashboard", layout: { span: 12 } },
        { id: "next-runs", type: "table", label: { en: "Upcoming runs", es: "Pr\xF3ximas ejecuciones" }, endpoint: "/schedules/actions/next-runs", layout: { span: 6 } },
        { id: "recent-logs", type: "table", label: { en: "Recent executions", es: "Ejecuciones recientes" }, endpoint: "/schedules/logs?sort=-started_at&limit=10", layout: { span: 6 } }
      ]
    }
  ],
  routes: (ctx) => createSchedulesRoutes(ctx),
  routePrefix: "/schedules",
  init: async (ctx) => {
    const service = initSchedulesService(ctx);
    ctx.services.register("schedules", service);
    if (shutdownHandler) {
      process.removeListener("SIGTERM", shutdownHandler);
      process.removeListener("SIGINT", shutdownHandler);
    }
    shutdownHandler = () => service.shutdown();
    process.on("SIGTERM", shutdownHandler);
    process.on("SIGINT", shutdownHandler);
    ctx.events.on("server.started", async () => {
      try {
        await service.init();
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        ctx.core.logger.error(`Failed to initialize schedules: ${message}`);
      }
    });
    ctx.core.logger.debug("Schedules plugin initialized");
  }
};

// src/index.ts
var pkg = JSON.parse(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf-8"));
var schedulesPlugin = {
  name: pkg.name,
  code: "sch",
  label: { en: "Schedules", es: "Programaciones" },
  icon: "mdi:clock-outline",
  version: pkg.version,
  description: {
    en: "Cron-based task scheduling with persistence and audit",
    es: "Programaci\xF3n de tareas con expresiones cron, persistencia y auditor\xEDa"
  },
  category: "jobs",
  modules: [schedulesModule]
};
var index_default = schedulesPlugin;
export {
  SchedulesService,
  assertSafeExternalUrl,
  assertSafeInternalPath,
  clearFunctionRegistry,
  dashboardEntity,
  index_default as default,
  getSchedulesService,
  initSchedulesService,
  isPrivateIP,
  listRegisteredFunctions,
  registerFunction,
  scheduleEntity,
  scheduleLogEntity,
  schedulesModule,
  schedulesPlugin,
  unregisterFunction
};
//# sourceMappingURL=index.js.map