"""Notifications plugin API endpoints - adapted to existing schema."""

import hashlib
import os
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

from flask import Blueprint, request
from flask_jwt_extended import jwt_required

from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query

from ..models import Notification, NotificationType
from ..models.notification import _utc_iso

from shopdb.api import require_permission, Setting

notifications_bp = Blueprint('notifications', __name__)

# Notification types whose multi-employee cards get split into one card per
# employee on the shopfloor dashboard. typecolor drives this (not typename), so
# recognition, training and recertification all fan out; every other type
# stays one card.
SPLIT_TYPECOLORS = frozenset({'recognition', 'training', 'recertification'})

# How long a card stays up on the shopfloor board when the creator does not set
# an explicit end time, by notification typecolor.
#   recognition   - clears at the next 8:00 AM Eastern (daily reset)
#   recertification - stays up two weeks (employees have time to book the course)
_DEFAULT_TZ = 'America/New_York'
RECERTIFICATION_DAYS = 14


def _site_tz():
    """Site-configured IANA timezone (settings key site_timezone), used for the
    daily-reset expiry math and any wall-clock computation. Falls back to
    America/New_York if unset or invalid."""
    row = Setting.query.filter_by(key='site_timezone').first()
    name = (row.value if row and row.value else _DEFAULT_TZ)
    try:
        return ZoneInfo(name)
    except Exception:
        return ZoneInfo(_DEFAULT_TZ)


def _parse_utc(date_str):
    """Parse an incoming ISO datetime to NAIVE UTC (how starttime/endtime store).

    The client submits new Date(local).toISOString(), i.e. UTC with a 'Z'. A
    value without an offset is assumed already-UTC (not server-local) so the
    stored instant is unambiguous."""
    dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc).replace(tzinfo=None)


def _next_site_time(after, hour, minute=0):
    """Next hour:minute in the site timezone strictly after `after` (naive UTC),
    returned as naive UTC. Uses the tz database so it is correct across DST."""
    tz = _site_tz()
    after_local = after.replace(tzinfo=timezone.utc).astimezone(tz)
    target = after_local.replace(hour=int(hour), minute=int(minute), second=0, microsecond=0)
    if target <= after_local:
        target += timedelta(days=1)
    return target.astimezone(timezone.utc).replace(tzinfo=None)


def _auto_endtime(ntype, starttime):
    """Default display window for a notification with no explicit end time, from
    the notification type's configured expiry rule:
      'dailytime' -> next expiryhour:expiryminute Eastern (daily reset)
      'duration'  -> starttime + expirydays days
      'none'      -> None (show indefinitely)
    Falls back to the legacy typecolor rules when the expiry columns are unset,
    so it is safe before/after the 7d03 migration."""
    mode = getattr(ntype, 'expirymode', None) or 'none'
    if mode == 'dailytime':
        hour = ntype.expiryhour if ntype.expiryhour is not None else 8
        return _next_site_time(starttime, hour, ntype.expiryminute or 0)
    if mode == 'duration' and ntype.expirydays:
        return starttime + timedelta(days=int(ntype.expirydays))
    if mode == 'none':
        # legacy fallback for rule-bearing types created before the expiry columns
        if getattr(ntype, 'typecolor', None) == 'recognition':
            return _next_site_time(starttime, 8, 0)
        if getattr(ntype, 'typecolor', None) == 'recertification':
            return starttime + timedelta(days=RECERTIFICATION_DAYS)
    return None


_EXPIRY_MODES = ('none', 'duration', 'dailytime')


