/**
 * src/server/app.js
 *
 * Express app factory.
 * Extracted during thinning pass (2026-05-28) to make index.js a thin bootstrap.
 *
 * Dependencies are injected so the server module has no hidden coupling to the monolith.
 */

import express from 'express';
import bodyParser from 'body-parser';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import cron from 'node-cron';

import { logger, ensureLogDir, getLogDir } from '../utils/logger.js';
import { verifyWebhook } from './webhookAuth.js';
import { requireAdmin } from './adminAuth.js';
import { pollActiveShipments, backfillShipmentsFromAllMappings } from '../services/shipmentTrackingService.js';
import { isShipmentTrackingEnabled } from '../integrations/fedex/client.js';
import { discoverWorkOrderCreateFields } from '../integrations/serviceChannel/client.js';
import { getWorkOrderCreateConfig } from '../config/workOrderCreateConfig.js';

// ────────────────────────────────────────────────────────────────────────────
// HTML escaping — used everywhere we interpolate SC-sourced data into HTML
// (technicians type note content and it lands here; without escaping this is
// a reflected XSS vector).
// ────────────────────────────────────────────────────────────────────────────
function esc(v) {
  if (v === null || v === undefined) return '';
  return String(v)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

export function createApp({
  db,
  DB_PATH,
  webhookProcessor,
  runSpaceCleanup,
  Framework,
  getStaleWorkOrdersReport,
}) {
  // Note: `db` here should be the single database connection created by the main
  // application using the production DB_PATH. This ensures we never accidentally
  // talk to a different database file.
  const app = express();

  app.use(bodyParser.json({
    // Capture raw body so webhook signature verification can HMAC it.
    verify: (req, res, buf) => { req.rawBody = buf; }
  }));

  // Enhanced Health Check with Docker/Container awareness
  app.get('/health', async (req, res) => {
    const startTime = Date.now();

    const health = {
      status: 'healthy',
      service: 'servchan-bot',
      version: process.env.npm_package_version || '1.0.0',
      uptime: Math.round(process.uptime()),
      uptime_human: `${Math.floor(process.uptime() / 3600)}h ${Math.floor((process.uptime() % 3600) / 60)}m`,
      timestamp: new Date().toISOString(),
      environment: process.env.NODE_ENV || 'development',

      container: {
        node_version: process.version,
        pid: process.pid,
        platform: process.platform,
        arch: process.arch,
        memory: {
          rss: `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`,
          heapTotal: `${(process.memoryUsage().heapTotal / 1024 / 1024).toFixed(2)} MB`,
          heapUsed: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB`,
          external: `${(process.memoryUsage().external / 1024 / 1024).toFixed(2)} MB`
        }
      },

      database: {
        status: 'unknown',
        path: DB_PATH
      },

      webex: {
        framework: Framework ? 'initialized' : 'not_ready',
        spawn_mode: 'disabled'
      },

      response_time_ms: 0
    };

    try {
      await new Promise((resolve, reject) => {
        db.get('SELECT 1 as ok', (err, row) => {
          if (err) reject(err);
          else resolve(row);
        });
      });
      health.database.status = 'connected';
    } catch (err) {
      health.database.status = 'error';
      health.database.error = err.message;
      health.status = 'degraded';
    }

    health.response_time_ms = Date.now() - startTime;

    const httpStatus = health.status === 'healthy' ? 200 : 503;
    res.status(httpStatus).json(health);
  });

  // Lightweight liveness probe (used by Docker healthcheck)
  app.get('/healthz', (req, res) => {
    res.status(200).send('ok');
  });

  // ────────────────────────────────────────────────────────────────────────
  // Webhook route
  //
  // Auth is scaffolded via verifyWebhook middleware. Default mode is "off"
  // (backward compatible). Once ServiceChannel is configured to sign / send a
  // shared token, flip SC_WEBHOOK_AUTH_MODE=log to observe successes/failures
  // in the log, then SC_WEBHOOK_AUTH_MODE=enforce to reject unauthenticated
  // requests. See src/server/webhookAuth.js.
  // ────────────────────────────────────────────────────────────────────────
  app.post('/webhook', verifyWebhook, (req, res) => {
    logWebhookPayload(req.body);

    // Basic shape check before we ack. If we can't even see an Object.Id,
    // return 400 so the sender can log the malformed payload upstream.
    if (!req.body || typeof req.body !== 'object' || !req.body.Object?.Id) {
      logger('webhook:route', `Rejected malformed payload from ${req.ip}`, 'warn');
      return res.status(400).json({ error: 'malformed webhook payload' });
    }

    // Ack immediately and dispatch. If the processor throws synchronously
    // (bad wiring) we log it but the response is already sent.
    res.status(200).json({ ok: true });

    Promise.resolve()
      .then(() => webhookProcessor.processWebhook(req.body))
      .catch(err => {
        logger('webhook:route', `Unhandled error in processor for WO ${req.body?.Object?.Id}: ${err.message}`, 'error');
      });
  });

  // ────────────────────────────────────────────────────────────────────────
  // /cleanup-test — protected admin endpoint
  //
  // Dry-run by default. To actually delete/prune, you now need BOTH
  // ?dryRun=false AND ?live=true. The old "one flag flips destructive mode"
  // behavior was a foot-gun.
  // ────────────────────────────────────────────────────────────────────────
  app.get('/cleanup-test', requireAdmin(), async (req, res) => {
    const requestedLive = req.query.dryRun === 'false' && req.query.live === 'true';
    const dryRun = !requestedLive;

    logger('cleanup-test', `Called dryRun=${dryRun} live=${requestedLive} by ${req.ip}`);

    // Pass the real db instance when available so we use the production database
    const result = await runSpaceCleanup(dryRun, { db });

    if (result.error) {
      return res.status(500).send(`<h2>Error</h2><pre>${esc(result.error)}</pre>`);
    }

    const { summary, results, mode } = result;

    let html = `
      <!DOCTYPE html>
      <html>
      <head>
        <title>Space Cleanup - ${esc(mode)}</title>
        <style>
          body { font-family: Arial, sans-serif; margin: 20px; }
          table { border-collapse: collapse; width: 100%; margin-top: 20px; }
          th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
          th { background-color: #f0f0f0; }
          .archive { background-color: #fff3cd; }
          .delete { background-color: #f8d7da; }
          .remind { background-color: #d1ecf1; }
          .digest { background-color: #d4edda; }
          .skipped { color: #666; }
          h1 { color: #333; }
          .banner { padding: 10px; border-radius: 4px; margin: 10px 0; }
          .banner-live { background: #f8d7da; border: 1px solid #dc3545; }
          .banner-dry { background: #d1ecf1; border: 1px solid #0c5460; }
        </style>
      </head>
      <body>
        <h1>Space Cleanup Report - ${esc(mode)}</h1>
        <div class="banner ${dryRun ? 'banner-dry' : 'banner-live'}">
          <strong>${dryRun ? 'DRY RUN' : 'LIVE RUN'}</strong> —
          ${dryRun
            ? 'No changes were made. Add <code>?dryRun=false&amp;live=true</code> to actually run.'
            : 'Destructive actions were performed against Webex and the mappings DB.'}
          <br><br>
          <strong>Rules:</strong>
          Cleanup applies only to service provider <code>${esc(process.env.SPACE_CLEANUP_PROVIDER_NAME || 'Pro-Motion Technology Group, LLC')}</code>
          (ID <code>${esc(process.env.SPACE_CLEANUP_PROVIDER_ID || '2000002215')}</code>).
          Auto-remove (prune ≥14d / delete ≥60d) for <code>COMPLETED / CONFIRMED</code>,
          <code>COMPLETED / CANCELLED</code>, and <code>COMPLETED / NO CHARGE</code>.
          All other <code>COMPLETED</code> variants are held until one of those terminal statuses;
          a consolidated daily digest posts to the ops room after ${esc(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60')} days.
          Invoice PDFs trigger an approval card in the WO room; use <code>/confirmed</code> after invoice
          (sets SC CONFIRMED when allowed, otherwise records close-out via SC note + ServChan DB).
        </div>
        <h2>Summary</h2>
        <pre>${esc(JSON.stringify(summary, null, 2))}</pre>

        <h2>Full Results (${results.length} mappings)</h2>
        <table>
          <thead>
            <tr>
              <th>Work Order ID</th>
              <th>Room ID</th>
              <th>Action</th>
              <th>Days Old</th>
              <th>Status</th>
              <th>Reason</th>
            </tr>
          </thead>
          <tbody>
    `;

    results.forEach(r => {
      const rowClass = r.action.includes('archive') || r.action.includes('remove') ? 'archive' :
        r.action.includes('delete') ? 'delete' :
        r.action.includes('digest') ? 'digest' :
        r.action.includes('remind') ? 'remind' : 'skipped';
      html += `
        <tr class="${rowClass}">
          <td>${esc(r.woId)}</td>
          <td style="font-size: 0.85em; word-break: break-all;">${esc(r.roomId)}</td>
          <td><strong>${esc(r.action)}</strong></td>
          <td>${r.days !== null && r.days !== undefined ? esc(r.days) : '-'}</td>
          <td>${esc(r.status)}</td>
          <td>${esc(r.reason || '')}</td>
        </tr>`;
    });

    html += `
          </tbody>
        </table>
      </body>
      </html>`;

    res.send(html);
  });

  // ────────────────────────────────────────────────────────────────────────
  // /track-backfill — protected admin endpoint
  //
  // Dry-run by default. Scans all mapped WOs for FedEx numbers in SC notes,
  // registers them silently (no WO-room post), then polls FedEx once in live
  // mode. Already-delivered packages are marked without ops-room notification.
  // Requires ?dryRun=false&live=true to write.
  // ────────────────────────────────────────────────────────────────────────
  app.get('/track-backfill', requireAdmin(), async (req, res) => {
    const requestedLive = req.query.dryRun === 'false' && req.query.live === 'true';
    const dryRun = !requestedLive;

    logger('track-backfill', `Called dryRun=${dryRun} live=${requestedLive} by ${req.ip}`);

    const result = await backfillShipmentsFromAllMappings({ db, dryRun });

    if (result.error) {
      return res.status(503).send(`<h2>Error</h2><pre>${esc(result.error)}</pre>`);
    }

    const { summary, results, mode } = result;

    let html = `
      <!DOCTYPE html>
      <html>
      <head>
        <title>FedEx Track Backfill - ${esc(mode)}</title>
        <style>
          body { font-family: Arial, sans-serif; margin: 20px; }
          table { border-collapse: collapse; width: 100%; margin-top: 20px; font-size: 0.9em; }
          th, td { border: 1px solid #ccc; padding: 8px; text-align: left; vertical-align: top; }
          th { background-color: #f0f0f0; }
          h1 { color: #333; }
          .banner { padding: 10px; border-radius: 4px; margin: 10px 0; }
          .banner-live { background: #f8d7da; border: 1px solid #dc3545; }
          .banner-dry { background: #d1ecf1; border: 1px solid #0c5460; }
          .error { background-color: #f8d7da; }
        </style>
      </head>
      <body>
        <h1>FedEx Track Backfill - ${esc(mode)}</h1>
        <div class="banner ${dryRun ? 'banner-dry' : 'banner-live'}">
          <strong>${dryRun ? 'DRY RUN' : 'LIVE RUN'}</strong> —
          ${dryRun
            ? 'No changes were made. Add <code>?dryRun=false&amp;live=true</code> to register and poll FedEx.'
            : 'New tracking numbers were inserted and polled once. WO rooms were not notified; historical deliveries were marked silently (no ops post).'}
        </div>
        <h2>Summary</h2>
        <pre>${esc(JSON.stringify(summary, null, 2))}</pre>

        <h2>Work Orders With Tracking (${results.length})</h2>
        <table>
          <thead>
            <tr>
              <th>Work Order ID</th>
              <th>Room ID</th>
              <th>Found</th>
              <th>Would Register / Registered</th>
              <th>Already Tracked</th>
              <th>Error</th>
            </tr>
          </thead>
          <tbody>`;

    for (const r of results) {
      html += `
            <tr class="${r.error ? 'error' : ''}">
              <td>${esc(r.workOrderId)}</td>
              <td><code>${esc(r.roomId)}</code></td>
              <td>${esc((r.trackingNumbers || []).join(', '))}</td>
              <td>${esc((r.registered || []).join(', '))}</td>
              <td>${esc((r.alreadyTracked || []).join(', '))}</td>
              <td>${esc(r.error || '')}</td>
            </tr>`;
    }

    html += `
          </tbody>
        </table>
      </body>
      </html>`;

    res.send(html);
  });

  // ────────────────────────────────────────────────────────────────────────
  // /create-wo-discovery — inspect recent SC WOs for /createWO defaults
  // ────────────────────────────────────────────────────────────────────────
  app.get('/create-wo-discovery', requireAdmin(), async (req, res) => {
    const trade = req.query.trade ? String(req.query.trade) : getWorkOrderCreateConfig().tradeName;
    const limit = req.query.limit ? Number(req.query.limit) : 25;

    logger('create-wo-discovery', `Called trade=${trade} limit=${limit} by ${req.ip}`);

    try {
      const [discovery, config] = await Promise.all([
        discoverWorkOrderCreateFields({ trade, limit }),
        Promise.resolve(getWorkOrderCreateConfig()),
      ]);

      res.json({
        config,
        discovery,
        hint:
          'Set SC_CREATE_CATEGORIES, SC_CREATE_PRIORITIES, SC_CREATE_DEFAULT_* from suggested values. ' +
          'If create fails with error 903, set SC_CREATE_REQUIRE_ISSUELIST=true and IssueList env vars.',
      });
    } catch (err) {
      logger('create-wo-discovery', `Failed: ${err.message}`, 'error');
      res.status(502).json({ error: err.message });
    }
  });

  // Stale Work Order Report (Phase 0) — protected admin endpoint
  if (getStaleWorkOrdersReport) {
    app.get('/stale-workorders', requireAdmin(), async (req, res) => {
      try {
        const report = await getStaleWorkOrdersReport(db);

        if (report.error) {
          return res.status(503).send(`
            <h1>Stale Work Orders Report</h1>
            <p style="color: #c00; font-weight: bold;">${esc(report.message)}</p>
            <p>Please set the required ServiceChannel environment variables and restart the application.</p>
          `);
        }

        const { summary, items } = report;

        let html = `
          <!DOCTYPE html>
          <html>
          <head>
            <title>Stale Work Orders Report</title>
            <style>
              body { font-family: Arial, sans-serif; margin: 20px; }
              table { border-collapse: collapse; width: 100%; margin-top: 20px; font-size: 0.9em; }
              th, td { border: 1px solid #ccc; padding: 8px; text-align: left; vertical-align: top; }
              th { background-color: #f0f0f0; }
              .stale { background-color: #fff3cd; }
              .very-stale { background-color: #f8d7da; }
              .note { font-size: 0.85em; color: #333; }
              h1 { color: #333; }
              .summary { background: #f8f9fa; padding: 15px; border-radius: 4px; }
            </style>
          </head>
          <body>
            <h1>Stale Work Orders Report</h1>
            <div class="summary">
              <strong>Generated:</strong> ${esc(new Date(summary.generatedAt).toLocaleString())}<br>
              <strong>Total monitored:</strong> ${esc(summary.totalMonitored)}<br>
              <strong>Stale (≥2 days no update):</strong> ${esc(summary.staleCount)}
            </div>

            <h2>Stale Work Orders (${items.length})</h2>
        `;

        if (items.length === 0) {
          html += `<p>No stale work orders found. Great job!</p>`;
        } else {
          html += `
            <table>
              <thead>
                <tr>
                  <th>WO #</th>
                  <th>Store</th>
                  <th>Status</th>
                  <th>Days Since Activity</th>
                  <th>Reason</th>
                  <th>Last Note</th>
                  <th>Links</th>
                </tr>
              </thead>
              <tbody>
          `;

          items.forEach(item => {
            const rowClass = item.daysSinceActivity >= 7 ? 'very-stale' : 'stale';

            let lastNoteHtml = '<em>No notes</em>';
            if (item.lastNote) {
              const author = esc(item.lastNote.CreatedBy || 'Unknown');
              const noteBody = item.lastNote.NoteData || '';
              const truncated = noteBody.length > 300 ? noteBody.substring(0, 300) + '…' : noteBody;
              lastNoteHtml = `<div class="note"><strong>${author}</strong><br>${esc(truncated)}</div>`;
            }

            const scLink = `https://www.servicechannel.com/sc/wo/Workorders/index?id=${encodeURIComponent(item.workOrderId)}`;
            const store = item.workOrder?.Location?.StoreId
              ? `Store ${esc(item.workOrder.Location.StoreId)}`
              : '';
            const locName = esc(item.workOrder?.LocationName || '');

            html += `
              <tr class="${rowClass}">
                <td><strong>${esc(item.workOrder?.WorkorderNumber || item.workOrderId)}</strong></td>
                <td>${store} ${locName}</td>
                <td>${esc(item.workOrder?.Status?.Primary || '-')}<br><small>${esc(item.workOrder?.Status?.Extended || '')}</small></td>
                <td><strong>${esc(item.daysSinceActivity)}</strong> days</td>
                <td>${esc(item.reason || '-')}</td>
                <td>${lastNoteHtml}</td>
                <td>
                  <a href="${scLink}" target="_blank" rel="noopener noreferrer">View in ServiceChannel</a>
                  ${item.roomId ? `<br><a href="webexteams://im?space=${encodeURIComponent(item.roomId)}" target="_blank" style="font-size: 0.85em;">Open Webex Space</a>` : ''}
                </td>
              </tr>
            `;
          });

          html += `
              </tbody>
            </table>
          `;
        }

        html += `
            <p style="margin-top: 30px; font-size: 0.85em; color: #666;">
              Report generated on demand. Data sourced from local mappings + live ServiceChannel.
            </p>
          </body>
          </html>
        `;

        res.send(html);
      } catch (err) {
        logger('stale-workorders', `Error: ${err.message}`, 'error');
        res.status(500).send(`<h2>Error</h2><pre>${esc(err.message)}</pre>`);
      }
    });
  }

  return app;
}

// ────────────────────────────────────────────────────────────────────────────
// Webhook payload sink + log rotation.
//
// Both write to the same directory the structured logger uses
// (src/utils/logPath.js → LOG_DIR env, default ./logs) so every log file lives
// in one place regardless of which module produced it.
// ────────────────────────────────────────────────────────────────────────────

function logWebhookPayload(payload) {
  try {
    const dir = ensureLogDir();
    const now = new Date();
    const yyyymmdd = now.toISOString().slice(0, 10).replace(/-/g, '');
    const filePath = path.join(dir, `${yyyymmdd}.webhook.log`);
    const line = `${now.toISOString()} ${JSON.stringify(payload)}\n`;
    fs.appendFileSync(filePath, line);
  } catch (err) {
    logger('webhook:archive', `Failed to persist raw payload: ${err.message}`, 'warn');
  }
}

/**
 * Delete `*.log` files older than N days in the shared log directory.
 * - Only touches files ending in `.log` (avoids nuking non-log files).
 * - Uses async fs and awaits everything so it doesn't race with writers.
 */
async function cleanupOldLogs({ maxAgeDays = 7 } = {}) {
  const dir = getLogDir();
  try {
    ensureLogDir();
    const now = Date.now();
    const cutoff = maxAgeDays * 24 * 60 * 60 * 1000;

    const entries = await fsp.readdir(dir, { withFileTypes: true });
    for (const entry of entries) {
      if (!entry.isFile()) continue;
      if (!entry.name.endsWith('.log')) continue;

      const filePath = path.join(dir, entry.name);
      try {
        const stats = await fsp.stat(filePath);
        if ((now - stats.mtimeMs) > cutoff) {
          await fsp.unlink(filePath);
          logger('cleanupOldLogs', `Deleted ${entry.name}`);
        }
      } catch (err) {
        logger('cleanupOldLogs', `Failed to inspect/delete ${entry.name}: ${err.message}`, 'warn');
      }
    }
  } catch (err) {
    logger('cleanupOldLogs', `Failed for ${dir}: ${err.message}`, 'error');
  }
}

/**
 * Convenience helper to set up the daily log cleanup cron.
 * Can be called from the thin bootstrap.
 */
export function setupCron({ db, runSpaceCleanup, webex } = {}) {
  cron.schedule('0 15 0,8,16 * * *', () => {
    cleanupOldLogs().catch(err => {
      logger('cron:cleanupOldLogs', `Unhandled: ${err.message}`, 'error');
    });
  });
  logger('cron', 'Log cleanup scheduled (0 15 0,8,16 * * *)');

  if (db && runSpaceCleanup) {
    const reminderCron = process.env.SPACE_CLEANUP_REMINDER_CRON || '0 0 14 * * *';
    cron.schedule(reminderCron, () => {
      logger('cron:spaceCleanupReminders', 'Starting daily consolidated close-out digest');
      runSpaceCleanup(false, { db, remindersOnly: true }).catch(err => {
        logger('cron:spaceCleanupReminders', `Unhandled: ${err.message}`, 'error');
      });
    });
    logger('cron', `Space cleanup consolidated digest scheduled (${reminderCron})`);
  }

  if (db && webex && isShipmentTrackingEnabled()) {
    const defaultTrackingCron = '0 0 8,10,12,14,16,18 * * *';
    const trackingCron = process.env.SHIPMENT_TRACKING_CRON || defaultTrackingCron;
    const trackingTz = process.env.SHIPMENT_TRACKING_TIMEZONE || 'America/New_York';

    if (process.env.SHIPMENT_TRACKING_CRON && trackingCron !== defaultTrackingCron) {
      logger(
        'cron',
        `SHIPMENT_TRACKING_CRON override (${trackingCron}) — expected ${defaultTrackingCron} for 2-hour 8am–6pm ET polling`,
        'warn'
      );
    }

    cron.schedule(
      trackingCron,
      () => {
        logger('cron:shipmentTracking', 'Starting FedEx shipment status poll');
        pollActiveShipments({ db, webex, consolidateRoomPosts: true }).catch((err) => {
          logger('cron:shipmentTracking', `Unhandled: ${err.message}`, 'error');
        });
      },
      { timezone: trackingTz }
    );
    logger('cron', `FedEx shipment tracking poll scheduled (${trackingCron}, ${trackingTz})`);
  }
}

export { cleanupOldLogs };
export default createApp;
