/**
 * Notification Routes
 *
 * The current user's own notifications — list, counts, read state and archiving.
 *
 * Ownership is structural, not a filter that can be forgotten: every query in
 * this file starts from `req.user.id`, and no endpoint accepts a userId. There
 * is deliberately no "notifications for user X" API. The one exception is the
 * Super Admin broadcast, which creates notifications but never reads anyone's.
 *
 * Notifications are never created here — that is `notificationService`'s job,
 * called from wherever the underlying event actually happens.
 */

import { Router, Request, Response } from 'express';
import { body, param, query, validationResult } from 'express-validator';
import { getModels } from '../models';
import { authenticate, requireRole } from '../middleware/auth';
import { asyncHandler } from '../middleware/errorHandler';
import { notificationService } from '../services/notificationService';
import {
  PREFERENCE_CATEGORIES,
  defaultChannelPreferences,
} from '../models/NotificationPreference';

export const notificationRoutes = Router();

notificationRoutes.use(authenticate);

/** The Notification model, or null when the app is running without it. */
function getNotificationModel(): any {
  const models = getModels() as Record<string, any>;
  return models.Notification;
}

function escapeRegex(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// Allowed sort fields — guards against arbitrary field injection.
const SORTABLE = new Set(['createdAt', 'priority', 'category', 'type', 'isRead']);

// 'approval' is retired — its events moved to 'content' and 'support'. Anything
// already stored under it still lists, opens, marks read and archives (none of
// those paths filter by category); only the category filter value is gone, and
// the UI no longer offers it.
const CATEGORIES = ['ai', 'billing', 'content', 'account', 'system', 'social', 'support'];
const PRIORITIES = ['low', 'normal', 'high', 'critical'];
const DIGEST_FREQUENCIES = ['off', 'daily', 'weekly'];

/** The NotificationPreference model, or null when running without it. */
function getPreferenceModel(): any {
  const models = getModels() as Record<string, any>;
  return models.NotificationPreference;
}

/**
 * A user's preferences as a plain object, filled in with defaults.
 *
 * Always returns a complete shape whether or not a document exists, so the UI
 * renders one way and `PUT` has something to merge onto.
 */
async function readPreferences(userId: string): Promise<{
  channelsByCategory: Record<string, { inApp: boolean; email: boolean }>;
  muteAll: boolean;
  digestFrequency: string;
}> {
  const defaults = defaultChannelPreferences();
  const NotificationPreference = getPreferenceModel();
  const doc = NotificationPreference
    ? await NotificationPreference.findOne({ userId })
    : null;

  if (!doc) return { channelsByCategory: defaults, muteAll: false, digestFrequency: 'off' };

  // Mongoose stores this as a Map; the mock DB returns a plain object.
  const raw = doc.channelsByCategory as any;
  const stored: Record<string, any> =
    typeof raw?.get === 'function' ? Object.fromEntries(raw) : raw || {};

  const channelsByCategory: Record<string, { inApp: boolean; email: boolean }> = {};
  for (const category of PREFERENCE_CATEGORIES) {
    channelsByCategory[category] = {
      inApp: stored[category]?.inApp ?? defaults[category].inApp,
      email: stored[category]?.email ?? defaults[category].email,
    };
  }

  return {
    channelsByCategory,
    muteAll: doc.muteAll === true,
    digestFrequency: doc.digestFrequency || 'off',
  };
}

/**
 * Base scope for EVERY read: this user, not archived unless asked.
 * Nothing in this file queries notifications without going through here.
 */
function ownScope(req: Request, includeArchived = false): Record<string, unknown> {
  const scope: Record<string, unknown> = { userId: String(req.user!.id) };
  if (!includeArchived) scope.isArchived = false;
  return scope;
}

/** Translate query params into a Mongo filter, on top of the ownership scope. */
function buildFilter(req: Request): Record<string, unknown> {
  const q = req.query as Record<string, unknown>;
  const filter: Record<string, unknown> = ownScope(req, String(q.includeArchived) === 'true');

  if (q.category) filter.category = q.category;
  if (q.priority) filter.priority = q.priority;
  if (q.type) filter.type = q.type;
  if (q.module) filter.module = q.module;
  if (q.isRead === 'true') filter.isRead = true;
  if (q.isRead === 'false') filter.isRead = false;

  if (q.startDate || q.endDate) {
    const createdAt: Record<string, Date> = {};
    if (q.startDate) createdAt.$gte = new Date(String(q.startDate));
    if (q.endDate) createdAt.$lte = new Date(String(q.endDate));
    filter.createdAt = createdAt;
  }

  if (q.search) {
    const rx = { $regex: escapeRegex(String(q.search)), $options: 'i' };
    // `category` is searched alongside the text fields because the category is a
    // visible facet of every row (it is what the "All categories" dropdown lists,
    // and what picks the icon). Without it, typing a category name returned
    // nothing unless the word happened to appear in a title, module or type —
    // "System" missed every system-category entry whose type is not `system.*`
    // (backup.completed, deploy.failed, export.ready, email.dispatch.failed …).
    filter.$or = [{ title: rx }, { message: rx }, { module: rx }, { type: rx }, { category: rx }];
  }

  return filter;
}

// ============================================
// GET /unread-count — the bell's poll target
// ============================================
// Kept deliberately cheap: counts only, no documents. This is the endpoint the
// frontend hits on a timer, so it must stay a pair of indexed counts.
notificationRoutes.get(
  '/unread-count',
  asyncHandler(async (req: Request, res: Response) => {
    const Notification = getNotificationModel();
    if (!Notification) {
      res.json({ total: 0, byCategory: {} });
      return;
    }

    const scope = { ...ownScope(req), isRead: false };
    const total = await Notification.countDocuments(scope);

    // Per-category counts drive the filter chips' badges. Counted individually
    // rather than aggregated so this works identically in mock mode, where
    // aggregate() does not exist.
    const byCategory: Record<string, number> = {};
    if (total > 0) {
      await Promise.all(
        CATEGORIES.map(async (category) => {
          const count = await Notification.countDocuments({ ...scope, category });
          if (count > 0) byCategory[category] = count;
        }),
      );
    }

    res.json({ total, byCategory });
  }),
);

// ============================================
// GET / — paginated, filtered, searchable list
// ============================================
notificationRoutes.get(
  '/',
  [
    query('page').optional().isInt({ min: 1 }),
    query('limit').optional().isInt({ min: 1, max: 100 }),
    query('category').optional().isIn(CATEGORIES),
    query('priority').optional().isIn(PRIORITIES),
    query('type').optional().trim(),
    query('module').optional().trim(),
    query('isRead').optional().isIn(['true', 'false']),
    query('includeArchived').optional().isIn(['true', 'false']),
    query('search').optional().trim(),
    query('startDate').optional().isISO8601(),
    query('endDate').optional().isISO8601(),
    query('sortBy').optional().trim(),
    query('sortOrder').optional().isIn(['asc', 'desc']),
  ],
  asyncHandler(async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ error: 'Validation failed', details: errors.array() });
      return;
    }

    const Notification = getNotificationModel();
    if (!Notification) {
      res.json({ notifications: [], total: 0, page: 1, totalPages: 0, unreadCount: 0 });
      return;
    }

    const page = parseInt(req.query.page as string) || 1;
    const limit = parseInt(req.query.limit as string) || 20;
    const filter = buildFilter(req);

    const sortByRaw = (req.query.sortBy as string) || 'createdAt';
    const sortBy = SORTABLE.has(sortByRaw) ? sortByRaw : 'createdAt';
    const sortOrder = req.query.sortOrder === 'asc' ? 1 : -1;

    const skip = (page - 1) * limit;
    const [notifications, total, unreadCount] = await Promise.all([
      Notification.find(filter)
        .sort({ [sortBy]: sortOrder })
        .skip(skip)
        .limit(limit)
        .lean(),
      Notification.countDocuments(filter),
      Notification.countDocuments({ ...ownScope(req), isRead: false }),
    ]);

    res.json({ notifications, total, page, totalPages: Math.ceil(total / limit), unreadCount });
  }),
);