def _apply_expiry_fields(t, data):
    """Set expiry-rule columns on a NotificationType from request data. Only
    touches fields that are present. Returns an error string, or None on ok."""
    if 'expirymode' in data:
        mode = data.get('expirymode') or 'none'
        if mode not in _EXPIRY_MODES:
            return "expirymode must be one of: %s" % ", ".join(_EXPIRY_MODES)
        t.expirymode = mode
    if 'expirydays' in data:
        v = data.get('expirydays')
        if v in (None, ''):
            t.expirydays = None
        else:
            try:
                t.expirydays = int(v)
            except (TypeError, ValueError):
                return "expirydays must be an integer"
            if t.expirydays < 1:
                return "expirydays must be >= 1"
    if 'expiryhour' in data:
        v = data.get('expiryhour')
        if v in (None, ''):
            t.expiryhour = None
        else:
            try:
                t.expiryhour = int(v)
            except (TypeError, ValueError):
                return "expiryhour must be an integer"
            if not (0 <= t.expiryhour <= 23):
                return "expiryhour must be 0-23"
    if 'expiryminute' in data:
        v = data.get('expiryminute')
        try:
            t.expiryminute = int(v) if v not in (None, '') else 0
        except (TypeError, ValueError):
            return "expiryminute must be an integer"
        if not (0 <= t.expiryminute <= 59):
            return "expiryminute must be 0-59"
    # cross-field consistency
    mode = t.expirymode or 'none'
    if mode == 'dailytime' and t.expiryhour is None:
        t.expiryhour = 8
    if mode == 'duration' and not t.expirydays:
        return "duration expiry requires expirydays >= 1"
    return None


_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')

# Ceiling on a type's board position. Wide enough to leave gaps between rows
# (10, 20, 30 ...) so inserting one later needs no renumbering.
_MAX_BOARD_ORDER = 999

# Ceiling on the per-type post-expiry tail. A day is already far longer than
# "recently ended"; anything more is an end time that should have been later.
_MAX_GRACE_MINUTES = 1440


def _apply_display_fields(t, data):
    """Set shopfloor display-behavior columns on a NotificationType from request
    data. Only touches fields that are present. Returns an error string, or None."""
    if 'splitperemployee' in data:
        t.splitperemployee = bool(data.get('splitperemployee'))
    if 'showemployeephoto' in data:
        t.showemployeephoto = bool(data.get('showemployeephoto'))
    if 'displaystyle' in data:
        ds = data.get('displaystyle') or 'standard'
        if ds not in _DISPLAY_STYLES:
            return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
        t.displaystyle = ds
    if 'boardcategory' in data:
        category = (data.get('boardcategory') or '').strip()
        if len(category) > 50:
            return "boardcategory must be 50 characters or fewer"
        # Blank stores as NULL: "no category" is the absence of one, not the
        # empty-string category that every uncategorised type would share.
        t.boardcategory = category or None
    if 'boardorder' in data:
        raw = data.get('boardorder')
        raw = 100 if raw in (None, '') else raw
        try:
            order = int(raw)
        except (TypeError, ValueError):
            return "boardorder must be a whole number"
        if order < 0 or order > _MAX_BOARD_ORDER:
            return "boardorder must be between 0 and %d" % _MAX_BOARD_ORDER
        t.boardorder = order
    if 'gracewindowminutes' in data:
        raw = data.get('gracewindowminutes')
        raw = 0 if raw in (None, '') else raw
        try:
            minutes = int(raw)
        except (TypeError, ValueError):
            return "gracewindowminutes must be a whole number of minutes"
        if minutes < 0 or minutes > _MAX_GRACE_MINUTES:
            return ("gracewindowminutes must be between 0 and %d"
                    % _MAX_GRACE_MINUTES)
        t.gracewindowminutes = minutes
    return None


def _config_version():
    """Short hash of everything that changes the board's LAYOUT: per-type display
    config plus an optional deploy stamp (SHOPFLOOR_BUILD env). The shopfloor
    kiosks reload when this changes, so type/layout edits and frontend deploys
    reach pages that are already open."""
    types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
    parts = [
        "%s|%s|%s|%d|%d|%s|%s|%s|%d|%d|%s|%d" % (
            t.notificationtypeid, t.typecolor, t.displaystyle,
            int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
            t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
            int(t.gracewindowminutes or 0), t.boardcategory or '',
            int(t.boardorder if t.boardorder is not None else 100),
        )
        for t in types
    ]
    parts.append(os.environ.get('SHOPFLOOR_BUILD', ''))
    return hashlib.md5('||'.join(parts).encode()).hexdigest()[:12]


def _employee_name(sso):
    """Live directory name for an SSO; None on any miss. Used as the fallback
    when a notification has no stored employeename (see the shopfloor feed)."""
    try:
        from plugins.employees.api.routes import resolve_employee_display_name
        return resolve_employee_display_name(sso)
    except Exception:
        return None


