import { Router } from "express";
import { randomUUID } from "node:crypto";
import { ApiError } from "../shared/errors";
import { requireAdminToken } from "../shared/middleware/auth";
import { successResponse } from "../shared/middleware/errorMiddleware";
import { env } from "../config/env";
import { idempotency } from "../shared/middleware/idempotency";
import { createMerchant, getMerchantById, listMerchants, patchMerchant, toMerchantPayload } from "../shared/store/merchantStore";
import { createOutlet, createTerminal, getOutletById, getTerminalById, listOutlets, listTerminals, patchOutlet, patchTerminal, toOutletPayload, toTerminalPayload } from "../shared/store/locationStore";
import { bindDevice, getActiveBindingByDevice, getActiveBindingByTerminal, toBindingPayload, unbindDevice } from "../shared/store/bindingStore";
import { createDevice, getDeviceById, listDevices, patchDevice, toDevicePayload } from "../shared/store/deviceStore";
import { deriveDeviceHealthSummary, getHeartbeatCountForDeviceLastHours, getLatestHeartbeatByDeviceId, listHeartbeats, createDeviceHeartbeat } from "../shared/store/heartbeatStore";
import { createDeviceCommand, getDeviceCommandById, listDeviceCommands, toDeviceCommandPayload, toDeviceCommandPayloadBrief } from "../shared/store/deviceCommandStore";
import { createTransaction, getTransactionById, listTransactions, toTransactionEventPayload, toTransactionPayload, getTransactionEvents } from "../shared/store/transactionStore";
import { getNotificationByTransactionId, listNotifications, listNotificationsByDevice, toNotificationPayload } from "../shared/store/notificationStore";
import { retryNotificationByTransactionId } from "../shared/orchestrators/notificationOrchestrator";
import { createAuditLog, listAuditLogs, toAuditLogPayload } from "../shared/store/auditLogStore";
import { listLedgerEntries, toLedgerEntryPayload } from "../shared/store/ledgerStore";
import { resolveDeviceCapabilitySummary } from "../shared/services/deviceCapabilityResolver";
import { getOrCreateDeviceConfig, listDeviceConfigAcks, toDeviceConfigAckPayload, toDeviceConfigPayload, upsertDeviceConfig } from "../shared/store/deviceConfigStore";
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
import { publishConfigPush } from "../shared/services/mqttPublisher";
import { buildDeviceConfigStatus } from "../shared/services/deviceConfigStatus";
import { expireDueDynamicQrTransactions } from "../shared/services/dynamicQrExpiry";
const router = Router();
function parseIdempotentReplay(req) {
    return req.body.__idempotentReplay;
}
function getReplayResponse(req) {
    return req.body.__idempotentResponse;
}
function isIsoDate(value) {
    if (!value) {
        return false;
    }
    return Number.isFinite(Date.parse(value));
}
function isTxInDateRange(tx, from, to) {
    const createdAt = Date.parse(tx.created_at);
    if (Number.isNaN(createdAt)) {
        return false;
    }
    if (from && createdAt < Date.parse(from)) {
        return false;
    }
    if (to && createdAt > Date.parse(to)) {
        return false;
    }
    return true;
}
function parseDeviceStatusFilter(value) {
    if (value === "online" || value === "offline" || value === "degraded" || value === "stale") {
        return value;
    }
    return undefined;
}
function parseCommunicationModeFilter(value) {
    if (value === "static" || value === "mqtt" || value === "api") {
        return value;
    }
    return undefined;
}
function parseDeviceCommunicationMode(value) {
    if (value === "static" || value === "mqtt" || value === "api") {
        return value;
    }
    return undefined;
}
function parseDeviceStatusValue(value) {
    if (value === "active" || value === "inactive") {
        return value;
    }
    return undefined;
}
function parsePayoutMode(value) {
    if (value === "merchant_direct" || value === "manual") {
        return value;
    }
    return undefined;
}
function parseOutletStatusFilter(value) {
    if (value === "active" || value === "inactive") {
        return value;
    }
    return undefined;
}
function parseTerminalStatusFilter(value) {
    if (value === "active" || value === "inactive") {
        return value;
    }
    return undefined;
}
function parseTerminalModeFilter(value) {
    if (value === "static" || value === "dynamic_mqtt" || value === "dynamic_api") {
        return value;
    }
    return undefined;
}
function parseTransactionStatusFilter(value) {
    if (value === "initiated" ||
        value === "awaiting_payment" ||
        value === "paid" ||
        value === "failed" ||
        value === "expired" ||
        value === "reversed") {
        return value;
    }
    return undefined;
}
function parseCommandStatusFilter(value) {
    if (value === "accepted" || value === "delivered" || value === "failed" || value === "timeout") {
        return value;
    }
    return undefined;
}
function buildBindingSummary(binding) {
    if (!binding) {
        return null;
    }
    return {
        id: binding.id,
        merchant_id: binding.merchant_id,
        outlet_id: binding.outlet_id,
        terminal_id: binding.terminal_id
    };
}
async function buildDeviceAdminPayload(device) {
    const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
    const healthSummary = deriveDeviceHealthSummary({
        last_seen_at: device.last_seen_at,
        network_strength: latestHeartbeat?.network_strength ?? null,
        battery_level: latestHeartbeat?.battery_level ?? null
    });
    return {
        ...toDevicePayload(device),
        capability_summary: resolveDeviceCapabilitySummary(device),
        derived_status: healthSummary.status,
        health_summary: healthSummary,
        heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id),
        binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)),
        latest_heartbeat: latestHeartbeat
            ? {
                id: latestHeartbeat.id,
                timestamp: latestHeartbeat.timestamp,
                received_at: latestHeartbeat.received_at,
                state: latestHeartbeat.state,
                network_strength: latestHeartbeat.network_strength,
                battery_level: latestHeartbeat.battery_level,
                firmware_version: latestHeartbeat.firmware_version
            }
            : null
    };
}
async function deriveDeviceStatusesForDashboard() {
    const devices = await listDevices();
    return Promise.all(devices.map(async (device) => {
        const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
        const healthSummary = deriveDeviceHealthSummary({
            last_seen_at: device.last_seen_at,
            network_strength: latestHeartbeat?.network_strength ?? null,
            battery_level: latestHeartbeat?.battery_level ?? null
        });
        return {
            device,
            status: healthSummary.status,
            healthSummary
        };
    }));
}
function buildDashboardRange() {
    const start = new Date();
    start.setUTCHours(0, 0, 0, 0);
    const end = new Date(start);
    end.setUTCDate(start.getUTCDate() + 1);
    return { start, end };
}
function toStartEndDateFilter(from, to) {
    if (from && Number.isNaN(Date.parse(from))) {
        return null;
    }
    if (to && Number.isNaN(Date.parse(to))) {
        return null;
    }
    return {
        fromTs: from ? Date.parse(from) : null,
        toTs: to ? Date.parse(to) : null
    };
}
function normalizeMerchantMode(payloadMode) {
    return payloadMode || "merchant_direct";
}
async function auditAdminAction(req, payload) {
    await createAuditLog({
        actor_type: "admin",
        actor_id: "admin",
        action: payload.action,
        entity_type: payload.entity_type,
        entity_id: payload.entity_id,
        before_json: payload.before_json,
        after_json: payload.after_json,
        source_ip: req.ip,
        request_id: req.requestId,
        trace_id: req.traceId
    });
}
async function publishDeviceConfigPush(deviceId, config) {
    const mqttPayload = {
        message_type: "config_push",
        config_version: config.config_version,
        settings: config.settings_json
    };
    const publishResult = await publishConfigPush(deviceId, mqttPayload);
    return createMqttMessage({
        direction: "downlink",
        device_id: deviceId,
        topic: publishResult.topic,
        message_type: "config_push",
        correlation_id: `config:${config.config_version}`,
        payload_json: mqttPayload,
        publish_status: publishResult.ok ? "sent" : "failed",
        reason: publishResult.reason
    });
}
function validatePayoutConfig(payload) {
    const mode = normalizeMerchantMode(payload.payout_mode);
    if (mode === "merchant_direct") {
        if (!payload.settlement_account_reference || !payload.settlement_account_type) {
            throw new ApiError("BAD_REQUEST", "settlement_account_reference and settlement_account_type required when payout_mode=merchant_direct", 400);
        }
    }
}
async function ensureMerchant(req, next) {
    const merchantId = req.params.merchantId;
    const merchant = await getMerchantById(merchantId);
    if (!merchant) {
        return next(new ApiError("NOT_FOUND", "merchant not found", 404));
    }
    return merchant;
}
router.post("/login", async (req, res, next) => {
    const { username, password } = req.body;
    if (username !== "admin" || password !== "admin") {
        return next(new ApiError("UNAUTHORIZED", "Invalid credentials", 401));
    }
    const token = env.ADMIN_TOKEN;
    res.json(successResponse(req, {
        token
    }));
});
router.use(async (req, res, next) => {
    if (req.path === "/login") {
        return next();
    }
    return requireAdminToken(req, res, next);
});
router.get("/health", requireAdminToken, async (_req, res) => {
    res.json(successResponse(_req, {
        ok: true,
        now: new Date().toISOString()
    }));
});
router.post("/sample-idempotent", requireAdminToken, idempotency({ scope: "admin.sample", required: false }), async (req, res) => {
    if (parseIdempotentReplay(req)) {
        return res.status(200).json(getReplayResponse(req));
    }
    const id = randomUUID();
    res.json(successResponse(req, { id, generated_at: new Date().toISOString() }));
});
router.post("/merchants", requireAdminToken, idempotency({ scope: "merchant.create", required: false }), async (req, res, next) => {
    if (parseIdempotentReplay(req)) {
        return res.status(200).json(getReplayResponse(req));
    }
    const payload = req.body;
    if (!payload?.legal_name) {
        return next(new ApiError("BAD_REQUEST", "legal_name is required", 400));
    }
    const normalizedPayoutMode = parsePayoutMode(payload.payout_mode);
    if (payload.payout_mode && !normalizedPayoutMode) {
        return next(new ApiError("BAD_REQUEST", "payout_mode must be merchant_direct or manual", 400));
    }
    try {
        validatePayoutConfig(payload);
    }
    catch (err) {
        return next(err);
    }
    const created = await createMerchant({
        legal_name: payload.legal_name,
        brand_name: payload.brand_name,
        settlement_account_reference: payload.settlement_account_reference,
        settlement_account_type: payload.settlement_account_type,
        payout_mode: normalizeMerchantMode(payload.payout_mode),
        fee_profile_id: payload.fee_profile_id,
        status: payload.status,
        onboarding_status: payload.onboarding_status
    });
    await auditAdminAction(req, {
        action: "merchant.create",
        entity_type: "merchant",
        entity_id: created.id,
        after_json: toMerchantPayload(created)
    });
    res.status(201).json(successResponse(req, toMerchantPayload(created)));
});
router.get("/merchants", requireAdminToken, async (_req, res) => {
    res.json(successResponse(_req, (await listMerchants()).map(toMerchantPayload)));
});
router.get("/merchants/:merchantId", requireAdminToken, async (req, res, next) => {
    const merchant = await getMerchantById(req.params.merchantId);
    if (!merchant) {
        return next(new ApiError("NOT_FOUND", "merchant not found", 404));
    }
    res.json(successResponse(req, toMerchantPayload(merchant)));
});
router.patch("/merchants/:merchantId", requireAdminToken, async (req, res, next) => {
    const payload = req.body;
    if (!payload || Object.keys(payload).length === 0) {
        return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
    }
    const existing = await getMerchantById(req.params.merchantId);
    if (!existing) {
        return next(new ApiError("NOT_FOUND", "merchant not found", 404));
    }
    const normalized = {
        ...payload,
        payout_mode: payload.payout_mode ? payload.payout_mode : existing.payout_mode
    };
    if (normalized.payout_mode && !parsePayoutMode(normalized.payout_mode)) {
        return next(new ApiError("BAD_REQUEST", "payout_mode must be merchant_direct or manual", 400));
    }
    if (normalized.payout_mode === "merchant_direct") {
        normalized.settlement_account_reference =
            normalized.settlement_account_reference || existing.settlement_account_reference;
        normalized.settlement_account_type =
            normalized.settlement_account_type || existing.settlement_account_type;
    }
    try {
        validatePayoutConfig(normalized);
    }
    catch (err) {
        return next(err);
    }
    const updated = await patchMerchant(req.params.merchantId, normalized);
    await auditAdminAction(req, {
        action: "merchant.update",
        entity_type: "merchant",
        entity_id: updated.id,
        before_json: toMerchantPayload(existing),
        after_json: toMerchantPayload(updated)
    });
    res.json(successResponse(req, toMerchantPayload(updated)));
});
router.post("/merchants/:merchantId/approve", requireAdminToken, async (req, res, next) => {
    const existing = await getMerchantById(req.params.merchantId);
    if (!existing) {
        return next(new ApiError("NOT_FOUND", "merchant not found", 404));
    }
    if (existing.onboarding_status === "approved") {
        return res.json(successResponse(req, toMerchantPayload(existing)));
    }
    const updated = await patchMerchant(req.params.merchantId, {
        onboarding_status: "approved"
    });
    await auditAdminAction(req, {
        action: "merchant.approve",
        entity_type: "merchant",
        entity_id: updated.id,
        before_json: toMerchantPayload(existing),
        after_json: toMerchantPayload(updated)
    });
    res.json(successResponse(req, toMerchantPayload(updated)));
});
router.post("/merchants/:merchantId/reject", requireAdminToken, async (req, res, next) => {
    const payload = req.body;
    if (!payload?.reason || payload.reason.trim() === "") {
        return next(new ApiError("BAD_REQUEST", "reason is required", 400));
    }
    const existing = await getMerchantById(req.params.merchantId);
    if (!existing) {
        return next(new ApiError("NOT_FOUND", "merchant not found", 404));
    }
    const updated = await patchMerchant(req.params.merchantId, {
        onboarding_status: "rejected",
        status: "inactive"
    });
    await auditAdminAction(req, {
        action: "merchant.reject",
        entity_type: "merchant",
        entity_id: updated.id,
        before_json: toMerchantPayload(existing),
        after_json: {
            ...toMerchantPayload(updated),
            rejection_reason: payload.reason
        }
    });
    res.json(successResponse(req, {
        ...toMerchantPayload(updated),
        rejection_reason: payload.reason
    }));
});
router.post("/seed", requireAdminToken, idempotency({ scope: "seed.demo", required: false }), async (req, res, next) => {
    if (parseIdempotentReplay(req)) {
        return res.status(200).json(getReplayResponse(req));
    }
    const payload = req.body || {};
    const includeHeartbeat = payload.include_demo_heartbeat !== false;
    const includeTransactions = payload.include_demo_transactions !== false;
    if ((await listMerchants()).length > 0 || (await listDevices()).length > 0 || (await listOutlets()).length > 0 || (await listTerminals()).length > 0) {
        return next(new ApiError("BAD_REQUEST", "seed requires empty demo environment", 400));
    }
    const merchantA = await createMerchant({
        legal_name: "Seed Merchant A",
        brand_name: "Seed A",
        settlement_account_reference: "seed-bank:111111111",
        settlement_account_type: "merchant_bank_account",
        payout_mode: "merchant_direct",
        status: "active",
        onboarding_status: "approved"
    });
    const merchantB = await createMerchant({
        legal_name: "Seed Merchant B",
        brand_name: "Seed B",
        settlement_account_reference: "seed-bank:222222222",
        settlement_account_type: "merchant_bank_account",
        payout_mode: "manual",
        status: "active",
        onboarding_status: "pending"
    });
    const outletA = await createOutlet({
        merchant_id: merchantA.id,
        name: "Outlet Seed A",
        address: "Jl. Contoh Nomor 1"
    });
    const outletB = await createOutlet({
        merchant_id: merchantB.id,
        name: "Outlet Seed B",
        address: "Jl. Contoh Nomor 2"
    });
    const terminalA = await createTerminal({
        outlet_id: outletA.id,
        terminal_code: "TERM_SEED_A",
        qr_mode: "static"
    });
    const terminalB = await createTerminal({
        outlet_id: outletB.id,
        terminal_code: "TERM_SEED_B",
        qr_mode: "static"
    });
    const deviceA = await createDevice({
        device_code: "DEV_SEED_A",
        vendor: "seed-maker",
        model: "v1",
        communication_mode: "static",
        capability_profile_json: {
            dynamic_qr: false,
            flows: ["static_payment_notification"]
        },
        status: "active"
    });
    const deviceB = await createDevice({
        device_code: "DEV_SEED_B",
        vendor: "seed-maker",
        model: "v1",
        communication_mode: "mqtt",
        capability_profile_json: {
            dynamic_qr: {
                mqtt: true,
                api_direct: false
            },
            flows: ["dynamic_qr:mqtt", "static_payment_notification"]
        },
        status: "active"
    });
    const deviceC = await createDevice({
        device_code: "DEV_SEED_C",
        vendor: "seed-maker",
        model: "v1",
        communication_mode: "api",
        capability_profile_json: {
            dynamic_qr: {
                api_direct: true,
                mqtt: false
            },
            flows: ["dynamic_qr:api_direct", "static_payment_notification"]
        },
        status: "active"
    });
    await bindDevice({
        device_id: deviceA.id,
        merchant_id: merchantA.id,
        outlet_id: outletA.id,
        terminal_id: terminalA.id
    });
    await bindDevice({
        device_id: deviceB.id,
        merchant_id: merchantB.id,
        outlet_id: outletB.id,
        terminal_id: terminalB.id
    });
    if (includeHeartbeat) {
        await createDeviceHeartbeat({
            device_id: deviceA.id,
            timestamp: new Date().toISOString(),
            firmware_version: "1.0.0",
            network_strength: 92,
            battery_level: 89,
            state: "idle"
        });
        await createDeviceHeartbeat({
            device_id: deviceB.id,
            timestamp: new Date().toISOString(),
            firmware_version: "1.0.0",
            network_strength: 83,
            battery_level: 76,
            state: "idle"
        });
    }
    const transactions = includeTransactions
        ? [
            await createTransaction({
                merchant_id: merchantA.id,
                outlet_id: outletA.id,
                terminal_id: terminalA.id,
                device_id: deviceA.id,
                partner_reference: "seed-pr-001",
                amount: 25000,
                currency: "IDR",
                qr_mode: "static",
                initiation_mode: "static",
                status: "initiated"
            }),
            await createTransaction({
                merchant_id: merchantB.id,
                outlet_id: outletB.id,
                terminal_id: terminalB.id,
                device_id: deviceB.id,
                partner_reference: "seed-pr-002",
                amount: 50000,
                currency: "IDR",
                qr_mode: "static",
                initiation_mode: "static",
                status: "awaiting_payment"
            })
        ]
        : [];
    const seeded = {
        merchants: [toMerchantPayload(merchantA), toMerchantPayload(merchantB)],
        outlets: [outletA, outletB],
        terminals: [terminalA, terminalB],
        devices: [deviceA, deviceB, deviceC],
        transactions: transactions.map((tx) => toTransactionPayload(tx)),
        include_demo_heartbeat: includeHeartbeat,
        include_demo_transactions: includeTransactions
    };
    res.status(201).json(successResponse(req, seeded));
});
router.get("/seed/status", requireAdminToken, async (_req, res) => {
    res.json(successResponse(_req, {
        merchants: (await listMerchants()).length,
        outlets: (await listOutlets()).length,
        terminals: (await listTerminals()).length,
        devices: (await listDevices()).length,
        transactions: (await listTransactions()).length,
        heartbeats: (await listHeartbeats()).length,
        notifications: (await listNotifications()).length,
        seed_eligible: (await listMerchants()).length === 0 && (await listDevices()).length === 0 && (await listOutlets()).length === 0 && (await listTerminals()).length === 0
    }));
});
router.post("/merchants/:merchantId/outlets", requireAdminToken, idempotency({ scope: "outlet.create", required: false }), async (req, res, next) => {
    if (parseIdempotentReplay(req)) {
        return res.status(200).json(getReplayResponse(req));
    }
    const merchant = await ensureMerchant(req, next);
    if (!merchant) {
        return;
    }
    const payload = req.body;
    if (!payload?.name) {
        return next(new ApiError("BAD_REQUEST", "name is required", 400));
    }
    if (payload.status) {
        if (!parseOutletStatusFilter(payload.status)) {
            return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
        }
    }
    const outlet = await createOutlet({
        merchant_id: merchant.id,
        name: payload.name,
        address: payload.address,
        outlet_code: payload.outlet_code,
        status: payload.status
    });
    await auditAdminAction(req, {
        action: "outlet.create",
        entity_type: "outlet",
        entity_id: outlet.id,
        after_json: toOutletPayload(outlet)
    });
    res.status(201).json(successResponse(req, outlet));
});
router.get("/outlets", requireAdminToken, async (req, res, next) => {
    const merchantId = req.query.merchant_id?.trim();
    const statusRaw = req.query.status?.trim();
    const status = parseOutletStatusFilter(statusRaw);
    if (statusRaw && !status) {
        return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
    }
    const q = req.query.q?.trim();
    res.json(successResponse(req, (await listOutlets({
        merchant_id: merchantId,
        status,
        q: q || undefined
    })).map(toOutletPayload)));
});
router.get("/outlets/:outletId", requireAdminToken, async (req, res, next) => {
    const outlet = await getOutletById(req.params.outletId);
    if (!outlet) {
        return next(new ApiError("NOT_FOUND", "outlet not found", 404));
    }
    res.json(successResponse(req, toOutletPayload(outlet)));
});
router.patch("/outlets/:outletId", requireAdminToken, async (req, res, next) => {
    const payload = req.body;
    if (!payload || Object.keys(payload).length === 0) {
        return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
    }
    if (payload.status && !parseOutletStatusFilter(payload.status)) {
        return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
    }
    try {
        const existing = await getOutletById(req.params.outletId);
        const updated = await patchOutlet(req.params.outletId, payload);
        await auditAdminAction(req, {
            action: "outlet.update",
            entity_type: "outlet",
            entity_id: updated.id,
            before_json: existing ? toOutletPayload(existing) : null,
            after_json: toOutletPayload(updated)
        });
        res.json(successResponse(req, toOutletPayload(updated)));
    }
    catch (err) {
        if (err instanceof Error && err.message === "OUTLET_NOT_FOUND") {
            return next(new ApiError("NOT_FOUND", "outlet not found", 404));
        }
        return next(err);
    }
});
router.post("/outlets/:outletId/terminals", requireAdminToken, idempotency({ scope: "terminal.create", required: false }), async (req, res, next) => {
    if (parseIdempotentReplay(req)) {
        return res.status(201).json(getReplayResponse(req));
    }
    const outlet = await getOutletById(req.params.outletId);
    if (!outlet) {
        return next(new ApiError("NOT_FOUND", "outlet not found", 404));
    }
    const payload = req.body;
    if (!payload || typeof payload !== "object") {
        return next(new ApiError("BAD_REQUEST", "terminal payload required", 400));
    }
    if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) {
        return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400));
    }
    if (payload.status && !parseTerminalStatusFilter(payload.status)) {
        return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
    }
    const terminal = await createTerminal({
        outlet_id: outlet.id,
        terminal_code: payload.terminal_code,
        qr_mode: payload.qr_mode,
        partner_reference: payload.partner_reference,
        status: payload.status
    });
    await auditAdminAction(req, {
        action: "terminal.create",
        entity_type: "terminal",
        entity_id: terminal.id,
        after_json: toTerminalPayload(terminal)
    });
    res.status(201).json(successResponse(req, toTerminalPayload(terminal)));
});
router.get("/terminals", requireAdminToken, async (req, res, next) => {
    const outletId = req.query.outlet_id?.trim();
    const statusRaw = req.query.status?.trim();
    const status = parseTerminalStatusFilter(statusRaw);
    if (statusRaw && !status) {
        return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
    }
    const q = req.query.q?.trim();
    res.json(successResponse(req, (await listTerminals({
        outlet_id: outletId,
        status,
        q: q || undefined
    })).map(toTerminalPayload)));
});
router.get("/terminals/:terminalId", requireAdminToken, async (req, res, next) => {
    const terminal = await getTerminalById(req.params.terminalId);
    if (!terminal) {
        return next(new ApiError("NOT_FOUND", "terminal not found", 404));
    }
    res.json(successResponse(req, toTerminalPayload(terminal)));
});
router.patch("/terminals/:terminalId", requireAdminToken, async (req, res, next) => {
    const payload = req.body;
    if (!payload || Object.keys(payload).length === 0) {
        return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
    }
    if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) {
        return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400));
    }
    if (payload.status && !parseTerminalStatusFilter(payload.status)) {
        return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
    }
    try {
        const existing = await getTerminalById(req.params.terminalId);
        const updated = await patchTerminal(req.params.terminalId, payload);
        await auditAdminAction(req, {
            action: "terminal.update",
            entity_type: "terminal",
            entity_id: updated.id,
            before_json: existing ? toTerminalPayload(existing) : null,
            after_json: toTerminalPayload(updated)
        });
        res.json(successResponse(req, toTerminalPayload(updated)));
    }
    catch (err) {
        if (err instanceof Error && err.message === "TERMINAL_NOT_FOUND") {
            return next(new ApiError("NOT_FOUND", "terminal not found", 404));
        }
        return next(err);
    }
});
router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", required: false }), async (req, res, next) => {
    if (parseIdempotentReplay(req)) {
        return res.status(201).json(getReplayResponse(req));
    }
    const payload = req.body;
    if (!payload) {
        return next(new ApiError("BAD_REQUEST", "device payload required", 400));
    }
    if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) {
        return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400));
    }
    if (payload.status && !parseDeviceStatusValue(payload.status)) {
        return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
    }
    const created = await createDevice(payload);
    await auditAdminAction(req, {
        action: "device.create",
        entity_type: "device",
        entity_id: created.id,
        after_json: toDevicePayload(created)
    });
    res.status(201).json(successResponse(req, toDevicePayload(created)));
});
router.get("/devices", requireAdminToken, async (req, res) => {
    const status = parseDeviceStatusFilter(req.query.status);
    const vendor = req.query.vendor?.trim();
    const communicationMode = parseCommunicationModeFilter(req.query.communication_mode);
    const merchantId = req.query.merchant_id?.trim();
    const q = req.query.q?.trim();
    const rawDevices = await listDevices();
    const evaluated = await Promise.all(rawDevices.map(async (device) => {
        const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
        const binding = merchantId ? await getActiveBindingByDevice(device.id) : null;
        const healthSummary = deriveDeviceHealthSummary({
            last_seen_at: device.last_seen_at,
            network_strength: latestHeartbeat?.network_strength ?? null,
            battery_level: latestHeartbeat?.battery_level ?? null
        });
        return {
            device,
            latestHeartbeat,
            binding,
            derivedStatus: healthSummary.status
        };
    }));
    const data = evaluated
        .filter((entry) => {
        const { device, derivedStatus, binding } = entry;
        if (status && derivedStatus !== status) {
            return false;
        }
        if (vendor && device.vendor !== vendor) {
            return false;
        }
        if (communicationMode && device.communication_mode !== communicationMode) {
            return false;
        }
        if (merchantId && (!binding || binding.merchant_id !== merchantId)) {
            return false;
        }
        if (q) {
            const text = q.toLowerCase();
            const codeMatch = device.device_code.toLowerCase().includes(text);
            const serialMatch = device.serial_number?.toLowerCase().includes(text);
            if (!codeMatch && !serialMatch) {
                return false;
            }
        }
        return true;
    })
        .map((entry) => entry.device);
    const payloads = await Promise.all(data
        .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
        .map((device) => buildDeviceAdminPayload(device)));
    res.json(successResponse(req, payloads));
});
router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const activeBinding = await getActiveBindingByDevice(device.id);
    const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
    const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id);
    const healthSummary = deriveDeviceHealthSummary({
        last_seen_at: device.last_seen_at,
        network_strength: latestHeartbeat?.network_strength ?? null,
        battery_level: latestHeartbeat?.battery_level ?? null
    });
    const notifications = (await listNotificationsByDevice(device.id))
        .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
        .slice(0, 10)
        .map(toNotificationPayload);
    res.json(successResponse(req, {
        ...toDevicePayload(device),
        capability_summary: resolveDeviceCapabilitySummary(device),
        derived_status: healthSummary.status,
        health_summary: healthSummary,
        active_binding: activeBinding ? toBindingPayload(activeBinding) : null,
        latest_heartbeat: latestHeartbeat
            ? {
                id: latestHeartbeat.id,
                timestamp: latestHeartbeat.timestamp,
                received_at: latestHeartbeat.received_at,
                state: latestHeartbeat.state,
                network_strength: latestHeartbeat.network_strength,
                battery_level: latestHeartbeat.battery_level,
                firmware_version: latestHeartbeat.firmware_version
            }
            : null,
        heartbeat_count_24h: heartbeatCount24h,
        notifications_latest: notifications
    }));
});
router.get("/devices/:deviceId/heartbeats", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const from = req.query.from;
    const to = req.query.to;
    const state = req.query.state;
    if (from && Number.isNaN(Date.parse(from))) {
        return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
    }
    if (to && Number.isNaN(Date.parse(to))) {
        return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
    }
    res.json(successResponse(req, {
        device_id: device.id,
        heartbeats: await listHeartbeats({
            device_id: device.id,
            from,
            to,
            state
        })
    }));
});
router.patch("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
    const payload = req.body;
    if (!payload || Object.keys(payload).length === 0) {
        return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
    }
    if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) {
        return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400));
    }
    if (payload.status && !parseDeviceStatusValue(payload.status)) {
        return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
    }
    try {
        const existing = await getDeviceById(req.params.deviceId);
        const updated = await patchDevice(req.params.deviceId, payload);
        await auditAdminAction(req, {
            action: "device.update",
            entity_type: "device",
            entity_id: updated.id,
            before_json: existing ? toDevicePayload(existing) : null,
            after_json: toDevicePayload(updated)
        });
        res.json(successResponse(req, toDevicePayload(updated)));
    }
    catch (err) {
        if (err instanceof Error && err.message === "DEVICE_NOT_FOUND") {
            return next(new ApiError("NOT_FOUND", "device not found", 404));
        }
        return next(err);
    }
});
router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: "device.bind", required: false }), async (req, res, next) => {
    if (parseIdempotentReplay(req)) {
        return res.status(200).json(getReplayResponse(req));
    }
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const payload = req.body;
    if (!payload?.merchant_id || !payload.outlet_id || !payload.terminal_id) {
        return next(new ApiError("BAD_REQUEST", "merchant_id, outlet_id, terminal_id required", 400));
    }
    const merchant = await getMerchantById(payload.merchant_id);
    const outlet = await getOutletById(payload.outlet_id);
    const terminal = await getTerminalById(payload.terminal_id);
    if (!merchant || !outlet || !terminal) {
        return next(new ApiError("BAD_REQUEST", "merchant/outlet/terminal reference invalid", 400));
    }
    if (outlet.merchant_id !== merchant.id) {
        return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400));
    }
    if (terminal.outlet_id !== outlet.id) {
        return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400));
    }
    const binding = await bindDevice({
        device_id: device.id,
        merchant_id: merchant.id,
        outlet_id: outlet.id,
        terminal_id: terminal.id
    });
    await auditAdminAction(req, {
        action: "device.bind",
        entity_type: "device_binding",
        entity_id: binding.id,
        after_json: toBindingPayload(binding)
    });
    res.json(successResponse(req, toBindingPayload(binding)));
});
router.post("/devices/:deviceId/unbind", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const binding = await unbindDevice(device.id);
    if (!binding) {
        return next(new ApiError("BAD_REQUEST", "device has no active binding", 400));
    }
    await auditAdminAction(req, {
        action: "device.unbind",
        entity_type: "device_binding",
        entity_id: binding.id,
        before_json: toBindingPayload(binding),
        after_json: toBindingPayload(binding)
    });
    res.json(successResponse(req, toBindingPayload(binding)));
});
router.post("/devices/:deviceId/commands", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const payload = req.body;
    if (!payload || typeof payload.command !== "string" || payload.command.trim() === "") {
        return next(new ApiError("BAD_REQUEST", "command is required", 400));
    }
    const command = await createDeviceCommand({
        device_id: device.id,
        command: payload.command.trim(),
        payload: payload.payload || {}
    });
    res.status(201).json(successResponse(req, toDeviceCommandPayload(command)));
});
router.get("/devices/:deviceId/commands", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const statusFilter = parseCommandStatusFilter(req.query.status);
    const limitRaw = req.query.limit;
    const limit = limitRaw ? Number(limitRaw) : 50;
    if (!Number.isFinite(limit) || limit <= 0) {
        return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
    }
    const commands = (await listDeviceCommands(device.id))
        .filter((command) => !statusFilter || command.status === statusFilter)
        .slice(0, Math.min(limit, 200))
        .map(toDeviceCommandPayloadBrief);
    res.json(successResponse(req, { device_id: device.id, commands }));
});
router.get("/devices/:deviceId/commands/:commandId", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const command = await getDeviceCommandById(device.id, req.params.commandId);
    if (!command) {
        return next(new ApiError("NOT_FOUND", "command not found", 404));
    }
    res.json(successResponse(req, toDeviceCommandPayload(command)));
});
router.get("/devices/:deviceId/notifications", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const limitRaw = req.query.limit;
    const limit = limitRaw ? Number(limitRaw) : 50;
    if (!Number.isFinite(limit) || limit <= 0) {
        return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
    }
    const notifications = (await listNotificationsByDevice(device.id))
        .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
        .slice(0, Math.min(limit, 200))
        .map(toNotificationPayload);
    res.json(successResponse(req, { device_id: device.id, notifications }));
});
router.get("/devices/:deviceId/config", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const config = await getOrCreateDeviceConfig(device.id);
    const acks = (await listDeviceConfigAcks(device.id, 10)).map(toDeviceConfigAckPayload);
    const status = await buildDeviceConfigStatus(config);
    res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks, config_status: status }));
});
router.get("/devices/:deviceId/config/status", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const config = await getOrCreateDeviceConfig(device.id);
    res.json(successResponse(req, await buildDeviceConfigStatus(config)));
});
router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const payload = req.body;
    if (!payload || !payload.settings || typeof payload.settings !== "object") {
        return next(new ApiError("BAD_REQUEST", "settings object is required", 400));
    }
    const config = await upsertDeviceConfig({
        device_id: device.id,
        settings_json: payload.settings,
        config_version: payload.config_version
    });
    const outbox = await publishDeviceConfigPush(device.id, config);
    await auditAdminAction(req, {
        action: "device.config_push",
        entity_type: "device",
        entity_id: device.id,
        after_json: {
            config,
            downlink_message_id: outbox.id
        }
    });
    res.json(successResponse(req, {
        config: toDeviceConfigPayload(config),
        downlink_message: toMqttMessagePayload(outbox)
    }));
});
router.post("/devices/:deviceId/config/retry-push", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const payload = (req.body || {});
    const config = await getOrCreateDeviceConfig(device.id);
    const beforeStatus = await buildDeviceConfigStatus(config);
    if (beforeStatus.drift_status === "applied" && !payload.force) {
        return next(new ApiError("CONFIG_ALREADY_APPLIED", "config already applied; set force=true to push again", 409));
    }
    const outbox = await publishDeviceConfigPush(device.id, config);
    const afterStatus = await buildDeviceConfigStatus(config);
    await auditAdminAction(req, {
        action: "device.config_retry_push",
        entity_type: "device",
        entity_id: device.id,
        before_json: beforeStatus,
        after_json: {
            ...afterStatus,
            downlink_message_id: outbox.id,
            force: payload.force === true
        }
    });
    res.json(successResponse(req, {
        config: toDeviceConfigPayload(config),
        config_status: afterStatus,
        downlink_message: toMqttMessagePayload(outbox)
    }));
});
router.get("/devices/:deviceId/mqtt-messages", requireAdminToken, async (req, res, next) => {
    const device = await getDeviceById(req.params.deviceId);
    if (!device) {
        return next(new ApiError("NOT_FOUND", "device not found", 404));
    }
    const directionRaw = req.query.direction?.trim();
    const direction = directionRaw === "uplink" || directionRaw === "downlink" ? directionRaw : undefined;
    if (directionRaw && !direction) {
        return next(new ApiError("BAD_REQUEST", "direction must be uplink or downlink", 400));
    }
    const messageType = req.query.message_type?.trim();
    const correlationId = req.query.correlation_id?.trim();
    const limitRaw = req.query.limit;
    const limit = limitRaw ? Number(limitRaw) : 100;
    if (!Number.isFinite(limit) || limit <= 0) {
        return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
    }
    const messages = await listMqttMessages({
        device_id: device.id,
        direction,
        message_type: messageType || undefined,
        correlation_id: correlationId || undefined,
        limit
    });
    res.json(successResponse(req, { device_id: device.id, messages: messages.map(toMqttMessagePayload) }));
});
router.post("/transactions", requireAdminToken, idempotency({ scope: "transaction.create", required: false }), async (req, res, next) => {
    if (parseIdempotentReplay(req)) {
        return res.status(201).json(getReplayResponse(req));
    }
    const payload = req.body;
    if (!payload ||
        !payload.partner_reference ||
        !payload.merchant_id ||
        !payload.outlet_id ||
        !payload.terminal_id) {
        return next(new ApiError("BAD_REQUEST", "partner_reference, merchant_id, outlet_id, terminal_id required", 400));
    }
    const merchant = await getMerchantById(payload.merchant_id);
    if (!merchant) {
        return next(new ApiError("BAD_REQUEST", "merchant not found", 400));
    }
    const outlet = await getOutletById(payload.outlet_id);
    if (!outlet) {
        return next(new ApiError("BAD_REQUEST", "outlet not found", 400));
    }
    if (outlet.merchant_id !== merchant.id) {
        return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400));
    }
    const terminal = await getTerminalById(payload.terminal_id);
    if (!terminal) {
        return next(new ApiError("BAD_REQUEST", "terminal not found", 400));
    }
    if (terminal.outlet_id !== outlet.id) {
        return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400));
    }
    if (payload.device_id && !await getDeviceById(payload.device_id)) {
        return next(new ApiError("BAD_REQUEST", "device not found", 400));
    }
    const amount = Number(payload.amount);
    if (!Number.isFinite(amount) || amount <= 0) {
        return next(new ApiError("BAD_REQUEST", "amount must be a positive number", 400));
    }
    if (payload.status && !parseTransactionStatusFilter(payload.status)) {
        return next(new ApiError("BAD_REQUEST", "invalid status", 400));
    }
    if (payload.expired_at && Number.isNaN(Date.parse(payload.expired_at))) {
        return next(new ApiError("BAD_REQUEST", "expired_at must be valid ISO datetime", 400));
    }
    const created = await createTransaction({
        merchant_id: merchant.id,
        outlet_id: outlet.id,
        terminal_id: terminal.id,
        device_id: payload.device_id,
        partner_reference: payload.partner_reference,
        amount,
        currency: payload.currency,
        qr_mode: payload.qr_mode || "static",
        initiation_mode: payload.initiation_mode || "static",
        status: payload.status || "initiated",
        expired_at: payload.expired_at
    });
    await auditAdminAction(req, {
        action: "transaction.create",
        entity_type: "transaction",
        entity_id: created.id,
        after_json: toTransactionPayload(created)
    });
    res.status(201).json(successResponse(req, toTransactionPayload(created)));
});
router.get("/transactions", requireAdminToken, async (req, res, next) => {
    const statusRaw = req.query.status?.trim();
    const status = parseTransactionStatusFilter(statusRaw);
    if (statusRaw && !status) {
        return next(new ApiError("BAD_REQUEST", "invalid status", 400));
    }
    const merchantId = req.query.merchant_id?.trim();
    const from = req.query.from;
    const to = req.query.to;
    const partnerReference = req.query.partner_reference;
    const q = req.query.q?.trim();
    if (from && !isIsoDate(from)) {
        return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
    }
    if (to && !isIsoDate(to)) {
        return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
    }
    const normalizedPartnerRef = partnerReference?.trim();
    const normalizedQ = q || "";
    res.json(successResponse(req, (await listTransactions({
        status,
        merchant_id: merchantId
    }))
        .filter((tx) => isTxInDateRange(tx, from, to))
        .filter((tx) => !normalizedPartnerRef || tx.partner_reference === normalizedPartnerRef)
        .filter((tx) => {
        if (!normalizedQ) {
            return true;
        }
        const lower = normalizedQ.toLowerCase();
        return (tx.partner_reference.toLowerCase().includes(lower) ||
            tx.transaction_code.toLowerCase().includes(lower));
    })
        .map(toTransactionPayload)));
});
router.post("/transactions/expire-due", requireAdminToken, async (req, res, next) => {
    const limitRaw = req.body?.limit ?? req.query.limit;
    const limit = limitRaw === undefined || limitRaw === "" ? 100 : Number(limitRaw);
    if (!Number.isFinite(limit) || limit <= 0) {
        return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
    }
    const result = await expireDueDynamicQrTransactions({
        limit,
        source: "admin",
        request_id: req.requestId
    });
    await auditAdminAction(req, {
        action: "transaction.expire_due_dynamic_qr",
        entity_type: "transaction_batch",
        entity_id: `dynamic_qr_expiry_${result.swept_at}`,
        after_json: {
            scanned: result.scanned,
            expired_count: result.expired_count,
            skipped_count: result.skipped_count,
            expired_ids: result.expired.map((tx) => tx.id),
            skipped: result.skipped
        }
    });
    res.json(successResponse(req, result));
});
router.get("/transactions/:transactionId", requireAdminToken, async (req, res, next) => {
    const tx = await getTransactionById(req.params.transactionId);
    if (!tx) {
        return next(new ApiError("NOT_FOUND", "transaction not found", 404));
    }
    const events = (await getTransactionEvents(tx.id)).map(toTransactionEventPayload);
    const ledger_entries = (await listLedgerEntries({ transaction_id: tx.id })).map(toLedgerEntryPayload);
    const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null;
    const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id;
    const heartbeatHistory = heartbeatDeviceId
        ? (await listHeartbeats({ device_id: heartbeatDeviceId })).map((heartbeat) => ({
            id: heartbeat.id,
            device_id: heartbeat.device_id,
            timestamp: heartbeat.timestamp,
            state: heartbeat.state,
            network_strength: heartbeat.network_strength,
            battery_level: heartbeat.battery_level,
            firmware_version: heartbeat.firmware_version,
            received_at: heartbeat.received_at
        }))
        : [];
    res.json(successResponse(req, {
        transaction: toTransactionPayload(tx),
        events,
        ledger_entries,
        heartbeat_device_id: heartbeatDeviceId,
        heartbeat_history: heartbeatHistory
    }));
});
router.get("/transactions/:transactionId/events", requireAdminToken, async (req, res, next) => {
    const tx = await getTransactionById(req.params.transactionId);
    if (!tx) {
        return next(new ApiError("NOT_FOUND", "transaction not found", 404));
    }
    const events = (await getTransactionEvents(tx.id))
        .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
        .map(toTransactionEventPayload);
    res.json(successResponse(req, { transaction_id: tx.id, events }));
});
router.get("/transactions/:transactionId/heartbeats", requireAdminToken, async (req, res, next) => {
    const tx = await getTransactionById(req.params.transactionId);
    if (!tx) {
        return next(new ApiError("NOT_FOUND", "transaction not found", 404));
    }
    const from = req.query.from;
    const to = req.query.to;
    const state = req.query.state;
    const limitRaw = req.query.limit;
    const limit = limitRaw ? Number(limitRaw) : 100;
    if (from && Number.isNaN(Date.parse(from))) {
        return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
    }
    if (to && Number.isNaN(Date.parse(to))) {
        return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
    }
    if (!Number.isFinite(limit) || limit <= 0) {
        return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
    }
    const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null;
    const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id;
    if (!heartbeatDeviceId) {
        return res.json(successResponse(req, {
            transaction_id: tx.id,
            heartbeat_device_id: null,
            heartbeats: []
        }));
    }
    const heartbeats = (await listHeartbeats({
        device_id: heartbeatDeviceId,
        from,
        to,
        state
    }))
        .slice(0, Math.min(limit, 500))
        .map((heartbeat) => ({
        id: heartbeat.id,
        device_id: heartbeat.device_id,
        timestamp: heartbeat.timestamp,
        received_at: heartbeat.received_at,
        state: heartbeat.state,
        network_strength: heartbeat.network_strength,
        battery_level: heartbeat.battery_level,
        firmware_version: heartbeat.firmware_version
    }));
    res.json(successResponse(req, {
        transaction_id: tx.id,
        heartbeat_device_id: heartbeatDeviceId,
        heartbeats
    }));
});
router.post("/transactions/:transactionId/retry-notification", requireAdminToken, async (req, res, next) => {
    const transactionId = req.params.transactionId;
    try {
        const tx = await getTransactionById(transactionId);
        if (!tx) {
            return next(new ApiError("NOT_FOUND", "transaction not found", 404));
        }
        if (tx.status !== "paid") {
            return next(new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400));
        }
        const before = await getNotificationByTransactionId(transactionId);
        if (!before) {
            return next(new ApiError("NOT_FOUND", "notification not found for this transaction", 404));
        }
        if (before.delivery_status === "acknowledged") {
            return res.status(200).json(successResponse(req, {
                transaction_id: tx.id,
                notification_id: before.id,
                delivery_status: before.delivery_status,
                next_retry_at: before.next_retry_at || null
            }));
        }
        const updated = await retryNotificationByTransactionId(transactionId);
        if (!updated) {
            return next(new ApiError("NOTIFICATION_PUBLISH_FAILED", "notification retry could not be executed", 500));
        }
        res.json(successResponse(req, {
            transaction_id: tx.id,
            notification_id: updated.id,
            delivery_status: updated.delivery_status,
            next_retry_at: updated.next_retry_at || null
        }));
    }
    catch (error) {
        if (error instanceof Error && error.message === "NOTIFICATION_PUBLISH_CONDITION") {
            return next(new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400));
        }
        return next(error);
    }
});
router.get("/dashboard/summary", requireAdminToken, async (req, res) => {
    let dashboard = {
        transactions_today: 0,
        success_rate_today: 0,
        active_devices: 0,
        pending_notifications: 0,
        devices_stale: 0,
        devices_offline: 0
    };
    try {
        const { start, end } = buildDashboardRange();
        const startTs = start.getTime();
        const endTs = end.getTime();
        const todayTransactions = (await listTransactions()).filter((tx) => {
            const createdTs = Date.parse(tx.created_at);
            return createdTs >= startTs && createdTs < endTs;
        });
        const paidToday = todayTransactions.filter((tx) => tx.status === "paid").length;
        const transactionsToday = todayTransactions.length;
        const successRateToday = transactionsToday > 0 ? (paidToday / transactionsToday) * 100 : 0;
        const statuses = await deriveDeviceStatusesForDashboard();
        const activeDevices = statuses.filter((row) => row.status !== "offline").length;
        const devicesStale = statuses.filter((row) => row.status === "stale").length;
        const devicesOffline = statuses.filter((row) => row.status === "offline").length;
        const pendingNotifications = (await listNotifications()).filter((notification) => {
            return notification.delivery_status === "queued" || notification.delivery_status === "retrying";
        }).length;
        dashboard = {
            transactions_today: transactionsToday,
            success_rate_today: Number(successRateToday.toFixed(2)),
            active_devices: activeDevices,
            pending_notifications: pendingNotifications,
            devices_stale: devicesStale,
            devices_offline: devicesOffline
        };
    }
    catch (error) {
        console.error("[dashboard/summary] fallback due calculation error", error instanceof Error ? error.message : error);
    }
    res.json(successResponse(req, dashboard));
});
router.get("/notifications/failed", requireAdminToken, async (req, res, next) => {
    const deviceId = req.query.device_id;
    const from = req.query.from;
    const to = req.query.to;
    const range = toStartEndDateFilter(from, to);
    if ((from || to) && range === null) {
        return next(new ApiError("BAD_REQUEST", "from/to must be valid ISO datetime", 400));
    }
    const filtered = (await listNotifications())
        .filter((notification) => notification.delivery_status === "failed")
        .filter((notification) => !deviceId || notification.device_id === deviceId)
        .filter((notification) => {
        if (!range) {
            return true;
        }
        const createdTs = Date.parse(notification.created_at);
        if (range.fromTs && createdTs < range.fromTs) {
            return false;
        }
        if (range.toTs && createdTs > range.toTs) {
            return false;
        }
        return true;
    })
        .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
        .map((notification) => ({
        notification_id: notification.id,
        transaction_id: notification.transaction_id,
        device_id: notification.device_id,
        delivery_status: notification.delivery_status,
        retry_count: notification.retry_count,
        reason: notification.reason
    }));
    res.json(successResponse(req, filtered));
});
router.get("/audit-logs", requireAdminToken, async (req, res, next) => {
    const entityType = req.query.entity_type?.trim();
    const entityId = req.query.entity_id?.trim();
    const action = req.query.action?.trim();
    const from = req.query.from;
    const to = req.query.to;
    const limitRaw = req.query.limit;
    const limit = limitRaw ? Number(limitRaw) : 100;
    if (from && Number.isNaN(Date.parse(from))) {
        return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400));
    }
    if (to && Number.isNaN(Date.parse(to))) {
        return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400));
    }
    if (!Number.isFinite(limit) || limit <= 0) {
        return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
    }
    const logs = await listAuditLogs({
        entity_type: entityType || undefined,
        entity_id: entityId || undefined,
        action: action || undefined,
        from,
        to,
        limit
    });
    res.json(successResponse(req, logs.map(toAuditLogPayload)));
});
router.get("/ledger-entries", requireAdminToken, async (req, res, next) => {
    const transactionId = req.query.transaction_id?.trim();
    const merchantId = req.query.merchant_id?.trim();
    const limitRaw = req.query.limit;
    const limit = limitRaw ? Number(limitRaw) : 100;
    if (!Number.isFinite(limit) || limit <= 0) {
        return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
    }
    const entries = await listLedgerEntries({
        transaction_id: transactionId || undefined,
        merchant_id: merchantId || undefined,
        limit
    });
    res.json(successResponse(req, entries.map(toLedgerEntryPayload)));
});
export default router;