// ============================================
// PATCH /read-all — clear the badge in one action
// ============================================
// Declared before '/:id' routes so "read-all" is never parsed as an id.
notificationRoutes.patch(
  '/read-all',
  [body('category').optional().isIn(CATEGORIES)],
  asyncHandler(async (req: Request, res: Response) => {
    const Notification = getNotificationModel();
    if (!Notification) {
      res.json({ updated: 0 });
      return;
    }

    const filter: Record<string, unknown> = { ...ownScope(req), isRead: false };
    if (req.body?.category) filter.category = req.body.category;

    const result = await Notification.updateMany(filter, {
      $set: { isRead: true, readAt: new Date() },
    });

    res.json({ updated: result?.modifiedCount ?? 0 });
  }),
);

// ============================================
// DELETE / — bulk archive (soft delete)
// ============================================
notificationRoutes.delete(
  '/',
  [
    body('ids').optional().isArray({ max: 200 }),
    body('olderThan').optional().isISO8601(),
    body('readOnly').optional().isBoolean(),
  ],
  asyncHandler(async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ error: 'Validation failed', details: errors.array() });
      return;
    }

    const Notification = getNotificationModel();
    if (!Notification) {
      res.json({ archived: 0 });
      return;
    }

    const { ids, olderThan, readOnly } = req.body || {};
    // Require an explicit selector — an empty body must not archive everything.
    if (!ids?.length && !olderThan) {
      res.status(400).json({ error: 'Provide ids or olderThan' });
      return;
    }

    const filter: Record<string, unknown> = ownScope(req);
    if (ids?.length) filter._id = { $in: ids };
    if (olderThan) filter.createdAt = { $lte: new Date(String(olderThan)) };
    if (readOnly === true) filter.isRead = true;

    const result = await Notification.updateMany(filter, {
      $set: { isArchived: true, archivedAt: new Date() },
    });

    res.json({ archived: result?.modifiedCount ?? 0 });
  }),
);