def _employee_picture(sso):
    """Resolved display photo URL for an SSO, via the shared employees-plugin
    resolver so kiosk cards match EmployeeDetail in both directory modes
    (self-hosted upload URL or external HR URL). None on any miss."""
    try:
        from plugins.employees.api.routes import resolve_employee_photo_url
        return resolve_employee_photo_url(sso)
    except Exception:
        return None


# =============================================================================
# Notification Types
# =============================================================================

@notifications_bp.route('/types', methods=['GET'])
def list_notification_types():
    """List all notification types."""
    page, per_page = get_pagination_params(request)

    query = NotificationType.query

    if request.args.get('active', 'true').lower() != 'false':
        query = query.filter(NotificationType.isactive == True)

    query = query.order_by(NotificationType.typename)

    items, total = paginate_query(query, page, per_page)
    data = [t.to_dict() for t in items]

    return paginated_response(data, page, per_page, total)


@notifications_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('notifications.create')
def create_notification_type():
    """Create a new notification type."""
    data = request.get_json()

    if not data or not data.get('typename'):
        return error_response(ErrorCodes.VALIDATION_ERROR, 'typename is required')

    if NotificationType.query.filter_by(typename=data['typename']).first():
        return error_response(
            ErrorCodes.CONFLICT,
            f"Notification type '{data['typename']}' already exists",
            http_code=409
        )

    t = NotificationType(
        typename=data['typename'],
        typedescription=data.get('typedescription') or data.get('description'),
        typecolor=data.get('typecolor') or data.get('color', '#17a2b8')
    )

    err = _apply_expiry_fields(t, data)
    if err:
        return error_response(ErrorCodes.VALIDATION_ERROR, err)
    err = _apply_display_fields(t, data)
    if err:
        return error_response(ErrorCodes.VALIDATION_ERROR, err)

    db.session.add(t)
    db.session.commit()

    return success_response(t.to_dict(), message='Notification type created', http_code=201)


@notifications_bp.route('/types/<int:type_id>', methods=['PUT', 'PATCH'])
@jwt_required()
@require_permission('notifications.create')
def update_notification_type(type_id: int):
    """Update a notification type, including its auto-expiry rule."""
    t = db.session.get(NotificationType, type_id)
    if not t:
        return error_response(ErrorCodes.NOT_FOUND, f'Notification type {type_id} not found', http_code=404)

    data = request.get_json() or {}

    if data.get('typename'):
        dup = NotificationType.query.filter(
            NotificationType.typename == data['typename'],
            NotificationType.notificationtypeid != type_id
        ).first()
        if dup:
            return error_response(ErrorCodes.CONFLICT,
                                  f"Notification type '{data['typename']}' already exists", http_code=409)
        t.typename = data['typename']
    if 'typedescription' in data or 'description' in data:
        t.typedescription = data.get('typedescription') or data.get('description')
    if 'typecolor' in data or 'color' in data:
        t.typecolor = data.get('typecolor') or data.get('color')
    if 'isactive' in data:
        t.isactive = bool(data['isactive'])

    err = _apply_expiry_fields(t, data)
    if err:
        return error_response(ErrorCodes.VALIDATION_ERROR, err)
    err = _apply_display_fields(t, data)
    if err:
        return error_response(ErrorCodes.VALIDATION_ERROR, err)

    db.session.commit()
    return success_response(t.to_dict(), message='Notification type updated')


# =============================================================================
# Notifications CRUD
# =============================================================================

@notifications_bp.route('', methods=['GET'])
def list_notifications():
    """
    List all notifications with filtering and pagination.

    Query parameters:
    - page, per_page: Pagination
    - active: Filter by active status (default: true)
    - type_id: Filter by notification type ID
    - current: Filter to currently active notifications only
    - search: Search in notification text
    """
    page, per_page = get_pagination_params(request)

    query = Notification.query

    # Active filter
    if request.args.get('active', 'true').lower() != 'false':
        query = query.filter(Notification.isactive == True)

    # Type filter
    if type_id := request.args.get('typeid', request.args.get('type_id')):
        query = query.filter(Notification.notificationtypeid == int(type_id))

    # Exact-match lookup for idempotent import. Notifications have no strong
    # natural key; ticketnumber is the best available when a ticket is set.
    if exactticket := request.args.get('ticketnumber'):
        query = query.filter(Notification.ticketnumber == exactticket)

    # Current filter (active based on dates)
    if request.args.get('current', 'false').lower() == 'true':
        now = datetime.now(timezone.utc).replace(tzinfo=None)
        query = query.filter(
            Notification.starttime <= now,
            db.or_(
                Notification.endtime.is_(None),
                Notification.endtime >= now
            )
        )

    # Search filter
    if search := request.args.get('search'):
        query = query.filter(
            Notification.notification.ilike(f'%{search}%')
        )

    # Sorting by start time (newest first)
    query = query.order_by(Notification.starttime.desc())

    items, total = paginate_query(query, page, per_page)
    data = [n.to_dict() for n in items]

    return paginated_response(data, page, per_page, total)


@notifications_bp.route('/<int:notification_id>', methods=['GET'])
def get_notification(notification_id: int):
    """Get a single notification."""
    n = db.session.get(Notification, notification_id)

    if not n:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Notification with ID {notification_id} not found',
            http_code=404
        )

    return success_response(n.to_dict())


@notifications_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('notifications.create')
def create_notification():
    """Create a new notification."""
    data = request.get_json()

    if not data:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')

    # Validate required fields
    notification_text = data.get('notification') or data.get('message')
    if not notification_text:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'notification/message is required')

    # Parse dates
    starttime = datetime.now(timezone.utc).replace(tzinfo=None)
    if data.get('starttime') or data.get('startdate'):
        try:
            date_str = data.get('starttime') or data.get('startdate')
            starttime = _parse_utc(date_str)
        except ValueError:
            return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')

    endtime = None
    if data.get('endtime') or data.get('enddate'):
        try:
            date_str = data.get('endtime') or data.get('enddate')
            endtime = _parse_utc(date_str)
        except ValueError:
            return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')

    # No explicit end time: apply the per-type display window (recognition
    # clears at the next 8 AM Eastern, recertification runs two weeks).
    if endtime is None and data.get('notificationtypeid'):
        ntype = db.session.get(NotificationType, data['notificationtypeid'])
        if ntype:
            endtime = _auto_endtime(ntype, starttime)

    n = Notification(
        notification=notification_text,
        notificationtypeid=data.get('notificationtypeid'),
        businessunitid=data.get('businessunitid'),
        appid=data.get('appid'),
        starttime=starttime,
        endtime=endtime,
        ticketnumber=data.get('ticketnumber'),
        link=data.get('link') or data.get('linkurl'),
        isactive=True,
        isshopfloor=data.get('isshopfloor', False),
        employeesso=data.get('employeesso'),
        employeename=data.get('employeename')
    )

    db.session.add(n)
    db.session.commit()

    return success_response(n.to_dict(), message='Notification created', http_code=201)


@notifications_bp.route('/<int:notification_id>', methods=['PUT'])
@jwt_required()
@require_permission('notifications.edit')
def update_notification(notification_id: int):
    """Update a notification."""
    n = db.session.get(Notification, notification_id)

    if not n:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Notification with ID {notification_id} not found',
            http_code=404
        )

    data = request.get_json()
    if not data:
        return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')

    # Update text content
    if 'notification' in data or 'message' in data:
        n.notification = data.get('notification') or data.get('message')

    # Update simple fields
    if 'notificationtypeid' in data:
        n.notificationtypeid = data['notificationtypeid']
    if 'businessunitid' in data:
        n.businessunitid = data['businessunitid']
    if 'appid' in data:
        n.appid = data['appid']
    if 'ticketnumber' in data:
        n.ticketnumber = data['ticketnumber']
    if 'link' in data or 'linkurl' in data:
        n.link = data.get('link') or data.get('linkurl')
    if 'isactive' in data:
        n.isactive = data['isactive']
    if 'isshopfloor' in data:
        n.isshopfloor = data['isshopfloor']
    if 'employeesso' in data:
        n.employeesso = data['employeesso']
    if 'employeename' in data:
        n.employeename = data['employeename']

    # Parse and update dates
    if 'starttime' in data or 'startdate' in data:
        date_str = data.get('starttime') or data.get('startdate')
        if date_str:
            try:
                n.starttime = _parse_utc(date_str)
            except ValueError:
                return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid starttime format')
        else:
            n.starttime = datetime.now(timezone.utc).replace(tzinfo=None)

    if 'endtime' in data or 'enddate' in data:
        date_str = data.get('endtime') or data.get('enddate')
        if date_str:
            try:
                n.endtime = _parse_utc(date_str)
            except ValueError:
                return error_response(ErrorCodes.VALIDATION_ERROR, 'Invalid endtime format')
        else:
            n.endtime = None

    db.session.commit()
    return success_response(n.to_dict(), message='Notification updated')