// ============================================
// POST /admin/broadcast — Super Admin announcement
// ============================================
// The only endpoint that creates notifications. Declared before '/:id' so the
// path is never read as an id.
notificationRoutes.post(
  '/admin/broadcast',
  requireRole('super-admin'),
  [
    body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 200 }),
    body('message').optional().trim().isLength({ max: 1000 }),
    body('role').optional().isIn(['super-admin', 'admin', 'manager', 'editor', 'viewer', 'user']),
    body('organizationId').optional().trim(),
    body('priority').optional().isIn(PRIORITIES),
    body('actionUrl').optional().trim(),
  ],
  asyncHandler(async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ error: 'Validation failed', details: errors.array() });
      return;
    }

    const { title, message, role, organizationId, priority, actionUrl } = req.body;

    const result = await notificationService.notify({
      type: 'system.announcement',
      role: role || 'admin',
      organizationId: organizationId || null,
      title,
      message,
      priority,
      actionUrl,
      actorUserId: String(req.user!.id),
      actorName: req.user!.name,
    });

    res.status(201).json({
      created: result.created,
      recipients: result.recipients.length,
      skippedReason: result.skippedReason,
    });
  }),
);

// ============================================
// GET /preferences — this user's channel settings
//
// Declared BEFORE `/:id`, or Express would match "preferences" as an id and
// every read would 404.
// ============================================
notificationRoutes.get(
  '/preferences',
  asyncHandler(async (req: Request, res: Response) => {
    res.json({ preferences: await readPreferences(String(req.user!.id)) });
  }),
);