@notifications_bp.route('/<int:notification_id>', methods=['DELETE'])
@jwt_required()
@require_permission('notifications.delete')
def delete_notification(notification_id: int):
    """Delete (soft delete) a notification."""
    n = db.session.get(Notification, notification_id)

    if not n:
        return error_response(
            ErrorCodes.NOT_FOUND,
            f'Notification with ID {notification_id} not found',
            http_code=404
        )

    n.isactive = False
    db.session.commit()

    return success_response(message='Notification deleted')


# =============================================================================
# Special Endpoints
# =============================================================================

@notifications_bp.route('/active', methods=['GET'])
def get_active_notifications():
    """
    Get currently active notifications for display.
    """
    now = datetime.now(timezone.utc).replace(tzinfo=None)

    from datetime import timedelta
    lookahead = now + timedelta(days=10)

    notifications = Notification.query.filter(
        Notification.isactive == True,
        db.or_(
            Notification.starttime.is_(None),
            Notification.starttime <= lookahead
        ),
        db.or_(
            Notification.endtime.is_(None),
            Notification.endtime >= now
        )
    ).order_by(Notification.starttime.asc()).all()

    data = [n.to_dict() for n in notifications]

    return success_response({
        'notifications': data,
        'total': len(data)
    })


@notifications_bp.route('/calendar', methods=['GET'])
def get_calendar_events():
    """
    Get notifications in FullCalendar event format.

    Query parameters:
    - start: Start date (ISO format)
    - end: End date (ISO format)
    """
    query = Notification.query.filter(Notification.isactive == True)

    # Date range filter
    if start := request.args.get('start'):
        try:
            start_date = datetime.fromisoformat(start.replace('Z', '+00:00'))
            query = query.filter(
                db.or_(
                    Notification.endtime >= start_date,
                    Notification.endtime.is_(None)
                )
            )
        except ValueError:
            pass

    if end := request.args.get('end'):
        try:
            end_date = datetime.fromisoformat(end.replace('Z', '+00:00'))
            query = query.filter(Notification.starttime <= end_date)
        except ValueError:
            pass

    notifications = query.order_by(Notification.starttime).all()

    events = [n.to_calendar_event() for n in notifications]

    return success_response(events)


@notifications_bp.route('/dashboard/summary', methods=['GET'])
def dashboard_summary():
    """Get notifications dashboard summary."""
    now = datetime.now(timezone.utc).replace(tzinfo=None)

    # Total active notifications
    total_active = Notification.query.filter(
        Notification.isactive == True,
        db.or_(
            Notification.starttime.is_(None),
            Notification.starttime <= now
        ),
        db.or_(
            Notification.endtime.is_(None),
            Notification.endtime >= now
        )
    ).count()

    # By type
    by_type = db.session.query(
        NotificationType.typename,
        NotificationType.typecolor,
        db.func.count(Notification.notificationid)
    ).join(Notification
    ).filter(
        Notification.isactive == True
    ).group_by(NotificationType.typename, NotificationType.typecolor
    ).all()

    return success_response({
        'active': total_active,
        'bytype': [{'type': t, 'color': c, 'count': n} for t, c, n in by_type]
    })


@notifications_bp.route('/employee/<sso>', methods=['GET'])
def get_employee_recognitions(sso):
    """
    Get recognitions for a specific employee by SSO.

    Returns all recognition-type notifications where the employee is mentioned.
    """
    if not sso or not sso.isdigit():
        return error_response(ErrorCodes.VALIDATION_ERROR, 'Valid SSO required')

    # Find recognition type(s)
    recognition_types = NotificationType.query.filter(
        db.or_(
            NotificationType.typecolor == 'recognition',
            NotificationType.typename.ilike('%recognition%')
        )
    ).all()

    recognition_type_ids = [rt.notificationtypeid for rt in recognition_types]

    # Find notifications where this employee is mentioned
    # Check both exact match and comma-separated list
    query = Notification.query.filter(
        Notification.isactive == True,
        db.or_(
            Notification.employeesso == sso,
            Notification.employeesso.like(f'{sso},%'),
            Notification.employeesso.like(f'%,{sso}'),
            Notification.employeesso.like(f'%,{sso},%')
        )
    )

    # Optionally filter to recognition types only
    if recognition_type_ids:
        query = query.filter(Notification.notificationtypeid.in_(recognition_type_ids))

    query = query.order_by(Notification.starttime.desc())

    notifications = query.all()
    data = [n.to_dict() for n in notifications]

    return success_response({
        'recognitions': data,
        'total': len(data)
    })


@notifications_bp.route('/shopfloor', methods=['GET'])
def get_shopfloor_notifications():
    """
    Get notifications for shopfloor TV dashboard.

    Returns current and upcoming notifications with isshopfloor=1.
    Splits multi-employee recognition and training into separate entries.

    Query parameters:
    - businessunit: Filter by business unit ID (null = all units)
    """
    from datetime import timedelta

    now = datetime.now(timezone.utc).replace(tzinfo=None)
    business_unit = request.args.get('businessunit')

    # Base query for shopfloor notifications
    base_query = Notification.query.filter(Notification.isshopfloor == True)

    # Business unit filter
    if business_unit and business_unit.isdigit():
        # Specific BU: show that BU's notifications AND null (all units)
        base_query = base_query.filter(
            db.or_(
                Notification.businessunitid == int(business_unit),
                Notification.businessunitid.is_(None)
            )
        )
    else:
        # All units: only show notifications with NULL businessunitid
        base_query = base_query.filter(Notification.businessunitid.is_(None))

    # Current notifications: showing now, plus anything whose type asks to keep
    # ended cards up for a while (gracewindowminutes, 0 by default - an end time
    # means the card leaves the board then). The window is per type, so the SQL
    # only widens to the largest configured tail and each row is then held to
    # its own; that keeps this one portable query instead of a per-type interval
    # expression, and with every type at 0 it collapses to "still showing".
    widest_grace = db.session.query(
        db.func.max(NotificationType.gracewindowminutes)).scalar() or 0
    widest_grace_start = now - timedelta(minutes=int(widest_grace))
    current_query = base_query.filter(
        db.or_(
            # Active and currently showing
            db.and_(
                Notification.isactive == True,
                db.or_(Notification.starttime.is_(None), Notification.starttime <= now),
                db.or_(Notification.endtime.is_(None), Notification.endtime >= now)
            ),
            # Ended, but possibly inside its type's tail - narrowed below
            db.and_(
                Notification.endtime.isnot(None),
                Notification.endtime >= widest_grace_start,
                Notification.endtime < now
            )
        )
    ).order_by(Notification.notificationid.desc())

    def _within_grace(n):
        """True unless the row ended outside its own type's tail."""
        if n.endtime is None or n.endtime >= now:
            return True
        grace = (n.notificationtype.gracewindowminutes or 0) if n.notificationtype else 0
        return n.endtime >= now - timedelta(minutes=int(grace))

    current_notifications = [n for n in current_query.all() if _within_grace(n)]

    # Upcoming notifications (starts within next 5 days)
    five_days = now + timedelta(days=5)
    upcoming_query = base_query.filter(
        Notification.isactive == True,
        Notification.starttime > now,
        Notification.starttime <= five_days
    ).order_by(Notification.starttime)

    upcoming_notifications = upcoming_query.all()

    def notification_to_shopfloor(n, employee_override=None):
        """Convert notification to shopfloor format."""
        # bool, not the datetime-or-None the and-chain yields: a card with no
        # end time was serializing resolved as null.
        is_resolved = bool(n.endtime and n.endtime < now)
        ntype = n.notificationtype
        show_photo = bool(ntype and ntype.showemployeephoto)

        result = {
            'notificationid': n.notificationid,
            'notification': n.notification,
            # _utc_iso, not a bare isoformat: these are stored NAIVE but hold
            # UTC, and an untagged string is read by the browser as LOCAL time,
            # shifting every card on the board by the tz offset. The model's
            # to_dict already learned this; the shopfloor feed had not.
            'starttime': _utc_iso(n.starttime),
            'endtime': _utc_iso(n.endtime),
            'ticketnumber': n.ticketnumber,
            'link': n.link,
            'isactive': n.isactive,
            'isshopfloor': True,
            'resolved': is_resolved,
            'typename': ntype.typename if ntype else None,
            'typecolor': ntype.typecolor if ntype else None,
            # Per-type display behavior the dashboard groups/renders by. A
            # boardcategory puts several types in one row under that name;
            # blank gives the type a row of its own.
            'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
            'boardcategory': (ntype.boardcategory or '') if ntype else '',
            'boardorder': int(ntype.boardorder if ntype and ntype.boardorder is not None else 100),
        }

        # Employee info (photo only when the type wants it)
        if employee_override:
            result['employeesso'] = employee_override.get('sso')
            result['employeename'] = employee_override.get('name')
            result['employeepicture'] = employee_override.get('picture')
        else:
            result['employeesso'] = n.employeesso
            # Stored name first (import/manual entry), else resolve live from
            # the directory. Also resolve when the stored "name" is just the bare
            # SSO: WJ notifications were imported with SSOs and never converted to
            # names, so a digits-only stored name must still be looked up.
            name = n.employeename
            if (n.employeesso and ',' not in n.employeesso
                    and (not name or name.strip().isdigit())):
                name = _employee_name(n.employeesso) or name
            result['employeename'] = name
            result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None

        return result

    def expand(n):
        """One shopfloor card per notification, or one per employee when the
        type is configured to split multi-employee lists."""
        ntype = n.notificationtype
        is_split = bool(ntype and ntype.splitperemployee)
        if not (is_split and n.employeesso and ',' in n.employeesso):
            return [notification_to_shopfloor(n)]

        show_photo = bool(ntype and ntype.showemployeephoto)
        ssos = [s.strip() for s in n.employeesso.split(',')]
        names = n.employeename.split(', ') if n.employeename else []

        def _name_for(i, sso):
            stored = names[i].strip() if i < len(names) and names[i] else None
            # A stored bare SSO (digits) is not a real name - look it up.
            if stored and not stored.isdigit():
                return stored
            return _employee_name(sso) or stored or sso

        return [
            notification_to_shopfloor(n, {
                'sso': sso,
                'name': _name_for(i, sso),
                'picture': _employee_picture(sso) if show_photo else None,
            })
            for i, sso in enumerate(ssos)
        ]

    current_data = [card for n in current_notifications for card in expand(n)]
    upcoming_data = [card for n in upcoming_notifications for card in expand(n)]

    return success_response({
        'timestamp': now.isoformat(),
        'current': current_data,
        'upcoming': upcoming_data,
        'configversion': _config_version(),
    })