// ============================================
// PUT /preferences — update them
// ============================================
notificationRoutes.put(
  '/preferences',
  [
    body('muteAll').optional().isBoolean(),
    body('digestFrequency').optional().isIn(DIGEST_FREQUENCIES),
    body('channelsByCategory').optional().isObject(),
  ],
  asyncHandler(async (req: Request, res: Response) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).json({ errors: errors.array() });
      return;
    }

    const NotificationPreference = getPreferenceModel();
    if (!NotificationPreference) {
      res.status(503).json({ error: 'Notification preferences are unavailable' });
      return;
    }

    const userId = String(req.user!.id);
    const current = await readPreferences(userId);

    // Merge rather than replace, and accept only known categories with boolean
    // values — the body decides this user's own delivery, so an unrecognised
    // key is dropped rather than stored.
    const incoming = (req.body.channelsByCategory || {}) as Record<string, any>;
    const channelsByCategory = { ...current.channelsByCategory };
    for (const category of PREFERENCE_CATEGORIES) {
      const patch = incoming[category];
      if (!patch || typeof patch !== 'object') continue;
      channelsByCategory[category] = {
        inApp: typeof patch.inApp === 'boolean' ? patch.inApp : channelsByCategory[category].inApp,
        email: typeof patch.email === 'boolean' ? patch.email : channelsByCategory[category].email,
      };
    }

    const update = {
      userId,
      channelsByCategory,
      muteAll: typeof req.body.muteAll === 'boolean' ? req.body.muteAll : current.muteAll,
      digestFrequency: DIGEST_FREQUENCIES.includes(req.body.digestFrequency)
        ? req.body.digestFrequency
        : current.digestFrequency,
    };

    // Upsert: most users have no document until the first time they change
    // something, so a plain update would silently do nothing.
    await NotificationPreference.findOneAndUpdate({ userId }, update, {
      upsert: true,
      new: true,
      setDefaultsOnInsert: true,
    });

    res.json({ preferences: update });
  }),
);

// ============================================
// GET /:id — one notification
// ============================================
notificationRoutes.get(
  '/:id',
  [param('id').trim().notEmpty()],
  asyncHandler(async (req: Request, res: Response) => {
    const Notification = getNotificationModel();
    // A notification belonging to someone else is reported as missing, not
    // forbidden — a 403 would confirm that the id exists.
    const notification = Notification
      ? await Notification.findOne({ _id: req.params.id, ...ownScope(req, true) }).lean()
      : null;

    if (!notification) {
      res.status(404).json({ error: 'Notification not found' });
      return;
    }

    res.json({ notification });
  }),
);

// ============================================
// PATCH /:id/read  and  PATCH /:id/unread
// ============================================
async function setReadState(req: Request, res: Response, isRead: boolean): Promise<void> {
  const Notification = getNotificationModel();
  const notification = Notification
    ? await Notification.findOne({ _id: req.params.id, ...ownScope(req, true) })
    : null;

  if (!notification) {
    res.status(404).json({ error: 'Notification not found' });
    return;
  }

  notification.isRead = isRead;
  notification.readAt = isRead ? new Date() : null;
  await notification.save();

  res.json({ id: req.params.id, isRead });
}

notificationRoutes.patch(
  '/:id/read',
  [param('id').trim().notEmpty()],
  asyncHandler(async (req: Request, res: Response) => setReadState(req, res, true)),
);

notificationRoutes.patch(
  '/:id/unread',
  [param('id').trim().notEmpty()],
  asyncHandler(async (req: Request, res: Response) => setReadState(req, res, false)),
);

// ============================================
// DELETE /:id — archive one (soft delete)
// ============================================
notificationRoutes.delete(
  '/:id',
  [param('id').trim().notEmpty()],
  asyncHandler(async (req: Request, res: Response) => {
    const Notification = getNotificationModel();
    const notification = Notification
      ? await Notification.findOne({ _id: req.params.id, ...ownScope(req, true) })
      : null;

    if (!notification) {
      res.status(404).json({ error: 'Notification not found' });
      return;
    }

    notification.isArchived = true;
    notification.archivedAt = new Date();
    await notification.save();

    res.json({ id: req.params.id, archived: true });
  }),
);

export default notificationRoutes;