@notifications_bp.route('/dashboard/active', methods=['GET'])
@jwt_required()
def dashboard_active():
    """Notifications currently showing on the floor.

    The items, not a count. "4 active notifications" tells an admin nothing;
    knowing WHICH message the shop is looking at right now is the point,
    particularly when one is stale and nobody has taken it down.

    A null start or end means open-ended, matching how the boards read them.
    """
    now = datetime.now(timezone.utc).replace(tzinfo=None)

    rows = (Notification.query
            .filter(db.or_(Notification.starttime.is_(None),
                           Notification.starttime <= now),
                    db.or_(Notification.endtime.is_(None),
                           Notification.endtime >= now))
            .order_by(Notification.starttime.desc())
            .limit(25).all())

    out = []
    for row in rows:
        notificationtype = getattr(row, 'notificationtype', None)
        text = (row.notification or '').strip()
        out.append({
            'notificationid': row.notificationid,
            # Trimmed for the card, whole text for the tooltip. A notification
            # is prose written for the floor, so the first hundred characters
            # are usually enough to recognise which one it is, and the rest is
            # there when it is not.
            'message': text[:100] + ('...' if len(text) > 100 else ''),
            'fullmessage': text,
            'typename': notificationtype.typename if notificationtype else 'Notification',
            'endtime': row.endtime.isoformat() + 'Z' if row.endtime else None,
        })
    return success_response(out)
