"""
CMS Dashboard Views — YOUR COMPANY
All views require cms_required decorator minimum.
"""
import logging

log = logging.getLogger(__name__)

# Single source of truth for the latest platform release.
# Bump this with each new release — version_upgrade() stamps tenants with it.
# The newest release available to install. Must never sit behind CMS_VERSION,
# which is what this server is actually running.
_LATEST_PLATFORM_VERSION = '26.9.1'


def _version_key(value):
    """
    Turn '26.8.2' into (26, 8, 2) so versions compare numerically.

    String comparison gets this wrong: '26.8.10' sorts before '26.8.9', and any
    difference at all reads as 'newer'. Unparsable parts count as zero.
    """
    parts = []
    for chunk in str(value or '').strip().lstrip('v').split('.'):
        digits = ''.join(ch for ch in chunk if ch.isdigit())
        parts.append(int(digits) if digits else 0)
    while len(parts) < 3:
        parts.append(0)
    return tuple(parts[:3])


def _is_newer(candidate, current):
    """True only when candidate is genuinely ahead of current."""
    return _version_key(candidate) > _version_key(current)
from decimal import Decimal
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse
from django.contrib.auth import login, logout, authenticate
from django.contrib import messages
from django.utils import timezone
from django.views.decorators.http import require_POST
from django.views.decorators.cache import never_cache
from django.db.models import Q, Count, Max, Avg
from django.db.models.functions import TruncDate
from datetime import timedelta, date
import json
import io

from .decorators import cms_required, admin_required, super_admin_required, module_required
from .models import User
from apps.pages.models import (
    SiteSettings, HomeHero, HomeAbout, HomeCTA1, HomeCTA2,
    HomeProcess, HomeWhyChooseUs, WhyChooseUsBox, HomeTestimonials,
    Testimonial, Service, ServiceCategory, PartnerLogo, SocialLink, LegalPage,
    FooterLink
)
from apps.brands.models import Brand, RESERVED_SLUGS as BRAND_RESERVED_SLUGS
from apps.portfolio.models import PortfolioItem, PortfolioCategory
from apps.insights.models import Insight, InsightCategory
from apps.auction.models import AuctionListing
from apps.auction.page_generator import generate_auction_page
from apps.marketplace.models import MarketplaceService
from apps.tickets.models import Ticket, TicketReply
from apps.faq.models import FAQCategory, FAQItem
from apps.tenants.models import Tenant, TenantModule, TenantUser, MODULE_CHOICES, ALWAYS_ON_MODULES, PLAN_MODULES
from apps.tenants.utils import provision_tenant
from apps.chat.models import ChatSession, ChatMessage
from apps.sales.models import ProjectAssessment, AssessmentQuote
from django.db.models import Sum


# ─── Image helpers ────────────────────────────────────────────────────────────

def _convert_to_webp(source, original_name='image'):
    """
    Accept either:
      - a Django UploadedFile
      - a base64 data-URL string  (from client-side crop)
    Returns an InMemoryUploadedFile in WebP format (quality 85).
    Also returns (width, height) of the image so the caller can validate.
    """
    import base64, os
    from PIL import Image as PilImage
    from django.core.files.uploadedfile import InMemoryUploadedFile
    from django.utils.text import slugify

    if isinstance(source, str):
        _, encoded = source.split(',', 1)
        raw = base64.b64decode(encoded)
        img = PilImage.open(io.BytesIO(raw))
    else:
        img = PilImage.open(source)

    w, h = img.size

    # Keep transparency. This used to paste every image onto a solid white
    # square before saving, which turned a transparent logo into a white bar
    # and a transparent favicon into a filled block. The upload preview showed
    # the original file, so the damage only appeared after saving and looked
    # like a display bug for months. WebP carries an alpha channel, so there
    # was never a reason to flatten.
    if img.mode in ('RGBA', 'LA', 'P'):
        img = img.convert('RGBA')
    elif img.mode != 'RGB':
        img = img.convert('RGB')

    buf = io.BytesIO()
    img.save(buf, 'WEBP', quality=85, method=6)
    buf.seek(0)

    base_name = os.path.splitext(os.path.basename(str(original_name)))[0] or 'image'
    fname = f"{slugify(base_name)}.webp"

    return (
        InMemoryUploadedFile(buf, 'ImageField', fname, 'image/webp', buf.getbuffer().nbytes, None),
        w, h,
    )


# ─── Auth ─────────────────────────────────────────────────────────────────────

REMEMBER_DAYS = 7


TRUST_COOKIE = 'ft_2fa_trust'


def _trust_value(user):
    from django.core import signing
    return signing.dumps({'u': user.pk, 'p': user.password[-12:]}, salt='2fa-trust')


def _trust_ok(request, user):
    from django.core import signing
    raw = request.COOKIES.get(TRUST_COOKIE)
    if not raw:
        return False
    try:
        data = signing.loads(raw, salt='2fa-trust', max_age=REMEMBER_DAYS * 24 * 60 * 60)
    except Exception:
        return False
    return data.get('u') == user.pk and data.get('p') == user.password[-12:]


def _set_trust(response, user):
    response.set_cookie(TRUST_COOKIE, _trust_value(user),
                        max_age=REMEMBER_DAYS * 24 * 60 * 60,
                        secure=True, httponly=True, samesite='Lax')
    return response


def _apply_remember(request):
    flag = request.session.pop('cms_remember', False)
    if not flag and request.method == 'POST':
        flag = bool(request.POST.get('remember'))
    if flag:
        request.session.set_expiry(REMEMBER_DAYS * 24 * 60 * 60)
    else:
        request.session.set_expiry(0)


@never_cache
def cms_login(request):
    from .twofa import mark_2fa_pending, get_2fa_pending_user
    if request.user.is_authenticated and request.user.is_cms_user:
        return redirect('cms_dashboard')

    # The second factor is asked for here, at /login/, not at a separate
    # address.
    #
    # It used to redirect to /login/2fa/, and that page failed CSRF with
    # "token from POST incorrect" every time: the form was rendered with a
    # token belonging to a cached copy of the page rather than to the
    # visitor's own cookie. Sitting on /login/ it is covered by the same
    # never_cache header as the first step, and there is one address to
    # reason about instead of two.
    if request.GET.get('restart'):
        from .twofa import clear_2fa_pending
        clear_2fa_pending(request)
        return redirect('cms_login')

    if get_2fa_pending_user(request):
        return cms_2fa_verify(request)

    if request.method == 'POST':
        username = request.POST.get('username')
        password = request.POST.get('password')
        user = authenticate(request, username=username, password=password)
        if user and user.is_cms_user:
            request.session['cms_remember'] = bool(request.POST.get('remember'))
            from django.utils.http import url_has_allowed_host_and_scheme
            raw_next = request.GET.get('next', '') or request.POST.get('next', '')
            next_url = raw_next if url_has_allowed_host_and_scheme(
                raw_next, allowed_hosts={request.get_host()}
            ) else ''
            if user.has_2fa and not _trust_ok(request, user):
                mark_2fa_pending(request, user.pk)
                request.session['2fa_next'] = next_url or 'cms_dashboard'
                request.session['2fa_backend'] = 'django.contrib.auth.backends.ModelBackend'
                return redirect('cms_login')
            login(request, user)
            _apply_remember(request)
            return redirect(next_url or 'cms_dashboard')
        messages.error(request, 'Invalid credentials or access denied.')
    return render(request, 'cms/login.html')


@never_cache
def cms_2fa_verify(request):
    """
    Second step: verify TOTP / email / SMS / backup code.

    Reached through cms_login at /login/. The old /login/2fa/ route still
    exists and redirects here, so a bookmark or a half finished login does
    not dead end.
    """
    from .twofa import (
        get_2fa_pending_user, clear_2fa_pending,
        verify_totp, verify_email_otp, verify_sms_otp,
        verify_backup_code, send_email_otp, send_sms_otp,
    )
    user_id = get_2fa_pending_user(request)
    if not user_id:
        return redirect('cms_login')

    try:
        user = User.objects.get(pk=user_id)
    except User.DoesNotExist:
        return redirect('cms_login')

    fa = user.two_fa
    if not fa:
        # No 2FA record — just log them in
        clear_2fa_pending(request)
        login(request, user)
        _apply_remember(request)
        return redirect(request.session.pop('2fa_next', 'cms_dashboard'))

    method = fa.preferred_method or 'totp'
    error = None

    if request.method == 'POST':
        action = request.POST.get('action', 'verify')

        # Resend email / SMS
        if action == 'resend_email':
            send_email_otp(user, session=request.session)
            messages.success(request, 'A new code has been sent to your email.')
            return redirect('cms_login')
        if action == 'resend_sms':
            send_sms_otp(user, session=request.session)
            messages.success(request, 'A new code has been sent by SMS.')
            return redirect('cms_login')

        code = request.POST.get('code', '').strip()
        verified = False

        if method == 'totp' and fa.totp_secret:
            verified = verify_totp(fa.totp_secret, code)
        elif method == 'email' and fa.email_confirmed:
            verified = verify_email_otp(user, code, session=request.session)
        elif method == 'sms' and fa.sms_confirmed:
            verified = verify_sms_otp(user, code, session=request.session)

        # Fallback: backup code
        if not verified and code:
            verified = verify_backup_code(fa, code)

        if verified:
            clear_2fa_pending(request)
            backend = request.session.pop('2fa_backend', 'django.contrib.auth.backends.ModelBackend')
            login(request, user, backend=backend)
            _apply_remember(request)
            next_url = request.session.pop('2fa_next', 'cms_dashboard')
            resp = redirect(next_url)
            if request.POST.get('remember'):
                _set_trust(resp, user)
            return resp
        else:
            error = 'Invalid code. Please try again.'

    # Auto-send email/SMS OTP on first load
    if request.method == 'GET':
        if method == 'email' and fa.email_confirmed:
            send_email_otp(user, session=request.session)
        elif method == 'sms' and fa.sms_confirmed:
            send_sms_otp(user, session=request.session)

    return render(request, 'cms/2fa_verify.html', {
        'method': method,
        'fa': fa,
        'error': error,
        'email_masked': _mask_email(user.email),
        'phone_masked': _mask_phone(fa.sms_phone or ''),
    })


def _mask_email(email):
    if '@' not in email:
        return email
    local, domain = email.split('@', 1)
    return local[:2] + '***@' + domain


def _mask_phone(phone):
    if len(phone) < 4:
        return phone
    return '****' + phone[-4:]


def cms_logout(request):
    logout(request)
    return redirect('home')


# ─── Stripe health check ──────────────────────────────────────────────────────

def _get_stripe_status(tenant=None):
    """Ping Stripe API and return a status dict for the CRM panel."""
    try:
        import stripe
        from apps.ecommerce.stripe_utils import _get_stripe_secret_key
        key = _get_stripe_secret_key(tenant) or ''
        stripe.api_key = key
        account = stripe.Account.retrieve()
        return {
            'ok': True,
            'account': account.get('email') or account.get('id', ''),
            'live_mode': not key.startswith('sk_test'),
            'key_hint': key[:8] + '...' + key[-4:] if len(key) > 12 else key,
        }
    except Exception as e:
        try:
            from apps.ecommerce.stripe_utils import _get_stripe_secret_key as _gsk
            key = _gsk(tenant) or ''
        except Exception:
            key = ''
        return {
            'ok': False,
            'error': str(e)[:120],
            'live_mode': not key.startswith('sk_test'),
            'key_hint': key[:8] + '...' + key[-4:] if len(key) > 12 else '(not set)',
        }


def _get_stripe_payment_errors():
    """Return recent cached payment errors logged by the checkout view."""
    try:
        from django.core.cache import cache
        return cache.get('stripe_payment_errors', [])
    except Exception:
        return []


# ─── Dashboard ────────────────────────────────────────────────────────────────

def _safe_count(qs):
    """Count a queryset; returns 0 if the table doesn't exist yet (pre-migration)."""
    try:
        return qs.count()
    except Exception:
        return 0


@cms_required
def dashboard(request):
    today = date.today()
    thirty_ago = timezone.now() - timedelta(days=30)

    # Build date labels for last 30 days
    trend_labels = []
    date_range = []
    for i in range(29, -1, -1):
        d = today - timedelta(days=i)
        trend_labels.append(d.strftime('%d %b'))
        date_range.append(d)

    def _daily_counts(qs):
        """Return {date: count} for each day in date_range.
        One COUNT per day — avoids all SQLite TruncDate/timezone issues."""
        try:
            return {d: qs.filter(created_at__date=d).count() for d in date_range}
        except Exception:
            return {}

    ticket_counts     = _daily_counts(Ticket.objects.all())
    assessment_counts = _daily_counts(ProjectAssessment.objects.all())
    contact_counts    = _daily_counts(Ticket.objects.filter(source=Ticket.SOURCE_CONTACT))

    # Order counts split by product type
    def _daily_order_type_counts(ptypes):
        try:
            from apps.ecommerce.models import Order, OrderItem
            oid = OrderItem.objects.filter(product_type__in=ptypes).values_list('order_id', flat=True)
            qs  = Order.objects.filter(id__in=oid)
            return {d: qs.filter(created_at__date=d).count() for d in date_range}
        except Exception:
            return {}

    product_counts      = _daily_order_type_counts(['product', 'domain', 'hosting'])
    service_counts      = _daily_order_type_counts(['service'])
    subscription_counts = _daily_order_type_counts(['subscription'])

    trend_assessments   = [assessment_counts.get(d, 0)   for d in date_range]
    trend_contact       = [contact_counts.get(d, 0)      for d in date_range]
    trend_products      = [product_counts.get(d, 0)      for d in date_range]
    trend_services      = [service_counts.get(d, 0)      for d in date_range]
    trend_subscriptions = [subscription_counts.get(d, 0) for d in date_range]

    # Ticket status breakdown
    status_map = {label: 0 for _, label in Ticket.STATUS_CHOICES}
    for s, label in Ticket.STATUS_CHOICES:
        status_map[label] = Ticket.objects.filter(status=s).count()

    # Enquiry source breakdown
    source_map = {}
    for s, label in Ticket.SOURCE_CHOICES:
        cnt = Ticket.objects.filter(source=s).count()
        if cnt:
            source_map[label] = cnt

    # Google Analytics data (non-blocking — returns safe defaults on error)
    from .ga_utils import get_ga_dashboard_data
    ga = get_ga_dashboard_data(days=30)

    # Payments summary (non-blocking)
    payments = _get_payments_summary()
    dash_tenant = _resolve_tenant(request)
    service_health = _get_service_health(dash_tenant)
    service_events = _get_service_events(8, dash_tenant)

    # Activity feed — mixed recent events (tickets + assessments + orders)
    activity_feed = _get_pending_tasks(request.user)

    # Site Pulse — BPM-style score 0-100
    # Base resting = 50; each task type raises it
    _high_c = sum(1 for t in activity_feed if t['priority'] == 'high')
    _med_c  = sum(1 for t in activity_feed if t['priority'] == 'medium')
    _low_c  = sum(1 for t in activity_feed if t['priority'] == 'low')
    pulse_rate = min(100, 50 + _high_c * 20 + _med_c * 10 + _low_c * 3)
    # Report the number of tasks actually waiting, not the count of one
    # priority band. A single high priority task used to raise the warning and
    # then print the medium count, which read "0 item pending" beside a list
    # holding one.
    _total_c = len(activity_feed)
    _plural  = 's' if _total_c != 1 else ''
    # Anything that has been waiting more than a day is urgent on its own,
    # whatever the total score says.
    _overdue_c = sum(1 for t in activity_feed if t.get('overdue'))
    if _overdue_c:
        pulse_rate = max(pulse_rate, 80)
    if pulse_rate >= 80:
        pulse_state = 'urgent'
        if _overdue_c:
            _op = 's' if _overdue_c != 1 else ''
            pulse_label = f'Overdue — {_overdue_c} item{_op} waiting more than 24 hours'
        else:
            pulse_label = f'Action Required — {_total_c} task{_plural} waiting'
    elif pulse_rate >= 60:
        pulse_state = 'warning'
        pulse_label = f'Attention Needed — {_total_c} item{_plural} pending'
    else:
        pulse_state = 'normal'
        pulse_label = 'All Clear'

    # New and Archived, counted the way the two ticket screens define them, so
    # the dashboard and the inbox never disagree about how many there are.
    from apps.tickets.models import BlockedEmail as _BlockedEmail
    _blocked_senders = _BlockedEmail.objects.values_list('email', flat=True)

    # Every KPI box shows a total and splits it on the line below, so the
    # headline and the line under it always account for the same rows.
    # Counting only the active ones and then labelling the box "Active" meant
    # there was no way to see what was hidden, and the Services box was not
    # filtering on is_active at all, so its "Active services" label was wrong.
    from apps.ecommerce.models import Product as _Product

    _portfolio     = PortfolioItem.objects.all()
    _insights      = Insight.objects.all()
    # Domains have their own KPI box, so they come out of Products. Counting
    # them in both made the two boxes overlap and the same rows show twice.
    _products      = _Product.objects.filter(product_type__in=['product', 'hosting'])
    _domains       = _Product.objects.filter(product_type='domain')
    _services      = Service.objects.filter(Q(tenant=_resolve_tenant(request)) | Q(tenant__isnull=True))
    _subscriptions = _Product.objects.filter(product_type='subscription')
    # Same scope the Why Boxes and FAQ pages use, so the KPI and the list
    # they link to always report the same number.
    _why           = WhyChooseUsBox.objects.all()
    _faq           = FAQItem.objects.all()

    context = {
        'total_tickets':      Ticket.objects.count(),
        'new_tickets':        Ticket.inbox().filter(status=Ticket.STATUS_NEW).count(),
        'archived_tickets':   Ticket.objects.filter(
                                  Q(status__in=[Ticket.STATUS_CLOSED, Ticket.STATUS_RESOLVED])
                                  | Q(sender_email__in=_blocked_senders)
                              ).count(),
        'open_tickets':       Ticket.objects.filter(status__in=[Ticket.STATUS_OPEN, Ticket.STATUS_IN_PROGRESS]).count(),
        'portfolio_count':      _safe_count(_portfolio),
        'portfolio_active':     _safe_count(_portfolio.filter(is_active=True)),
        'portfolio_hidden':     _safe_count(_portfolio.filter(is_active=False)),
        # Draft here means everything short of Published, so In Review,
        # Approved, Ready and Scheduled are counted with it.
        'insights_count':       _safe_count(_insights),
        'insights_published':   _safe_count(_insights.filter(status='published')),
        'insights_draft':       _safe_count(_insights.exclude(status='published')),
        'product_count':        _safe_count(_products),
        'product_active':       _safe_count(_products.filter(is_active=True)),
        'product_hidden':       _safe_count(_products.filter(is_active=False)),
        'service_count':        _safe_count(_services),
        'service_active':       _safe_count(_services.filter(is_active=True)),
        'service_hidden':       _safe_count(_services.filter(is_active=False)),
        'why_count':            _safe_count(_why),
        'why_active':           _safe_count(_why.filter(is_active=True)),
        'why_hidden':           _safe_count(_why.filter(is_active=False)),
        'faq_count':            _safe_count(_faq),
        'faq_active':           _safe_count(_faq.filter(is_active=True)),
        'faq_hidden':           _safe_count(_faq.filter(is_active=False)),
        'domain_count':         _safe_count(_domains),
        'domain_active':        _safe_count(_domains.filter(is_active=True)),
        'domain_hidden':        _safe_count(_domains.filter(is_active=False)),
        'subscription_count':   _safe_count(_subscriptions),
        'subscription_active':  _safe_count(_subscriptions.filter(is_active=True)),
        'subscription_hidden':  _safe_count(_subscriptions.filter(is_active=False)),
        'shop_count':         _safe_count(__import__('apps.ecommerce.models', fromlist=['Product']).Product.objects.filter(is_active=True)),
        'assessment_count':   _safe_count(ProjectAssessment.objects.all()),
        'new_assessments':    _safe_count(ProjectAssessment.objects.filter(status='new')),
        # Same rule as the Tickets page: closed and resolved are archived, and
        # blocked senders are hidden. Showing everything here made the two
        # screens disagree about what was outstanding.
        'recent_tickets':             _dashboard_recent_tickets(),
        'service_events':             service_events,
        'activity_feed':              activity_feed,
        'pulse_rate':   pulse_rate,
        'pulse_state':  pulse_state,
        'pulse_label':  pulse_label,
        'recent_subscriptions_preview': list(
            __import__('apps.ecommerce.models', fromlist=['Order', 'OrderItem']).Order.objects.filter(
                id__in=__import__('apps.ecommerce.models', fromlist=['OrderItem']).OrderItem.objects.filter(
                    product_type='subscription'
                ).values_list('order_id', flat=True)
            ).order_by('-created_at')[:5]
        ),
        # Enquiry chart data
        'trend_labels':         json.dumps(trend_labels),
        'trend_products':       json.dumps(trend_products),
        'trend_services':       json.dumps(trend_services),
        'trend_subscriptions':  json.dumps(trend_subscriptions),
        'trend_assessments':    json.dumps(trend_assessments),
        'trend_contact':        json.dumps(trend_contact),
        'status_labels':   json.dumps(list(status_map.keys())),
        'status_data':     json.dumps(list(status_map.values())),
        'source_labels':   json.dumps(list(source_map.keys())),
        'source_data':     json.dumps(list(source_map.values())),
        # GA4 data
        **ga,
        # Payments
        **payments,
        # Service health, estate availability from ftlink check-ins
        **service_health,
    }
    return render(request, 'cms/dashboard.html', context)


def _get_service_health(tenant=None, days=30):
    """
    Availability of THIS install, whichever panel it is. YOUR COMPANY sees
    YOUR COMPANY, a tenant sees itself. Fleet wide state belongs on the Service
    Health page, not on anybody's dashboard.
    """
    from apps.ftlink.models import UptimeSample

    if tenant is None:
        return {
            'sh_labels': json.dumps([]), 'sh_series': json.dumps([]),
            'sh_overall': None, 'sh_down': 0, 'sh_sample_count': 0, 'sh_latency': None,
        }

    since = timezone.now() - timedelta(days=days)
    rows = list(UptimeSample.objects.filter(tenant=tenant, checked_at__gte=since)
                .values_list('checked_at', 'ok', 'latency_ms'))

    total = len(rows)
    up = sum(1 for _, ok, _ in rows if ok)
    lat = [l for _, ok, l in rows if ok and l]

    buckets = {}
    for when, ok, _l in rows:
        d = timezone.localtime(when).date()
        good, seen = buckets.get(d, (0, 0))
        buckets[d] = (good + (1 if ok else 0), seen + 1)

    today = timezone.now().date()
    labels, series = [], []
    for i in range(days - 1, -1, -1):
        d = today - timedelta(days=i)
        labels.append(d.strftime('%d %b'))
        good, seen = buckets.get(d, (0, 0))
        series.append(round(good / seen * 100, 1) if seen else None)
    first = next((i for i, v in enumerate(series) if v is not None), None)
    if first:
        labels, series = labels[first:], series[first:]

    last = UptimeSample.objects.filter(tenant=tenant).first()

    # One failed probe is not an outage. Say which of the three it is, rather
    # than printing "Site not responding" beside 99.87% availability.
    sh_state, sh_since = '', None
    if tenant is not None and not getattr(tenant, 'monitor_enabled', True):
        sh_state = 'not_monitored'
    elif last is not None and not last.ok:
        try:
            from apps.ftlink.models import Incident
            open_inc = (Incident.objects.filter(tenant=tenant, ended_at__isnull=True)
                        .order_by('-started_at').first())
        except Exception:
            open_inc = None
        if open_inc:
            sh_state, sh_since = 'down', open_inc.started_at
        else:
            sh_state = 'last_check_failed'

    return {
        'sh_labels': json.dumps(labels),
        'sh_series': json.dumps(series),
        'sh_overall': round(up / total * 100, 2) if total else None,
        'sh_sample_count': total,
        'sh_latency': int(sum(lat) / len(lat)) if lat else None,
        'sh_last': last,
        'sh_down': 0 if (last is None or last.ok) else 1,
        'sh_state': sh_state,
        'sh_since': sh_since,
    }


def _dashboard_recent_tickets(limit=6):
    """The newest open tickets, filtered exactly as the Tickets page filters."""
    qs = Ticket.objects.select_related('auction_listing').exclude(
        status__in=[Ticket.STATUS_CLOSED, Ticket.STATUS_RESOLVED])
    try:
        from apps.tickets.models import BlockedEmail
        blocked = list(BlockedEmail.objects.values_list('email', flat=True))
        if blocked:
            qs = qs.exclude(sender_email__in=blocked)
    except Exception:
        pass
    return qs.order_by('-created_at')[:limit]



def _get_service_events(limit=8, tenant=None):
    """
    Maintenance and change log: version grants, module changes and version moves
    seen on check-in. Newest first.
    """
    events = []
    # This install only, whoever is looking. A dashboard is about your own site.
    only = tenant.pk if tenant is not None else None

    try:
        from apps.tenants.models import TenantUpgradeCharge
        qs = TenantUpgradeCharge.objects.select_related('tenant')
        if only:
            qs = qs.filter(tenant_id=only)
        for c in qs.order_by('-created_at')[:limit]:
            events.append({
                'when': c.created_at,
                'kind': 'version',
                'title': f'FT OS {c.version}',
                'detail': f'{c.tenant.name} · {c.get_status_display()}',
            })
    except Exception:
        pass
    try:
        from apps.tenants.models import TenantModuleLog
        qs = TenantModuleLog.objects.select_related('tenant')
        if only:
            qs = qs.filter(tenant_id=only)
        for m in qs.order_by('-created_at')[:limit]:
            events.append({
                'when': m.created_at,
                'kind': 'module',
                'title': getattr(m, 'module', '') or 'Module change',
                'detail': f'{m.tenant.name} · {getattr(m, "action", "")}',
            })
    except Exception:
        pass
    # Incidents are the maintenance record people actually look for. Without
    # them this panel sat empty while Service Health listed five.
    try:
        from apps.ftlink.models import Incident
        qs = Incident.objects.select_related('tenant')
        if only:
            qs = qs.filter(tenant_id=only)
        for inc in qs.order_by('-started_at')[:limit]:
            events.append({
                'when': inc.started_at,
                'kind': 'incident',
                'title': inc.get_cause_display(),
                'detail': f'{inc.tenant.name} · '
                          + ('resolved' if inc.ended_at else 'open'),
            })
    except Exception:
        pass
    try:
        from apps.ftlink.models import CheckinLog
        seen = {}
        qs = CheckinLog.objects.select_related('tenant')
        if only:
            qs = qs.filter(tenant_id=only)
        for log in qs.order_by('-created_at')[:200]:
            key = log.tenant_id
            if key in seen and seen[key] == log.ft_version:
                continue
            if key in seen and log.ft_version and seen[key] != log.ft_version:
                events.append({
                    'when': log.created_at,
                    'kind': 'checkin',
                    'title': f'Reported {log.ft_version}',
                    'detail': f'{log.tenant.name} · {log.os_label or "checked in"}',
                })
            seen[key] = log.ft_version
    except Exception:
        pass

    events.sort(key=lambda e: e['when'], reverse=True)
    return events[:limit]


def _get_payments_summary():
    """Return payment stats for the dashboard. Non-blocking."""
    try:
        from apps.ecommerce.models import Order
        from decimal import Decimal
        now = timezone.now()

        paid_statuses  = [Order.STATUS_PAID, Order.STATUS_COMPLETED, Order.STATUS_PROCESSING]
        deposit_status = [Order.STATUS_DEPOSIT_PAID]
        from apps.ecommerce.models import OrderItem

        paid_order_ids    = set(Order.objects.filter(status__in=paid_statuses).values_list('id', flat=True))
        deposit_order_ids = set(Order.objects.filter(status__in=deposit_status).values_list('id', flat=True))

        # Total cleared — sum item prices, not raw order amount_paid
        cleared = OrderItem.objects.filter(
            order_id__in=paid_order_ids
        ).aggregate(total=Sum('total_price'))['total'] or Decimal('0')

        # Deposit paid (balance still outstanding)
        deposit_orders = Order.objects.filter(status__in=deposit_status)
        deposits_collected = OrderItem.objects.filter(
            order_id__in=deposit_order_ids
        ).aggregate(total=Sum('total_price'))['total'] or Decimal('0')
        balance_outstanding = sum(o.balance_due for o in deposit_orders)

        # This month vs last month revenue
        month_start      = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
        prev_month_end   = month_start - timedelta(seconds=1)
        prev_month_start = prev_month_end.replace(day=1, hour=0, minute=0, second=0, microsecond=0)

        month_paid_ids = set(Order.objects.filter(
            status__in=paid_statuses, paid_at__gte=month_start
        ).values_list('id', flat=True))
        month_revenue = OrderItem.objects.filter(
            order_id__in=month_paid_ids
        ).aggregate(total=Sum('total_price'))['total'] or Decimal('0')

        prev_paid_ids = set(Order.objects.filter(
            status__in=paid_statuses, paid_at__gte=prev_month_start, paid_at__lt=month_start
        ).values_list('id', flat=True))
        last_month_revenue = OrderItem.objects.filter(
            order_id__in=prev_paid_ids
        ).aggregate(total=Sum('total_price'))['total'] or Decimal('0')

        if last_month_revenue > 0:
            month_change_pct = round(
                (float(month_revenue) - float(last_month_revenue)) / float(last_month_revenue) * 100, 1
            )
        else:
            month_change_pct = None

        # Recent transactions (all statuses except pending, last 30 days)
        recent_orders = Order.objects.filter(
            created_at__gte=now - timedelta(days=30)
        ).exclude(status=Order.STATUS_PENDING).order_by('-created_at')[:8]

        # All recent transactions for the transactions table (wider view)
        recent_transactions = Order.objects.order_by('-created_at')[:10]

        # Orders count
        total_orders   = Order.objects.exclude(status=Order.STATUS_PENDING).count()
        pending_orders = Order.objects.filter(status=Order.STATUS_PENDING).count()

        # Subscriptions (orders containing subscription-type products)
        from apps.ecommerce.models import OrderItem
        sub_oids    = set(OrderItem.objects.filter(product_type='subscription').values_list('order_id', flat=True))
        sub_paid    = paid_order_ids & sub_oids
        sub_revenue = OrderItem.objects.filter(
            product_type='subscription', order_id__in=sub_paid
        ).aggregate(total=Sum('total_price'))['total'] or Decimal('0')
        sub_count   = Order.objects.filter(id__in=sub_paid).count()

        # Instalments (active deposit-paid orders — balance still owed)
        instalment_count = deposit_orders.count()

        # Total expenses (costs + supplier fees)
        from apps.ecommerce.models import Expense
        total_expenses = Expense.objects.aggregate(t=Sum('amount'))['t'] or Decimal('0')

        # Actual Stripe fees stored on each paid order; fall back to estimate for older orders
        stripe_fees_actual = Order.objects.filter(
            id__in=paid_order_ids
        ).aggregate(t=Sum('stripe_fee'))['t'] or Decimal('0')
        if stripe_fees_actual > 0:
            stripe_fees = stripe_fees_actual
        else:
            # Estimate: 1.5% + £0.20 per transaction (rounded per txn like Stripe does)
            paid_count = len(paid_order_ids)
            stripe_fees = (cleared * Decimal('0.015') +
                           Decimal('0.20') * paid_count).quantize(Decimal('0.01'))
        net_profit  = (cleared - total_expenses - stripe_fees).quantize(Decimal('0.01'))

        return {
            'pay_cleared':                cleared,
            'pay_deposits_collected':     deposits_collected,
            'pay_balance_outstanding':    balance_outstanding,
            'pay_month_revenue':          month_revenue,
            'pay_last_month_revenue':     last_month_revenue,
            'pay_month_change_pct':       month_change_pct,
            'pay_recent_orders':          recent_orders,
            'pay_recent_transactions':    recent_transactions,
            'pay_total_orders':           total_orders,
            'pay_pending_orders':         pending_orders,
            'pay_sub_revenue':            sub_revenue,
            'pay_sub_count':              sub_count,
            'pay_instalment_count':       instalment_count,
            'pay_expenses':               total_expenses,
            'pay_stripe_fees':            stripe_fees,
            'pay_net_profit':             net_profit,
            'pay_gross_revenue':          cleared,
        }
    except Exception:
        from decimal import Decimal
        return {
            'pay_cleared':                Decimal('0'),
            'pay_deposits_collected':     Decimal('0'),
            'pay_balance_outstanding':    Decimal('0'),
            'pay_month_revenue':          Decimal('0'),
            'pay_last_month_revenue':     Decimal('0'),
            'pay_month_change_pct':       None,
            'pay_recent_orders':          [],
            'pay_recent_transactions':    [],
            'pay_total_orders':           0,
            'pay_pending_orders':         0,
            'pay_sub_revenue':            Decimal('0'),
            'pay_sub_count':              0,
            'pay_instalment_count':       0,
            'pay_expenses':               Decimal('0'),
            'pay_stripe_fees':            Decimal('0'),
            'pay_net_profit':             Decimal('0'),
            'pay_gross_revenue':          Decimal('0'),
        }


def _get_pending_tasks(user):
    """Task-based pending items: what needs actioning when the user logs back in.
    Ordered by priority: high (needs reply/review) → medium (finish/publish) → low (follow-up)."""
    tasks = []

    # New tickets awaiting first reply, weighted by how long they have waited.
    #
    # This used to score every new ticket 'high' the moment it arrived, which
    # put the welcome card into amber before anyone could reasonably have
    # replied. The ticket rows in the table have always used a different rule,
    # amber after twelve hours and red after twenty four, so the two disagreed
    # about the same ticket. One rule now, and it is the table's.
    try:
        from django.utils import timezone as _tz
        _now = _tz.now()
        for t in Ticket.objects.filter(status=Ticket.STATUS_NEW).order_by('-created_at')[:5]:
            _waited = (_now - t.created_at).total_seconds() / 3600 if t.created_at else 0
            if _waited > 24:
                _pri = 'high'
            elif _waited > 12:
                _pri = 'medium'
            else:
                _pri = 'low'
            tasks.append({
                'priority': _pri,
                # Past a day, this alone turns the card red. The score is an
                # aggregate and a single ticket never reaches the urgent
                # threshold on its own, so the rule is stated rather than
                # arrived at by arithmetic: amber past 12 hours, red past 24,
                # exactly what the ticket rows show.
                'overdue': _waited > 24,
                'priority_label': 'Reply',
                'icon': 'fa-reply',
                'color': '#1d4ed8',
                'bg': '#eff6ff',
                'action': 'Reply to',
                'title': t.sender_name,
                'sub': t.subject[:55],
                'pk': t.pk,
                'url_name': 'cms_ticket_detail',
            })
    except Exception:
        pass

    # HIGH — Assessments not yet reviewed
    try:
        from django.utils import timezone as _tz2
        _now2 = _tz2.now()
        for a in ProjectAssessment.objects.filter(status='new').order_by('-created_at')[:4]:
            _w = (_now2 - a.created_at).total_seconds() / 3600 if a.created_at else 0
            if _w > 24:
                _p = 'high'
            elif _w > 12:
                _p = 'medium'
            else:
                _p = 'low'
            tasks.append({
                'priority': _p,
                'overdue': _w > 24,
                'priority_label': 'Review',
                'icon': 'fa-clipboard-list',
                'color': '#ff404b',
                'bg': 'rgba(255,64,75,.1)',
                'action': 'Review',
                'title': a.name,
                'sub': a.company or a.get_budget_display(),
                'pk': a.pk,
                'url_name': 'cms_assessment_detail',
            })
    except Exception:
        pass

    # MEDIUM — Insights not yet published (draft / in review / approved / ready)
    try:
        from apps.insights.models import Insight
        for i in Insight.objects.filter(
            status__in=['draft', 'review', 'approved', 'ready']
        ).order_by('-updated_at')[:4]:
            action = 'Publish' if i.status in ('approved', 'ready') else 'Finish'
            tasks.append({
                'priority': 'medium',
                'priority_label': action,
                'icon': 'fa-newspaper',
                'color': '#6d28d9',
                'bg': '#faf5ff',
                'action': action,
                'title': i.title[:50],
                'sub': i.get_status_display(),
                'pk': i.pk,
                'url_name': 'cms_insight_edit',
            })
    except Exception:
        pass

    # MEDIUM — Orders with outstanding balance (deposit paid, balance owed)
    try:
        from apps.ecommerce.models import Order
        for o in Order.objects.filter(status='deposit_paid').order_by('-created_at')[:3]:
            tasks.append({
                'priority': 'medium',
                'priority_label': 'Balance',
                'icon': 'fa-sterling-sign',
                'color': '#92400e',
                'bg': '#fffbeb',
                'action': 'Chase balance',
                'title': o.customer_name,
                'sub': f'Order #{o.order_number}',
                'pk': o.pk,
                'url_name': 'cms_order_detail',
            })
    except Exception:
        pass

    # HIGH/MEDIUM — Tickets assigned to this user (not closed/resolved)
    try:
        CLOSED = [Ticket.STATUS_CLOSED, Ticket.STATUS_RESOLVED]
        for t in Ticket.objects.filter(
            assigned_to=user
        ).exclude(status__in=CLOSED).order_by('-created_at')[:8]:
            if t.priority in ('urgent', 'high'):
                task_priority = 'high'
                icon_color    = '#dc2626'
                icon_bg       = '#fef2f2'
                badge_label   = t.get_priority_display()
            elif t.priority == 'medium':
                task_priority = 'medium'
                icon_color    = '#d97706'
                icon_bg       = '#fffbeb'
                badge_label   = 'Medium'
            else:
                task_priority = 'low'
                icon_color    = '#64748b'
                icon_bg       = '#f1f5f9'
                badge_label   = 'Low'
            tasks.append({
                'priority':       task_priority,
                'priority_label': badge_label,
                'icon':           'fa-ticket',
                'color':          icon_color,
                'bg':             icon_bg,
                'action':         'Assigned ticket:',
                'title':          t.sender_name,
                'sub':            t.subject[:55],
                'pk':             t.pk,
                'url_name':       'cms_ticket_detail',
            })
    except Exception:
        pass

    # LOW — Open tickets with no recent update
    try:
        stale_cutoff = timezone.now() - timedelta(days=3)
        for t in Ticket.objects.filter(
            status=Ticket.STATUS_OPEN, updated_at__lte=stale_cutoff
        ).order_by('updated_at')[:3]:
            tasks.append({
                'priority': 'low',
                'priority_label': 'Follow up',
                'icon': 'fa-clock',
                'color': '#64748b',
                'bg': '#f1f5f9',
                'action': 'Follow up with',
                'title': t.sender_name,
                'sub': t.subject[:55],
                'pk': t.pk,
                'url_name': 'cms_ticket_detail',
            })
    except Exception:
        pass

    priority_order = {'high': 0, 'medium': 1, 'low': 2}
    tasks.sort(key=lambda x: priority_order.get(x['priority'], 2))
    return tasks[:12]


# ─── Dashboard trend AJAX ────────────────────────────────────────────────────

@cms_required
def dashboard_trend_json(request):
    """Return enquiry trend data for a given period (1w/1m/3m/6m/1y)."""
    from django.http import JsonResponse as JR
    from collections import defaultdict

    period = request.GET.get('period', '1m')
    days   = {'1w': 7, '1m': 30, '3m': 90, '6m': 180, '1y': 365}.get(period, 30)
    today  = date.today()
    start_date = today - timedelta(days=days - 1)
    start_dt   = timezone.make_aware(
        timezone.datetime.combine(start_date, timezone.datetime.min.time())
    )

    # 2 DB queries — bulk fetch, then aggregate in Python
    ticket_rows     = list(Ticket.objects.filter(created_at__gte=start_dt).values_list('created_at', 'source'))
    assessment_rows = list(ProjectAssessment.objects.filter(created_at__gte=start_dt).values_list('created_at', flat=True))

    # Choose bucket granularity
    if days <= 30:
        buckets = [start_date + timedelta(days=i) for i in range(days)]
        labels  = [b.strftime('%d %b') for b in buckets]
        def get_bucket(dt):
            return dt.date()
    elif days <= 180:
        num_weeks = (days + 6) // 7
        buckets   = [start_date + timedelta(days=i * 7) for i in range(num_weeks)]
        labels    = [b.strftime('%d %b') for b in buckets]
        def get_bucket(dt):
            d = dt.date()
            n = (d - start_date).days // 7
            return start_date + timedelta(days=n * 7)
    else:
        # Monthly (1Y)
        months, y, m = [], start_date.year, start_date.month
        while date(y, m, 1) <= today:
            months.append(date(y, m, 1))
            m += 1
            if m > 12: m, y = 1, y + 1
        buckets = months
        labels  = [b.strftime('%b %Y') for b in buckets]
        def get_bucket(dt):
            d = dt.date()
            return date(d.year, d.month, 1)

    bucket_set = set(buckets)
    contact_by = defaultdict(int); assess_by = defaultdict(int)
    for created_at, source in ticket_rows:
        b = get_bucket(created_at)
        if b in bucket_set and source == 'contact':
            contact_by[b] += 1
    for created_at in assessment_rows:
        b = get_bucket(created_at)
        if b in bucket_set: assess_by[b] += 1

    # Order counts by product type
    product_by = defaultdict(int); service_by = defaultdict(int); sub_by = defaultdict(int)
    try:
        from apps.ecommerce.models import Order, OrderItem
        product_oids = set(OrderItem.objects.filter(product_type__in=['product', 'domain', 'hosting']).values_list('order_id', flat=True))
        service_oids = set(OrderItem.objects.filter(product_type='service').values_list('order_id', flat=True))
        sub_oids     = set(OrderItem.objects.filter(product_type='subscription').values_list('order_id', flat=True))
        for order in Order.objects.filter(created_at__gte=start_dt).values_list('id', 'created_at'):
            oid, cat = order[0], order[1]
            b = get_bucket(cat)
            if b in bucket_set:
                if oid in product_oids: product_by[b] += 1
                if oid in service_oids: service_by[b] += 1
                if oid in sub_oids:     sub_by[b]     += 1
    except Exception:
        pass

    return JR({
        'labels':        labels,
        'products':      [product_by.get(b, 0)  for b in buckets],
        'services':      [service_by.get(b, 0)  for b in buckets],
        'subscriptions': [sub_by.get(b, 0)      for b in buckets],
        'assessments':   [assess_by.get(b, 0)   for b in buckets],
        'contact':       [contact_by.get(b, 0)  for b in buckets],
    })


# ─── CRM ─────────────────────────────────────────────────────────────────────

@cms_required
@module_required('crm')
def crm(request):
    """CRM overview — leads, pipeline, clients, enquiries."""
    from decimal import Decimal

    # Pipeline counts
    total_leads = _safe_count(ProjectAssessment.objects.all())
    new_leads   = _safe_count(ProjectAssessment.objects.filter(status='new'))
    reviewed    = _safe_count(ProjectAssessment.objects.filter(status='reviewed'))
    quoted      = _safe_count(ProjectAssessment.objects.filter(status='quoted'))
    won         = _safe_count(ProjectAssessment.objects.filter(status='won'))
    lost        = _safe_count(ProjectAssessment.objects.filter(status='lost'))

    won_revenue = ProjectAssessment.objects.filter(
        status='won', quote_amount__isnull=False
    ).aggregate(total=Sum('quote_amount'))['total'] or Decimal('0')

    pipeline_value = ProjectAssessment.objects.filter(
        status='quoted', quote_amount__isnull=False
    ).aggregate(total=Sum('quote_amount'))['total'] or Decimal('0')

    win_rate = round((won / total_leads * 100) if total_leads else 0, 1)

    # Recent assessments
    recent_assessments = list(ProjectAssessment.objects.order_by('-created_at')[:25])

    # Lead sources chart
    heard_labels, heard_data = [], []
    for val, label in ProjectAssessment.HEARD_FROM_CHOICES:
        cnt = _safe_count(ProjectAssessment.objects.filter(heard_from=val))
        if cnt:
            heard_labels.append(label)
            heard_data.append(cnt)

    # Service type breakdown
    svc_labels, svc_data = [], []
    for val, label in ProjectAssessment.SERVICE_CHOICES:
        cnt = _safe_count(ProjectAssessment.objects.filter(service_type__contains=val))
        if cnt:
            svc_labels.append(label.replace(' / ', '/').replace('(', '').replace(')',''))
            svc_data.append(cnt)

    # Clients from paid orders + all recent transactions
    try:
        from apps.ecommerce.models import Order, OrderItem
        # All statuses that represent money received (exclude pending/abandoned/cancelled)
        paid_statuses_crm    = ['paid', 'completed', 'processing']
        money_statuses_crm   = ['paid', 'completed', 'processing', 'deposit_paid']
        # Show ALL orders in the clients list so nothing is hidden
        paid_orders          = Order.objects.order_by('-created_at')[:15]
        total_clients        = Order.objects.filter(status__in=paid_statuses_crm).count()
        # Sum the order totals, not amount_paid. A few historic rows carry an
        # amount_paid larger than the order total, which inflated this figure
        # and made it disagree with Total Cleared everywhere else.
        total_revenue        = Order.objects.filter(status__in=paid_statuses_crm).aggregate(t=Sum('total'))['t'] or Decimal('0')
        # Show all non-cancelled orders so nothing is hidden
        recent_transactions  = Order.objects.exclude(status='cancelled').order_by('-created_at')[:12]

        # Revenue split by product type — sum OrderItem.total_price (not full order amount)
        # so mixed-type orders don't double-count across categories
        paid_order_ids = set(Order.objects.filter(status__in=paid_statuses_crm).values_list('id', flat=True))

        def _revenue_for_types(ptypes):
            return OrderItem.objects.filter(
                product_type__in=ptypes, order_id__in=paid_order_ids
            ).aggregate(t=Sum('total_price'))['t'] or Decimal('0')

        crm_product_revenue  = _revenue_for_types(['product', 'domain', 'hosting'])
        crm_service_revenue  = _revenue_for_types(['service'])

        # Subscriptions
        _sub_ids    = OrderItem.objects.filter(product_type='subscription').values_list('order_id', flat=True).distinct()
        _sub_orders = Order.objects.filter(id__in=_sub_ids, status__in=paid_statuses_crm)
        crm_sub_revenue         = OrderItem.objects.filter(
            product_type='subscription', order_id__in=paid_order_ids
        ).aggregate(t=Sum('total_price'))['t'] or Decimal('0')
        crm_sub_count           = _sub_orders.count()
        crm_recent_subscriptions = list(_sub_orders.order_by('-created_at')[:7])

        # Instalments (deposit_paid = balance still outstanding)
        _inst_orders                = Order.objects.filter(status='deposit_paid').order_by('-created_at')
        crm_instalment_collected    = _inst_orders.aggregate(t=Sum('amount_paid'))['t'] or Decimal('0')
        crm_instalment_outstanding  = sum(o.balance_due for o in _inst_orders)
        crm_instalment_count        = _inst_orders.count()
        crm_recent_instalments      = list(_inst_orders[:7])

        # Total revenue = sum of all typed item revenues (consistent with the boxes above)
        crm_total_revenue = crm_product_revenue + crm_service_revenue + crm_sub_revenue

        # Actual Stripe fees; fall back to estimate for orders paid before stripe_fee was tracked
        _stripe_actual = Order.objects.filter(
            id__in=paid_order_ids
        ).aggregate(t=Sum('stripe_fee'))['t'] or Decimal('0')
        if _stripe_actual > 0:
            crm_stripe_fees = _stripe_actual
        else:
            crm_stripe_fees = (crm_total_revenue * Decimal('0.015') +
                               Decimal('0.20') * len(paid_order_ids)).quantize(Decimal('0.01'))

        # Total expenses
        from apps.ecommerce.models import Expense as _Exp
        crm_expenses    = _Exp.objects.aggregate(t=Sum('amount'))['t'] or Decimal('0')
        crm_total_costs = (crm_expenses + crm_stripe_fees).quantize(Decimal('0.01'))
        crm_net_profit  = (crm_total_revenue - crm_total_costs).quantize(Decimal('0.01'))

    except Exception:
        paid_orders, total_clients, total_revenue, recent_transactions = [], 0, Decimal('0'), []
        crm_product_revenue = crm_service_revenue = crm_sub_revenue = Decimal('0')
        crm_instalment_collected = crm_instalment_outstanding = Decimal('0')
        crm_sub_count = crm_instalment_count = 0
        crm_recent_instalments = []
        crm_recent_subscriptions = []
        crm_total_revenue = crm_expenses = crm_stripe_fees = crm_total_costs = crm_net_profit = Decimal('0')

    # Recent tickets
    recent_tickets = list(Ticket.objects.order_by('-created_at')[:8])

    # Sales by location (derived from phone country code)
    _CC_MAP = {
        '1':'USA / Canada','44':'United Kingdom','61':'Australia','64':'New Zealand',
        '49':'Germany','33':'France','34':'Spain','39':'Italy','31':'Netherlands',
        '32':'Belgium','41':'Switzerland','43':'Austria','46':'Sweden','47':'Norway',
        '45':'Denmark','358':'Finland','353':'Ireland','48':'Poland',
        '36':'Hungary','40':'Romania','30':'Greece','351':'Portugal','7':'Russia',
        '380':'Ukraine','90':'Turkey','972':'Israel','971':'UAE','966':'Saudi Arabia',
        '965':'Kuwait','974':'Qatar','973':'Bahrain','968':'Oman','962':'Jordan',
        '961':'Lebanon','20':'Egypt','212':'Morocco','27':'South Africa',
        '91':'India','92':'Pakistan','86':'China','81':'Japan','82':'South Korea',
        '852':'Hong Kong','65':'Singapore','60':'Malaysia','62':'Indonesia',
        '63':'Philippines','66':'Thailand','84':'Vietnam','55':'Brazil','52':'Mexico',
        '54':'Argentina','57':'Colombia','56':'Chile','98':'Iran',
    }
    def _parse_country(phone):
        s = ''.join(c for c in (phone or '') if c.isdigit() or c == '+')
        if s.startswith('+'):
            d = s[1:]
            for n in (3, 2, 1):
                if d[:n] in _CC_MAP:
                    return _CC_MAP[d[:n]]
        return None

    from collections import Counter as _Ctr
    _loc_ctr = _Ctr()
    for _ph in ProjectAssessment.objects.exclude(phone='').values_list('phone', flat=True):
        _c = _parse_country(_ph)
        if _c:
            _loc_ctr[_c] += 1
    loc_items  = _loc_ctr.most_common(8)
    loc_labels = json.dumps([x[0] for x in loc_items])
    loc_data   = json.dumps([x[1] for x in loc_items])
    loc_max    = loc_items[0][1] if loc_items else 1

    # Monthly lead trend (last 6 months)
    trend_months, trend_new, trend_won = [], [], []
    today_ = date.today()
    y_, m_ = today_.year, today_.month
    for _ in range(5, -1, -1):
        mo_m = m_ - _
        mo_y = y_
        while mo_m <= 0: mo_m += 12; mo_y -= 1
        mo_start = date(mo_y, mo_m, 1)
        mo_end   = date(mo_y, mo_m, 28) + timedelta(days=4)
        mo_end   = mo_end.replace(day=1) - timedelta(days=1)
        trend_months.append(mo_start.strftime('%b'))
        trend_new.append(_safe_count(ProjectAssessment.objects.filter(created_at__date__gte=mo_start, created_at__date__lte=mo_end)))
        trend_won.append(_safe_count(ProjectAssessment.objects.filter(status='won', updated_at__date__gte=mo_start, updated_at__date__lte=mo_end)))

    # Monthly revenue + expenses for Balance Overview (12 months)
    crm_months_l, crm_rev_l, crm_exp_l = [], [], []
    for _i in range(11, -1, -1):
        mo_m = m_ - _i; mo_y = y_
        while mo_m <= 0: mo_m += 12; mo_y -= 1
        mo_start = date(mo_y, mo_m, 1)
        mo_end   = (date(mo_y, mo_m, 28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
        crm_months_l.append(mo_start.strftime('%b %y'))
        try:
            from apps.ecommerce.models import Order as _OB
            # Same rule as the headline figure above: sum the order total, over
            # the same three statuses. This summed amount_paid over two
            # statuses, so a July row carrying 2.00 paid against a 1.00 order
            # made the trend read 3.00 while the headline read 2.00. Two
            # numbers on one page disagreeing is worse than either being wrong.
            mo_rev = _OB.objects.filter(status__in=['paid', 'completed', 'processing'], created_at__date__gte=mo_start, created_at__date__lte=mo_end).aggregate(t=Sum('total'))['t'] or 0
            crm_rev_l.append(float(mo_rev))
        except Exception:
            crm_rev_l.append(0)
        try:
            from apps.ecommerce.models import Expense as _EB
            mo_exp = _EB.objects.filter(date__gte=mo_start, date__lte=mo_end).aggregate(t=Sum('amount'))['t'] or 0
            crm_exp_l.append(float(mo_exp))
        except Exception:
            crm_exp_l.append(0)

    crm_profit_ratio = round(float(crm_net_profit) / float(total_revenue) * 100, 1) if total_revenue else 0

    # Deal Type radar: per heard_from source — Won / Pending / Lost
    radar_labels, radar_won_d, radar_pend_d, radar_lost_d = [], [], [], []
    for _val, _lbl in ProjectAssessment.HEARD_FROM_CHOICES:
        _tot = _safe_count(ProjectAssessment.objects.filter(heard_from=_val))
        if _tot:
            radar_labels.append(_lbl.split('/')[0].split('(')[0].strip())
            radar_won_d.append(_safe_count(ProjectAssessment.objects.filter(heard_from=_val, status='won')))
            radar_pend_d.append(_safe_count(ProjectAssessment.objects.filter(heard_from=_val, status__in=['new','reviewed','quoted'])))
            radar_lost_d.append(_safe_count(ProjectAssessment.objects.filter(heard_from=_val, status='lost')))

    context = {
        'total_leads': total_leads, 'new_leads': new_leads, 'reviewed': reviewed,
        'quoted': quoted, 'won': won, 'lost': lost, 'win_rate': win_rate,
        'won_revenue': won_revenue, 'pipeline_value': pipeline_value,
        'recent_assessments': recent_assessments,
        'heard_labels': json.dumps(heard_labels), 'heard_data': json.dumps(heard_data),
        'svc_labels': json.dumps(svc_labels), 'svc_data': json.dumps(svc_data),
        'funnel_labels': json.dumps(['New', 'Reviewed', 'Quoted', 'Won']),
        'funnel_data': json.dumps([total_leads, reviewed + quoted + won, quoted + won, won]),
        'trend_months': json.dumps(trend_months), 'trend_new': json.dumps(trend_new), 'trend_won': json.dumps(trend_won),
        'paid_orders': paid_orders, 'total_clients': total_clients, 'total_revenue': total_revenue,
        'recent_tickets': recent_tickets,
        'recent_transactions': recent_transactions,
        'loc_labels': loc_labels, 'loc_data': loc_data, 'loc_items': loc_items, 'loc_max': loc_max,
        'crm_product_revenue': crm_product_revenue,
        'crm_service_revenue': crm_service_revenue,
        'crm_sub_revenue': crm_sub_revenue, 'crm_sub_count': crm_sub_count,
        'crm_recent_subscriptions': crm_recent_subscriptions,
        'crm_instalment_collected': crm_instalment_collected,
        'crm_instalment_outstanding': crm_instalment_outstanding,
        'crm_instalment_count': crm_instalment_count,
        'crm_recent_instalments': crm_recent_instalments,
        'crm_total_revenue': crm_total_revenue,
        'crm_expenses': crm_expenses,
        'crm_stripe_fees': crm_stripe_fees, 'crm_total_costs': crm_total_costs, 'crm_net_profit': crm_net_profit,
        'crm_months_labels': json.dumps(crm_months_l),
        'crm_rev_series': json.dumps(crm_rev_l),
        'crm_exp_series': json.dumps(crm_exp_l),
        'crm_profit_ratio': crm_profit_ratio,
        'radar_labels': json.dumps(radar_labels),
        'radar_won': json.dumps(radar_won_d),
        'radar_pend': json.dumps(radar_pend_d),
        'radar_lost': json.dumps(radar_lost_d),
        'stripe_status': _get_stripe_status(getattr(request, 'tenant', None)),
        'stripe_payment_errors': _get_stripe_payment_errors(),
    }
    # Total Cleared, This Month, Deposits and Outstanding moved here from the
    # dashboard, so CRM is the single place money is reported.
    context.update(_get_payments_summary())
    return render(request, 'cms/crm.html', context)


@cms_required
def crm_lead_trend_json(request):
    """Return lead trend data (New vs Won) for a given period."""
    from django.http import JsonResponse as JR
    from collections import defaultdict

    period = request.GET.get('period', '6m')
    days   = {'1w': 7, '1m': 30, '3m': 90, '6m': 180, '1y': 365}.get(period, 180)
    today_ = date.today()
    start_date = today_ - timedelta(days=days - 1)
    start_dt   = timezone.make_aware(
        timezone.datetime.combine(start_date, timezone.datetime.min.time())
    )

    # Choose bucket granularity
    if days <= 30:
        buckets = [start_date + timedelta(days=i) for i in range(days)]
        labels  = [b.strftime('%d %b') for b in buckets]
        def get_bucket(dt): return dt.date()
    elif days <= 180:
        num_weeks = (days + 6) // 7
        buckets   = [start_date + timedelta(days=i * 7) for i in range(num_weeks)]
        labels    = [b.strftime('%d %b') for b in buckets]
        def get_bucket(dt):
            d = dt.date()
            n = (d - start_date).days // 7
            return start_date + timedelta(days=n * 7)
    else:
        months, y, m = [], start_date.year, start_date.month
        while date(y, m, 1) <= today_:
            months.append(date(y, m, 1))
            m += 1
            if m > 12: m, y = 1, y + 1
        buckets = months
        labels  = [b.strftime('%b %Y') for b in buckets]
        def get_bucket(dt):
            d = dt.date()
            return date(d.year, d.month, 1)

    bucket_set = set(buckets)
    new_by = defaultdict(int); won_by = defaultdict(int)

    for created_at, in ProjectAssessment.objects.filter(created_at__gte=start_dt).values_list('created_at'):
        b = get_bucket(created_at)
        if b in bucket_set: new_by[b] += 1

    for updated_at, in ProjectAssessment.objects.filter(status='won', updated_at__gte=start_dt).values_list('updated_at'):
        b = get_bucket(updated_at)
        if b in bucket_set: won_by[b] += 1

    return JR({
        'labels': labels,
        'new':    [new_by.get(b, 0) for b in buckets],
        'won':    [won_by.get(b, 0) for b in buckets],
    })


# ─── Analytics ───────────────────────────────────────────────────────────────

@cms_required
@module_required('analytics')
def analytics(request):
    """Analytics overview — GA traffic + internal conversion stats."""
    from .ga_utils import get_ga_dashboard_data
    ga = get_ga_dashboard_data(days=30)
    pages_per_session = round(ga['total_pageviews'] / ga['total_sessions'], 1) if ga.get('total_sessions') else 0

    # ── Internal stats ────────────────────────────────────────────────────────
    total_chat    = _safe_count(ChatSession.objects.all())
    active_chat   = _safe_count(ChatSession.objects.filter(status='active'))
    total_assess  = _safe_count(ProjectAssessment.objects.all())
    won_assess    = _safe_count(ProjectAssessment.objects.filter(status='won'))
    total_tickets = _safe_count(Ticket.objects.all())

    # ── Ecommerce stats ───────────────────────────────────────────────────────
    _paid = ['paid', 'completed', 'deposit_paid']
    try:
        from apps.ecommerce.models import Order as _Ord
        an_clients       = _Ord.objects.filter(status__in=_paid).values('customer_email').distinct().count()
        an_sub_count     = _Ord.objects.filter(
            status__in=['paid', 'completed'],
            items__product__category__category_type='subscription',
        ).values('id').distinct().count()
        an_total_revenue = _Ord.objects.filter(status__in=_paid).aggregate(t=Sum('total_amount'))['t'] or 0
        _ord_ok = True
    except Exception:
        an_clients = 0; an_sub_count = 0; an_total_revenue = 0; _ord_ok = False

    # ── Monthly trends (12 months) ─────────────────────────────────────────────
    months_l, tickets_l, revenue_l = [], [], []
    today_ = date.today()
    y_, m_  = today_.year, today_.month
    for i in range(11, -1, -1):
        mo_m = m_ - i; mo_y = y_
        while mo_m <= 0: mo_m += 12; mo_y -= 1
        mo_start = date(mo_y, mo_m, 1)
        mo_end   = (date(mo_y, mo_m, 28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
        months_l.append(mo_start.strftime('%b %y'))
        tickets_l.append(_safe_count(Ticket.objects.filter(created_at__date__gte=mo_start, created_at__date__lte=mo_end)))
        if _ord_ok:
            mo_rev = _Ord.objects.filter(
                status__in=_paid,
                created_at__date__gte=mo_start,
                created_at__date__lte=mo_end,
            ).aggregate(t=Sum('total_amount'))['t'] or 0
            revenue_l.append(float(mo_rev))
        else:
            revenue_l.append(0)

    # ── Ticket series for period selector ─────────────────────────────────────
    def _t_daily(days):
        lbl, dat = [], []
        for i in range(days, -1, -1):
            d = today_ - timedelta(days=i)
            lbl.append(d.strftime('%-d %b'))
            dat.append(_safe_count(Ticket.objects.filter(created_at__date=d)))
        return lbl, dat

    def _t_weekly(weeks):
        lbl, dat = [], []
        for i in range(weeks, 0, -1):
            s = today_ - timedelta(weeks=i)
            e = s + timedelta(days=6)
            lbl.append(s.strftime('%-d %b'))
            dat.append(_safe_count(Ticket.objects.filter(created_at__date__gte=s, created_at__date__lte=e)))
        return lbl, dat

    def _t_monthly(mos):
        lbl, dat = [], []
        for i in range(mos - 1, -1, -1):
            mo_m = m_ - i; mo_y = y_
            while mo_m <= 0: mo_m += 12; mo_y -= 1
            s = date(mo_y, mo_m, 1)
            e = (date(mo_y, mo_m, 28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
            lbl.append(s.strftime('%b %y'))
            dat.append(_safe_count(Ticket.objects.filter(created_at__date__gte=s, created_at__date__lte=e)))
        return lbl, dat

    t1w_l,  t1w_d  = _t_daily(6)
    t1m_l,  t1m_d  = _t_daily(29)
    t3m_l,  t3m_d  = _t_weekly(13)
    t6m_l,  t6m_d  = _t_monthly(6)

    # ── Ticket source breakdown ───────────────────────────────────────────────
    source_labels, source_data = [], []
    for val, label in Ticket.SOURCE_CHOICES:
        cnt = _safe_count(Ticket.objects.filter(source=val))
        if cnt:
            source_labels.append(label)
            source_data.append(cnt)

    # ── Insights ──────────────────────────────────────────────────────────────
    recent_insights  = list(Insight.objects.filter(status='published').order_by('-published_at')[:5])
    popular_insights = list(Insight.objects.filter(status='published').order_by('-view_count', '-published_at')[:5])

    # ── Sales by locations (from order phone country codes) ───────────────────
    try:
        import re
        from collections import Counter
        _CC = {
            '44': 'United Kingdom', '1': 'United States', '33': 'France', '49': 'Germany',
            '971': 'UAE', '966': 'Saudi Arabia', '974': 'Qatar', '965': 'Kuwait',
            '968': 'Oman', '973': 'Bahrain', '20': 'Egypt', '212': 'Morocco',
            '90': 'Turkey', '91': 'India', '92': 'Pakistan', '60': 'Malaysia',
            '62': 'Indonesia', '81': 'Japan', '86': 'China', '234': 'Nigeria',
            '27': 'South Africa', '55': 'Brazil', '52': 'Mexico',
            '34': 'Spain', '39': 'Italy', '31': 'Netherlands',
        }
        _ctr = Counter()
        from apps.ecommerce.models import Order as _OL
        for ph in _OL.objects.filter(status__in=_paid).values_list('customer_phone', flat=True):
            if not ph:
                continue
            m = re.match(r'^\+(\d{1,3})', ph.replace(' ', ''))
            if m:
                c = _CC.get(m.group(1))
                if c:
                    _ctr[c] += 1
        an_loc_items = _ctr.most_common(5)
        an_loc_max   = an_loc_items[0][1] if an_loc_items else 1
    except Exception:
        an_loc_items = []
        an_loc_max   = 1

    # How They Found Us — heard_from breakdown
    heard_labels, heard_data = [], []
    for _val, _lbl in ProjectAssessment.HEARD_FROM_CHOICES:
        _c = _safe_count(ProjectAssessment.objects.filter(heard_from=_val))
        if _c:
            heard_labels.append(_lbl.split('/')[0].split('(')[0].strip())
            heard_data.append(_c)

    context = {
        **ga,
        'total_chat': total_chat,    'active_chat': active_chat,
        'total_assess': total_assess, 'won_assess': won_assess,
        'total_tickets': total_tickets,
        'months_labels':  json.dumps(months_l),
        'months_tickets': json.dumps(tickets_l),
        'months_revenue': json.dumps(revenue_l),
        # Period-selector ticket series
        't1w_labels': json.dumps(t1w_l),  't1w_data': json.dumps(t1w_d),
        't1m_labels': json.dumps(t1m_l),  't1m_data': json.dumps(t1m_d),
        't3m_labels': json.dumps(t3m_l),  't3m_data': json.dumps(t3m_d),
        't6m_labels': json.dumps(t6m_l),  't6m_data': json.dumps(t6m_d),
        'source_labels':  json.dumps(source_labels),
        'source_data':    json.dumps(source_data),
        'conversion_rate': round((won_assess / total_assess * 100) if total_assess else 0, 1),
        'chat_rate':       round((active_chat / total_chat * 100) if total_chat else 0, 1),
        'pages_per_session': pages_per_session,
        'an_clients':       an_clients,
        'an_sub_count':     an_sub_count,
        'an_total_revenue': an_total_revenue,
        'recent_insights':  recent_insights,
        'popular_insights': popular_insights,
        'an_loc_items':     an_loc_items,
        'an_loc_max':       an_loc_max,
        'heard_labels':     json.dumps(heard_labels),
        'heard_data':       json.dumps(heard_data),
    }
    return render(request, 'cms/analytics.html', context)


# ─── Tenant resolver ─────────────────────────────────────────────────────────

def _resolve_tenant(request):
    """Return a real Tenant DB instance for the current request.

    In dev, TenantMiddleware attaches a placeholder with pk=0. The shared
    resolver in apps/tenants/resolve.py swaps that for the real row, and
    refuses to guess when an install holds more than one tenant.
    """
    from apps.tenants.resolve import resolve_tenant
    return resolve_tenant(request)


# ─── Homepage Editor ──────────────────────────────────────────────────────────

# ALT text is capped at 125 characters everywhere. Screen readers cut longer
# text off mid sentence and search engines ignore the tail, so a longer one is
# not a better one. The form stops at 125 too; this is the half that survives a
# bypassed browser.
ALT_MAX = 125


def _alt(request, field):
    return request.POST.get(field, '').strip()[:ALT_MAX]

def _hp_save(obj, request):
    """Set tenant FK on new singleton objects before saving.
    When no master tenant exists yet, reuse the first null-tenant row
    so repeated saves update the same record instead of creating duplicates.
    """
    if not obj.pk:
        tenant = _resolve_tenant(request)
        if tenant:
            obj.tenant = tenant
        else:
            # No master tenant yet — prevent duplicate null-tenant rows
            existing = type(obj).objects.filter(tenant__isnull=True).first()
            if existing:
                obj.pk = existing.pk
    obj.save()


@cms_required
def homepage_editor(request):
    tenant = _resolve_tenant(request)
    email_templates = _email_templates_for(tenant)
    return render(request, 'cms/homepage/index.html', {
        'hero':            HomeHero.load(tenant),
        'about':           HomeAbout.load(tenant),
        'cta1':            HomeCTA1.load(tenant),
        'cta2':            HomeCTA2.load(tenant),
        'process':         HomeProcess.load(tenant),
        'why':             HomeWhyChooseUs.load(tenant),
        'email_templates': email_templates,
    })


@admin_required
def edit_hero(request):
    tenant = _resolve_tenant(request)
    obj = HomeHero.load(tenant)
    if request.method == 'POST':
        for f in ['heading_line1', 'heading_accent', 'heading_line2', 'description',
                  'trusted_clients_count', 'trusted_clients_label', 'video_url',
                  'play_btn_url', 'portfolio_btn_text', 'badge_1', 'badge_2', 'badge_3']:
            if f in request.POST:
                setattr(obj, f, request.POST[f])
        if 'video' in request.FILES:
            obj.video = request.FILES['video']
        _hp_save(obj, request)
        messages.success(request, 'Hero section saved.')
        return redirect(reverse('cms_homepage') + '#content-sections')
    return render(request, 'cms/homepage/hero.html', {'obj': obj})


@admin_required
def edit_about(request):
    tenant = _resolve_tenant(request)
    obj = HomeAbout.load(tenant)
    if request.method == 'POST':
        for f in ['heading', 'heading_em', 'body', 'card_number', 'card_label',
                  'card_sub', 'section_tag', 'btn_text', 'btn_url']:
            setattr(obj, f, request.POST.get(f, getattr(obj, f, '')))

        # The crop dialog hands back a data URL rather than a file, the same
        # way the product editor does. It wins when present, because a file is
        # always posted alongside it and that one is the uncropped original.
        # With JavaScript off nothing sets image_b64 and the plain upload is
        # used exactly as before.
        image_b64 = request.POST.get('image_b64', '').strip()
        if image_b64:
            try:
                webp_file, _w, _h = _convert_to_webp(image_b64, 'about')
                obj.image = webp_file
            except Exception:
                if 'image' in request.FILES:
                    obj.image = request.FILES['image']
        elif 'image' in request.FILES:
            obj.image = request.FILES['image']
        _hp_save(obj, request)
        messages.success(request, 'About section saved.')
        return redirect(reverse('cms_homepage') + '#content-sections')
    from django.conf import settings as _s
    ck_config = json.dumps(_s.CKEDITOR_5_CONFIGS.get('extended', {}))
    return render(request, 'cms/homepage/about.html', {'obj': obj, 'ck_config': ck_config})


@admin_required
def edit_founder(request):
    """The message from the founder. Two images, both cropped the same way the
    About image is, so the photo and the signature keep their shapes."""
    from apps.pages.models import HomeFounder
    tenant = _resolve_tenant(request)
    obj = HomeFounder.load(tenant)
    if request.method == 'POST':
        for f in ['section_tag', 'heading', 'heading_em', 'quote', 'body',
                  'photo_alt', 'signature_alt']:
            setattr(obj, f, request.POST.get(f, getattr(obj, f, '')))
        obj.photo_alt = obj.photo_alt[:ALT_MAX]
        obj.signature_alt = obj.signature_alt[:ALT_MAX]
        obj.is_active = 'is_active' in request.POST

        # Remove on the Media card posts a flag, so nothing goes until saved.
        for field in ('photo', 'signature'):
            if request.POST.get('clear_' + field) == '1' and getattr(obj, field):
                getattr(obj, field).delete(save=False)
                setattr(obj, field, None)

        # Same rule as edit_about: the crop dialog posts a data URL and it wins,
        # because the plain file alongside it is the uncropped original.
        for field, name in (('photo', 'founder'), ('signature', 'signature')):
            b64 = request.POST.get(field + '_b64', '').strip()
            if b64:
                try:
                    webp_file, _w, _h = _convert_to_webp(b64, name)
                    setattr(obj, field, webp_file)
                    continue
                except Exception:
                    pass
            if field in request.FILES:
                setattr(obj, field, request.FILES[field])

        _hp_save(obj, request)
        messages.success(request, 'Founder section saved.')
        return redirect(reverse('cms_homepage') + '#content-sections')
    from django.conf import settings as _s
    ck_config = json.dumps(_s.CKEDITOR_5_CONFIGS.get('extended', {}))
    return render(request, 'cms/homepage/founder.html', {'obj': obj, 'ck_config': ck_config})

@admin_required
def edit_services_header(request):
    from apps.pages.models import HomeServices
    tenant = _resolve_tenant(request)
    obj = HomeServices.load(tenant)
    if request.method == 'POST':
        for f in ['section_tag', 'heading', 'heading_em', 'subtitle']:
            setattr(obj, f, request.POST.get(f, getattr(obj, f, '')))
        _hp_save(obj, request)
        messages.success(request, 'Services header saved.')
        return redirect(reverse('cms_homepage') + '#content-sections')
    return render(request, 'cms/homepage/services_header.html', {'obj': obj})


@admin_required
def edit_process(request):
    tenant = _resolve_tenant(request)
    obj = HomeProcess.load(tenant)
    if request.method == 'POST':
        fields = ['heading', 'sub_label',
                  'step1_title', 'step1_body', 'step2_title', 'step2_body',
                  'step3_title', 'step3_body']
        for f in fields:
            setattr(obj, f, request.POST.get(f, getattr(obj, f, '')))
        for i in [1, 2, 3]:
            key = f'step{i}_icon'
            if key in request.FILES:
                setattr(obj, key, request.FILES[key])
        _hp_save(obj, request)
        messages.success(request, 'Process section saved.')
        return redirect(reverse('cms_homepage') + '#content-sections')
    return render(request, 'cms/homepage/process.html', {'obj': obj})


@admin_required
def edit_why(request):
    tenant = _resolve_tenant(request)
    obj = HomeWhyChooseUs.load(tenant)
    if request.method == 'POST':
        # The rating and satisfaction fields are no longer on the form. They
        # stay in the list because the default below is the stored value, so
        # anything already written keeps its content rather than being wiped
        # on the next save.
        fields = ['heading', 'heading_em', 'sub_label', 'body',
                  'rating_score', 'rating_label', 'rating_body',
                  'satisfied_pct', 'satisfied_label', 'satisfied_body']
        for f in fields:
            setattr(obj, f, request.POST.get(f, getattr(obj, f, '')))
        if 'image' in request.FILES:
            obj.image = request.FILES['image']
        _hp_save(obj, request)
        messages.success(request, 'Why Choose Us saved.')
        return redirect(reverse('cms_homepage') + '#content-sections')
    return render(request, 'cms/homepage/why.html', {'obj': obj})


@admin_required
def edit_cta1(request):
    tenant = _resolve_tenant(request)
    obj = HomeCTA1.load(tenant)
    if request.method == 'POST':
        for f in ['word1', 'word2', 'word3', 'body', 'btn_text', 'btn_url']:
            setattr(obj, f, request.POST.get(f, getattr(obj, f, '')))
        _hp_save(obj, request)
        messages.success(request, 'CTA 1 saved.')
        return redirect(reverse('cms_homepage') + '#cta-sections')
    return render(request, 'cms/homepage/cta.html', {'obj': obj, 'cta_num': 1})


@admin_required
def edit_cta2(request):
    tenant = _resolve_tenant(request)
    obj = HomeCTA2.load(tenant)
    if request.method == 'POST':
        for f in ['heading', 'body', 'btn_text', 'btn_url']:
            setattr(obj, f, request.POST.get(f, getattr(obj, f, '')))
        _hp_save(obj, request)
        messages.success(request, 'CTA 2 saved.')
        return redirect(reverse('cms_homepage') + '#cta-sections')
    return render(request, 'cms/homepage/cta.html', {'obj': obj, 'cta_num': 2})


@admin_required
def edit_testimonials_section(request):
    tenant = _resolve_tenant(request)
    obj = HomeTestimonials.load(tenant)
    if request.method == 'POST':
        for f in ['heading', 'body', 'google_review_url']:
            setattr(obj, f, request.POST.get(f, getattr(obj, f, '')))
        if 'image' in request.FILES:
            obj.image = request.FILES['image']
        _hp_save(obj, request)
        messages.success(request, 'Testimonials section saved.')
        return redirect(reverse('cms_homepage') + '#content-sections')
    return render(request, 'cms/homepage/testimonials_section.html', {'obj': obj})


# ─── Services ─────────────────────────────────────────────────────────────────

@cms_required
def services_list(request):
    q      = request.GET.get('q', '').strip()
    cat_f  = request.GET.get('cat', '').strip()
    tenant = _resolve_tenant(request)
    services = Service.objects.select_related('category').filter(
        Q(tenant=tenant) | Q(tenant__isnull=True)
    ).order_by('order')
    total = services.count()
    if q:
        services = services.filter(Q(title__icontains=q) | Q(description__icontains=q))
    if cat_f:
        services = services.filter(category_id=cat_f)
    categories = ServiceCategory.objects.filter(
        Q(tenant=tenant) | Q(tenant__isnull=True)
    ).annotate(item_count=Count('services')).order_by('order')
    from django.core.paginator import Paginator
    paginator = Paginator(services, 20)
    page_obj  = paginator.get_page(request.GET.get('page'))
    _fqs = request.GET.copy(); _fqs.pop('page', None)
    return render(request, 'cms/services/list.html', {
        'services': page_obj,
        'page_obj': page_obj,
        'categories': categories,
        'q': q,
        'cat_f': cat_f,
        'total': total,
        'filter_qs': _fqs.urlencode(),
    })


@admin_required
def service_edit(request, pk=None):
    obj = get_object_or_404(Service, pk=pk) if pk else Service()
    categories = ServiceCategory.objects.all()
    if request.method == 'POST':
        obj.title = request.POST.get('title', '')
        obj.description = request.POST.get('description', '')
        obj.body = request.POST.get('body', '')
        obj.is_active = 'is_active' in request.POST
        obj.show_on_home = 'show_on_home' in request.POST
        obj.service_page_active = 'service_page_active' in request.POST
        obj.slug = request.POST.get('slug', '')
        raw_link = request.POST.get('link', '').strip()
        obj.link = raw_link if raw_link else '#services'
        if 'icon_class' in request.POST:
            obj.icon_class = request.POST.get('icon_class', '')
        cat_id = request.POST.get('category')
        obj.category_id = int(cat_id) if cat_id else None
        obj.meta_title       = request.POST.get('meta_title', '').strip()
        obj.meta_description = request.POST.get('meta_description', '').strip()
        obj.focus_keyword    = request.POST.get('focus_keyword', '').strip()
        obj.canonical_url    = request.POST.get('canonical_url', '').strip()
        obj.og_image_url     = request.POST.get('og_image_url', '').strip()
        obj.target_region    = request.POST.get('target_region', 'global').strip()
        if request.POST.get('clear_icon') == '1':
            obj.icon = None
        elif 'icon' in request.FILES:
            obj.icon = request.FILES['icon']
        if not obj.slug:
            from django.utils.text import slugify
            obj.slug = slugify(obj.title)
        missing = []
        if not obj.title:
            missing.append('title')
        if not obj.category_id:
            missing.append('category')
        if missing:
            messages.error(request, 'Please fill in the required fields: ' + ', '.join(missing) + '.')
        else:
            obj.save()
            messages.success(request, 'Service saved.')
            return redirect('cms_services')
    from django.conf import settings as _settings
    ck_config = json.dumps(_settings.CKEDITOR_5_CONFIGS.get('extended', {}))
    return render(request, 'cms/services/edit.html', {'obj': obj, 'categories': categories, 'ck_config': ck_config})


@admin_required
def service_delete(request, pk):
    obj = get_object_or_404(Service, pk=pk)
    if request.method == 'POST':
        obj.delete()
        messages.success(request, 'Service deleted.')
    return redirect('cms_services')


@admin_required
def service_toggle_visibility(request, pk):
    obj = get_object_or_404(Service, pk=pk)
    obj.is_active = not obj.is_active
    obj.save(update_fields=['is_active'])
    return redirect('cms_services')


@admin_required
def service_toggle_home(request, pk):
    obj = get_object_or_404(Service, pk=pk)
    obj.show_on_home = not obj.show_on_home
    obj.save(update_fields=['show_on_home'])
    return redirect('cms_services')


@admin_required
def service_category_edit(request, pk=None):
    from django.utils.text import slugify
    obj = get_object_or_404(ServiceCategory, pk=pk) if pk else ServiceCategory()
    if request.method == 'POST':
        obj.name       = request.POST.get('name', '').strip()
        obj.icon_class = request.POST.get('icon_class', '').strip()
        obj.order      = int(request.POST.get('order', 1))
        if not obj.pk:
            obj.slug = slugify(obj.name)
            tenant = _resolve_tenant(request)
            obj.tenant = tenant
        obj.save()
        messages.success(request, 'Category saved.')
        return redirect('cms_services')
    return render(request, 'cms/services/category_edit.html', {'obj': obj})


@admin_required
def service_category_delete(request, pk):
    if request.method == 'POST':
        get_object_or_404(ServiceCategory, pk=pk).delete()
        messages.success(request, 'Category deleted.')
    return redirect('cms_services')


# ─── Why Choose US Boxes ──────────────────────────────────────────────────────

@cms_required
def why_boxes_list(request):
    boxes = WhyChooseUsBox.objects.all()
    return render(request, 'cms/why_boxes/list.html', {'boxes': boxes})


@admin_required
def why_box_edit(request, pk=None):
    obj = get_object_or_404(WhyChooseUsBox, pk=pk) if pk else WhyChooseUsBox()
    if request.method == 'POST':
        obj.title = request.POST.get('title', '')
        obj.body = request.POST.get('body', '')
        obj.is_active = 'is_active' in request.POST

        # No order box on the form. A new card joins the end of the section
        # rather than jumping to the front, and an existing one keeps the
        # position it has.
        if not obj.pk:
            last = WhyChooseUsBox.objects.order_by('-order').first()
            obj.order = (last.order + 1) if last else 1
        if 'icon' in request.FILES:
            obj.icon = request.FILES['icon']
        obj.save()
        messages.success(request, 'Box saved.')
        return redirect('cms_why_boxes')
    return render(request, 'cms/why_boxes/edit.html', {'obj': obj})


@admin_required
def why_box_delete(request, pk):
    get_object_or_404(WhyChooseUsBox, pk=pk).delete()
    messages.success(request, 'Deleted.')
    return redirect('cms_why_boxes')


@admin_required
def why_box_toggle_visibility(request, pk):
    obj = get_object_or_404(WhyChooseUsBox, pk=pk)
    obj.is_active = not obj.is_active
    obj.save(update_fields=['is_active'])
    return redirect('cms_why_boxes')


# ─── Testimonials ─────────────────────────────────────────────────────────────

@cms_required
def testimonials_list(request):
    q = request.GET.get('q', '').strip()
    qs = Testimonial.objects.all()
    if q:
        qs = qs.filter(Q(name__icontains=q) | Q(company__icontains=q) | Q(quote__icontains=q))
    return render(request, 'cms/testimonials/list.html', {
        'testimonials': qs,
        'q': q,
        'total': Testimonial.objects.count(),
    })


@admin_required
def testimonial_toggle_visibility(request, pk):
    t = get_object_or_404(Testimonial, pk=pk)
    Testimonial.objects.filter(pk=pk).update(is_active=not t.is_active)
    return redirect('cms_testimonials')


@admin_required
def testimonial_edit(request, pk=None):
    obj = get_object_or_404(Testimonial, pk=pk) if pk else Testimonial()
    if request.method == 'POST':
        obj.name = request.POST.get('name', '')
        obj.company = request.POST.get('company', '')
        obj.designation = request.POST.get('designation', '')
        obj.quote = request.POST.get('quote', '')
        obj.star_rating = int(request.POST.get('star_rating', 5))
        obj.order = int(request.POST.get('order', 1))
        obj.show_on_home = 'show_on_home' in request.POST
        obj.is_active = 'is_active' in request.POST
        if 'photo' in request.FILES:
            obj.photo = request.FILES['photo']
        missing = []
        if not obj.name.strip():
            missing.append('name')
        if not obj.company.strip():
            missing.append('company')
        if not obj.quote.strip():
            missing.append('quote')
        if missing:
            messages.error(request, 'Please fill in the required fields: ' + ', '.join(missing) + '.')
        else:
            obj.save()
            messages.success(request, 'Testimonial saved.')
            return redirect('cms_testimonials')
    return render(request, 'cms/testimonials/edit.html', {'obj': obj})


@admin_required
def testimonial_delete(request, pk):
    get_object_or_404(Testimonial, pk=pk).delete()
    messages.success(request, 'Deleted.')
    return redirect('cms_testimonials')


# ─── Partners ─────────────────────────────────────────────────────────────────

@cms_required
def partners_list(request):
    q = request.GET.get('q', '').strip()
    type_filter = request.GET.get('type', '')
    qs = PartnerLogo.objects.all()
    if q:
        qs = qs.filter(name__icontains=q)
    if type_filter:
        qs = qs.filter(logo_type=type_filter)
    partner_count = PartnerLogo.objects.filter(logo_type='partner').count()
    client_count = PartnerLogo.objects.filter(logo_type='client').count()
    from django.core.paginator import Paginator
    paginator = Paginator(qs, 20)
    page_obj  = paginator.get_page(request.GET.get('page'))
    _fqs = request.GET.copy(); _fqs.pop('page', None)
    return render(request, 'cms/partners/list.html', {
        'partners': page_obj,
        'page_obj': page_obj,
        'q': q,
        'type_filter': type_filter,
        'partner_count': partner_count,
        'client_count': client_count,
        'filter_qs': _fqs.urlencode(),
    })


@admin_required
def partner_edit(request, pk=None):
    obj = get_object_or_404(PartnerLogo, pk=pk) if pk else PartnerLogo()
    if request.method == 'POST':
        obj.name = request.POST.get('name', '')
        obj.url = request.POST.get('url', '')
        obj.logo_type = request.POST.get('logo_type', 'partner')
        obj.order = int(request.POST.get('order', 1))
        obj.is_active = 'is_active' in request.POST
        if 'logo' in request.FILES:
            obj.logo = request.FILES['logo']
        missing = []
        if not obj.name.strip():
            missing.append('name')
        if not obj.logo_type:
            missing.append('type')
        if not obj.url.strip():
            missing.append('website URL')
        if not obj.logo:
            missing.append('logo image')
        if missing:
            messages.error(request, 'Please fill in the required fields: ' + ', '.join(missing) + '.')
            return render(request, 'cms/partners/edit.html', {'obj': obj})
        obj.save()
        messages.success(request, 'Saved.')
        return redirect('cms_partners')
    return render(request, 'cms/partners/edit.html', {'obj': obj})


@admin_required
def partner_toggle_visibility(request, pk):
    p = get_object_or_404(PartnerLogo, pk=pk)
    PartnerLogo.objects.filter(pk=pk).update(is_active=not p.is_active)
    return redirect('cms_partners')


@admin_required
def partner_delete(request, pk):
    get_object_or_404(PartnerLogo, pk=pk).delete()
    messages.success(request, 'Deleted.')
    return redirect('cms_partners')


# ─── Portfolio ────────────────────────────────────────────────────────────────

@cms_required
@module_required('portfolio')
def portfolio_list(request):
    q     = request.GET.get('q', '').strip()
    cat_f = request.GET.get('cat', '').strip()
    items = PortfolioItem.objects.select_related('category', 'added_by').order_by('title')
    if q:
        items = items.filter(
            Q(title__icontains=q) | Q(short_description__icontains=q) |
            Q(technologies__icontains=q) | Q(category_label__icontains=q)
        )
    if cat_f:
        items = items.filter(category_id=cat_f)
    categories = PortfolioCategory.objects.annotate(item_count=Count('portfolioitem')).order_by('order')
    total = PortfolioItem.objects.count()
    from django.core.paginator import Paginator
    paginator = Paginator(items, 20)
    page_obj  = paginator.get_page(request.GET.get('page'))
    _fqs = request.GET.copy(); _fqs.pop('page', None)
    return render(request, 'cms/portfolio/list.html', {
        'items': page_obj,
        'page_obj': page_obj,
        'categories': categories,
        'q': q,
        'cat_f': cat_f,
        'total': total,
        'filter_qs': _fqs.urlencode(),
    })


@admin_required
def portfolio_toggle_home(request, pk):
    """The green house on the listing. Same one line of state the homepage row
    already reads, so nothing new is stored."""
    obj = get_object_or_404(PortfolioItem, pk=pk)
    obj.show_on_home = not obj.show_on_home
    obj.save(update_fields=['show_on_home'])
    return redirect(request.META.get('HTTP_REFERER') or 'cms_portfolio')

def _pf_ck_config():
    import json as _json
    from django.conf import settings as _s
    return _json.dumps(_s.CKEDITOR_5_CONFIGS.get('extended', {}))


def _thumb_size():
    """Crop size for portfolio thumbnails, read from PORTFOLIO_THUMB in .env."""
    from django.conf import settings as _s
    raw = getattr(_s, 'PORTFOLIO_THUMB', '600x800')
    try:
        w, h = (int(n) for n in str(raw).lower().split('x'))
    except (ValueError, TypeError):
        w, h = 600, 800
    return w, h


@admin_required
def portfolio_edit(request, pk=None):
    obj = get_object_or_404(PortfolioItem, pk=pk) if pk else PortfolioItem()
    categories = PortfolioCategory.objects.all()
    cms_users = User.objects.filter(is_staff=True).order_by('first_name', 'last_name', 'username')
    if request.method == 'POST':
        obj.title           = request.POST.get('title', '').strip()
        obj.category_label  = request.POST.get('category_label', '').strip()
        obj.short_description = request.POST.get('short_description', '').strip()
        obj.full_description  = request.POST.get('full_description', '').strip()
        obj.client_url      = request.POST.get('client_url', '').strip()
        obj.technologies    = request.POST.get('technologies', '').strip()
        # Server-side required-field validation
        errors = []
        if not obj.title:           errors.append('Title is required.')
        if not request.POST.get('category'): errors.append('Category is required.')
        if not obj.short_description: errors.append('Short description is required.')
        if not obj.pk and 'thumbnail' not in request.FILES:
            errors.append('Thumbnail image is required for new projects.')
        if errors:
            for e in errors:
                messages.error(request, e)
            return render(request, 'cms/portfolio/edit.html', {'obj': obj, 'categories': categories, 'cms_users': cms_users, 'ck_config': _pf_ck_config(),
                   'thumb_w': _thumb_size()[0], 'thumb_h': _thumb_size()[1]})
        obj.show_on_home = 'show_on_home' in request.POST
        obj.is_active = 'is_active' in request.POST
        # Featured and Added By have gone from the form. Their stored values are
        # left alone rather than cleared by their absence, and a new record
        # still records who created it.
        if not obj.pk:
            obj.added_by = request.user
        cat_id = request.POST.get('category')
        obj.category_id = int(cat_id) if cat_id else None
        date_str = request.POST.get('date_completed')
        if date_str:
            from datetime import date
            obj.date_completed = date.fromisoformat(date_str)
        else:
            obj.date_completed = None
        obj.image_alt = _alt(request, 'image_alt')
        # Remove on the Media card posts a flag rather than deleting on click,
        # so nothing goes until the form is saved.
        if request.POST.get('clear_thumbnail') == '1' and obj.thumbnail:
            obj.thumbnail.delete(save=False)
            obj.thumbnail = None
        thumbnail_b64 = request.POST.get('thumbnail_b64', '').strip()
        if thumbnail_b64 or 'thumbnail' in request.FILES:
            source = thumbnail_b64 if thumbnail_b64 else request.FILES['thumbnail']
            orig_name = 'portfolio' if thumbnail_b64 else request.FILES['thumbnail'].name
            try:
                webp_file, img_w, img_h = _convert_to_webp(source, orig_name)
                obj.thumbnail = webp_file
            except Exception as exc:
                messages.error(request, f'Image could not be processed: {exc}')
        obj.save()

        # Showcase images. Removals first, so a brand at the limit can have one
        # swapped in the same save. Both fields are written by the form's own
        # JavaScript and are simply absent on an older cached page, in which
        # case nothing here runs and the gallery is left alone.
        from apps.portfolio.models import PortfolioImage
        dropped = [d for d in request.POST.get('gallery_delete', '').split(',') if d.strip().isdigit()]
        if dropped:
            PortfolioImage.objects.filter(item=obj, pk__in=dropped).delete()

        raw_new = request.POST.get('gallery_new', '').strip()
        incoming = []
        if raw_new:
            import json as _json
            try:
                incoming = _json.loads(raw_new)
            except ValueError:
                incoming = []

        # gallery_order is the strip exactly as it looks on screen, one token
        # per picture: e12 for a row already stored, n0 for the first of the
        # ones just cropped. Walking it once assigns every position, so a
        # dragged picture and a newly added one land in the same sequence.
        tokens = [x for x in request.POST.get('gallery_order', '').split(',') if x]
        if not tokens:
            # An older cached page posts no order. Keep what is stored and put
            # anything new on the end.
            start = (PortfolioImage.objects.filter(item=obj)
                     .aggregate(m=Max('order'))['m'] or 0)
            tokens = [f'n{i}' for i in range(len(incoming))]
            position = start
        else:
            position = 0

        room = 10 - PortfolioImage.objects.filter(item=obj).count()
        for token in tokens:
            position += 1
            if token.startswith('e') and token[1:].isdigit():
                PortfolioImage.objects.filter(item=obj, pk=int(token[1:])).update(order=position)
            elif token.startswith('n') and token[1:].isdigit():
                idx = int(token[1:])
                if idx >= len(incoming) or room <= 0:
                    continue
                try:
                    webp_file, _w, _h = _convert_to_webp(incoming[idx], 'showcase')
                except Exception as exc:
                    messages.error(request, f'One showcase image could not be processed: {exc}')
                    continue
                PortfolioImage.objects.create(
                    item=obj, image=webp_file, alt=obj.title, order=position,
                )
                room -= 1

        messages.success(request, 'Portfolio item saved.')
        return redirect('cms_portfolio')
    return render(request, 'cms/portfolio/edit.html', {'obj': obj, 'categories': categories, 'cms_users': cms_users, 'ck_config': _pf_ck_config(),
                   'thumb_w': _thumb_size()[0], 'thumb_h': _thumb_size()[1]})


@admin_required
def portfolio_delete(request, pk):
    get_object_or_404(PortfolioItem, pk=pk).delete()
    messages.success(request, 'Deleted.')
    return redirect('cms_portfolio')


@admin_required
def portfolio_toggle_visibility(request, pk):
    item = get_object_or_404(PortfolioItem, pk=pk)
    item.is_active = not item.is_active
    item.save(update_fields=['is_active'])
    return redirect('cms_portfolio')


@admin_required
def portfolio_category_edit(request, pk=None):
    obj = get_object_or_404(PortfolioCategory, pk=pk) if pk else PortfolioCategory()
    if request.method == 'POST':
        obj.name = request.POST.get('name', '').strip()
        obj.order = int(request.POST.get('order', 1))
        if not obj.slug:
            from django.utils.text import slugify
            obj.slug = slugify(obj.name)
        obj.save()
        messages.success(request, 'Category saved.')
        return redirect('cms_portfolio')
    return render(request, 'cms/portfolio/category_edit.html', {'obj': obj})


@admin_required
def portfolio_category_delete(request, pk):
    get_object_or_404(PortfolioCategory, pk=pk).delete()
    messages.success(request, 'Category deleted.')
    return redirect('cms_portfolio')


# ─── Insights ─────────────────────────────────────────────────────────────────

@cms_required
@module_required('insights')
def insights_list(request):
    q          = request.GET.get('q', '').strip()
    status_f   = request.GET.get('status', '').strip()
    category_f = request.GET.get('cat', '').strip()

    posts = Insight.objects.select_related('category', 'author').order_by('-published_at', '-pk')
    if q:
        posts = posts.filter(
            Q(title__icontains=q) |
            Q(excerpt__icontains=q) |
            Q(tags__icontains=q) |
            Q(focus_keyword__icontains=q)
        )
    if status_f:
        posts = posts.filter(status=status_f)
    if category_f:
        posts = posts.filter(category_id=category_f)

    from django.core.paginator import Paginator as _Pag
    _pag     = _Pag(posts, 20)
    page_obj = _pag.get_page(request.GET.get('page'))
    post_rows = [{'post': p, 'seo': p.seo_score} for p in page_obj]
    categories = InsightCategory.objects.annotate(post_count=Count('insight')).order_by('order')
    status_choices = [('draft','Draft'),('review','In Review'),('approved','Approved'),
                      ('ready','Ready'),('published','Published'),('needs_update','Needs Update')]
    total = Insight.objects.count()
    _fqs = request.GET.copy(); _fqs.pop('page', None)
    return render(request, 'cms/insights/list.html', {
        'post_rows': post_rows,
        'page_obj': page_obj,
        'categories': categories,
        'status_choices': status_choices,
        'q': q, 'status_f': status_f, 'category_f': category_f,
        'total': total,
        'filter_qs': _fqs.urlencode(),
    })


@admin_required
def insight_edit(request, pk=None):
    obj = get_object_or_404(Insight, pk=pk) if pk else Insight()
    categories = InsightCategory.objects.all()
    pillar_posts = Insight.objects.filter(article_type='pillar').exclude(pk=obj.pk if obj.pk else 0)
    cms_users = User.objects.filter(is_active=True).order_by('first_name', 'username')

    # Parse existing JSON for template display
    faq_items = []
    if obj.faq_json:
        try:
            faq_items = json.loads(obj.faq_json)
        except (json.JSONDecodeError, TypeError):
            faq_items = []

    sources = []
    if obj.sources_json:
        try:
            sources = json.loads(obj.sources_json)
        except (json.JSONDecodeError, TypeError):
            sources = []

    if request.method == 'POST':
        obj.title            = request.POST.get('title', '')
        obj.excerpt          = request.POST.get('excerpt', '')
        obj.body             = request.POST.get('body', '')
        obj.status           = request.POST.get('status', 'draft')
        obj.priority         = request.POST.get('priority', 'medium')
        obj.article_type     = request.POST.get('article_type', 'pillar')
        obj.meta_description = request.POST.get('meta_description', '')
        obj.meta_title       = request.POST.get('meta_title', '')
        obj.focus_keyword    = request.POST.get('focus_keyword', '')
        obj.target_region    = request.POST.get('target_region', 'global')
        obj.canonical_url    = request.POST.get('canonical_url', '')
        obj.og_image_url     = request.POST.get('og_image_url', '')
        obj.tags             = request.POST.get('tags', '')
        obj.tldr             = request.POST.get('tldr', '')
        obj.author_title     = request.POST.get('author_title', '')
        obj.author_bio       = request.POST.get('author_bio', '')
        obj.author_linkedin  = request.POST.get('author_linkedin', '')
        obj.revision_note    = request.POST.get('revision_note', '')
        obj.is_featured      = 'is_featured' in request.POST

        # scheduled_at field removed from top of form; handled via published_at_manual below
        obj.scheduled_at = None  # reset; will be set below if status=scheduled

        try:
            obj.read_time = int(request.POST.get('read_time', 3))
        except (ValueError, TypeError):
            obj.read_time = 3

        update_date_str = request.POST.get('update_date', '').strip()
        if update_date_str:
            try:
                from datetime import date as _date
                obj.update_date = _date.fromisoformat(update_date_str)
            except ValueError:
                obj.update_date = None
        else:
            obj.update_date = None

        cat_id = request.POST.get('category')
        obj.category_id = int(cat_id) if cat_id else None

        pillar_id = request.POST.get('pillar_id', '').strip()
        obj.pillar_id = int(pillar_id) if pillar_id and obj.article_type == 'cluster' else None

        if not obj.pk:
            obj.author = request.user
        # existing posts keep their current author

        obj.image_alt = _alt(request, 'image_alt')

        # Handle thumbnail: delete, replace, or leave unchanged
        if request.POST.get('clear_thumbnail') == '1':
            if obj.thumbnail:
                obj.thumbnail.delete(save=False)
                obj.thumbnail = None

        thumbnail_b64 = request.POST.get('thumbnail_b64', '').strip()
        if thumbnail_b64 or 'thumbnail' in request.FILES:
            source = thumbnail_b64 if thumbnail_b64 else request.FILES['thumbnail']
            orig_name = 'insight' if thumbnail_b64 else request.FILES['thumbnail'].name
            try:
                webp_file, img_w, img_h = _convert_to_webp(source, orig_name)
                if img_w < 600 or img_h < 700:
                    if obj.status in ('published', 'scheduled'):
                        obj.status = 'draft'
                        messages.warning(
                            request,
                            f'Image is {img_w}×{img_h}px (minimum 600×700). '
                            'Status reverted to Draft.'
                        )
                obj.thumbnail = webp_file
            except Exception as exc:
                messages.error(request, f'Image could not be processed: {exc}')

        # Single date field — JS converts to UTC ISO before submit, so no tz conversion needed
        pub_date_str = request.POST.get('published_at_manual', '').strip()
        if pub_date_str:
            try:
                from django.utils.dateparse import parse_datetime
                import datetime as _dt
                parsed = parse_datetime(pub_date_str.replace('Z', '+00:00'))
                if parsed:
                    if parsed.tzinfo is None:
                        parsed = parsed.replace(tzinfo=_dt.timezone.utc)
                    if obj.status == 'scheduled':
                        obj.scheduled_at = parsed
                        if parsed <= timezone.now():
                            messages.warning(request, 'Scheduled time is in the past — the post will publish on the next task run.')
                    else:
                        obj.published_at = parsed
            except Exception:
                pass
        elif obj.status == 'scheduled':
            # Scheduled status requires a date — revert to draft
            obj.status = 'draft'
            messages.error(request, 'Please set a publish date/time when using Scheduled status. Reverted to Draft.')
        elif obj.status == 'published' and not obj.published_at:
            obj.published_at = timezone.now()

        # Build FAQ JSON from dynamic form fields
        faq_items_new = []
        i = 0
        while i < 50:
            key_q = f'faq_q_{i}'
            if key_q not in request.POST:
                break
            q = request.POST.get(key_q, '').strip()
            a = request.POST.get(f'faq_a_{i}', '').strip()
            if q or a:
                faq_items_new.append({'q': q, 'a': a})
            i += 1
        obj.faq_json = json.dumps(faq_items_new, ensure_ascii=False) if faq_items_new else ''

        # Build sources JSON from dynamic form fields
        sources_new = []
        i = 0
        while i < 50:
            key_t = f'source_title_{i}'
            if key_t not in request.POST:
                break
            title = request.POST.get(key_t, '').strip()
            url   = request.POST.get(f'source_url_{i}', '').strip()
            if url:
                sources_new.append({'title': title, 'url': url})
            i += 1
        obj.sources_json = json.dumps(sources_new, ensure_ascii=False) if sources_new else ''

        if not obj.pk:
            obj.tenant = _resolve_tenant(request)
        obj.save()
        messages.success(request, 'Insight saved.')
        return redirect('cms_insights')

    from django.conf import settings as _settings
    ck_config = json.dumps(_settings.CKEDITOR_5_CONFIGS.get('extended', {}))
    return render(request, 'cms/insights/edit.html', {
        'obj': obj,
        'categories': categories,
        'pillar_posts': pillar_posts,
        'cms_users': cms_users,
        'faq_items': faq_items,
        'sources': sources,
        'ck_config': ck_config,
    })


@admin_required
def insight_toggle_visibility(request, pk):
    post = get_object_or_404(Insight, pk=pk)
    post.is_visible = not post.is_visible
    post.save(update_fields=['is_visible'])
    return redirect('cms_insights')

@admin_required
def insight_delete(request, pk):
    get_object_or_404(Insight, pk=pk).delete()
    messages.success(request, 'Deleted.')
    return redirect('cms_insights')


@admin_required
def insight_category_edit(request, pk=None):
    obj = get_object_or_404(InsightCategory, pk=pk) if pk else InsightCategory()
    if request.method == 'POST':
        obj.name = request.POST.get('name', '').strip()
        obj.order = int(request.POST.get('order', 1))
        if not obj.slug:
            from django.utils.text import slugify
            obj.slug = slugify(obj.name)
        obj.save()
        messages.success(request, 'Category saved.')
        return redirect('cms_insights')
    return render(request, 'cms/insights/category_edit.html', {'obj': obj})


@admin_required
def insight_category_delete(request, pk):
    get_object_or_404(InsightCategory, pk=pk).delete()
    messages.success(request, 'Category deleted.')
    return redirect('cms_insights')


# ─── Auction ──────────────────────────────────────────────────────────────────

@cms_required
def auction_list(request):
    listings = AuctionListing.objects.all()
    return render(request, 'cms/auction/list.html', {'listings': listings})


@admin_required
def auction_edit(request, pk=None):
    obj = get_object_or_404(AuctionListing, pk=pk) if pk else AuctionListing()
    if request.method == 'POST':
        obj.asset_type = request.POST.get('asset_type', 'domain')
        obj.name = request.POST.get('name', '')
        obj.is_premium = 'is_premium' in request.POST
        obj.tagline = request.POST.get('tagline', '')
        obj.description = request.POST.get('description', '')
        price = request.POST.get('asking_price', '').strip()
        obj.asking_price = float(price) if price else None
        # If a price is set, price_on_request is automatically off
        obj.price_on_request = (not obj.asking_price) and ('price_on_request' in request.POST)
        obj.status = request.POST.get('status', 'active')
        obj.generate_page = 'generate_page' in request.POST
        obj.meta_description = request.POST.get('meta_description', '')
        obj.save()
        if obj.generate_page:
            generate_auction_page(obj)
            messages.success(request, f'Listing saved and page generated.')
        else:
            messages.success(request, 'Listing saved.')
        return redirect('cms_auction')
    return render(request, 'cms/auction/edit.html', {
        'obj': obj,
        'type_choices': AuctionListing.TYPE_CHOICES,
        'status_choices': AuctionListing.STATUS_CHOICES,
    })


@admin_required
def auction_delete(request, pk):
    get_object_or_404(AuctionListing, pk=pk).delete()
    messages.success(request, 'Deleted.')
    return redirect('cms_auction')


@admin_required
def auction_generate_page(request, pk):
    listing = get_object_or_404(AuctionListing, pk=pk)
    path = generate_auction_page(listing)
    if path:
        messages.success(request, f'Page generated: {listing.page_path}')
    else:
        messages.error(request, 'Page generation failed. Check AUCTION_PAGES_ROOT setting.')
    return redirect('cms_auction')


# ─── Service Health ──────────────────────────────────────────────────────────

def _uptime_for(tenant, days=30):
    """Availability, latency and a daily series for one site."""
    from apps.ftlink.models import UptimeSample
    since = timezone.now() - timedelta(days=days)
    samples = list(
        UptimeSample.objects.filter(tenant=tenant, checked_at__gte=since)
        .values_list('checked_at', 'ok', 'latency_ms')
    )
    total = len(samples)
    up = sum(1 for _, ok, _ in samples if ok)
    lat = [l for _, ok, l in samples if ok and l]

    buckets = {}
    for when, ok, _ in samples:
        d = timezone.localtime(when).date()
        good, seen = buckets.get(d, (0, 0))
        buckets[d] = (good + (1 if ok else 0), seen + 1)

    today = timezone.now().date()
    labels, series = [], []
    for i in range(days - 1, -1, -1):
        d = today - timedelta(days=i)
        labels.append(d.strftime('%d %b'))
        good, seen = buckets.get(d, (0, 0))
        series.append(round(good / seen * 100, 2) if seen else None)

    first = next((i for i, v in enumerate(series) if v is not None), None)
    if first:
        labels, series = labels[first:], series[first:]

    last = UptimeSample.objects.filter(tenant=tenant).first()
    return {
        'uptime_pct': round(up / total * 100, 2) if total else None,
        'sample_count': total,
        'avg_latency': int(sum(lat) / len(lat)) if lat else None,
        'labels': labels,
        'series': series,
        'last': last,
        'is_up': bool(last and last.ok),
    }


@admin_required
def health_trend_json(request):
    """
    Availability for one site at a chosen granularity, not a chosen range:

        1M  per minute over the last hour
        1H  per hour over the last day
        1D  per day over the last month
        1W  per week over the last six months
    """
    from django.http import JsonResponse as JR
    from apps.tenants.models import Tenant
    from apps.ftlink.models import UptimeSample

    GRAINS = {
        '1m': {'step': timedelta(minutes=1), 'count': 60,  'fmt': '%H:%M'},
        '1h': {'step': timedelta(hours=1),   'count': 24,  'fmt': '%H:00'},
        '1d': {'step': timedelta(days=1),    'count': 30,  'fmt': '%d %b'},
        '1w': {'step': timedelta(weeks=1),   'count': 26,  'fmt': '%d %b'},
    }
    grain = GRAINS.get(request.GET.get('period', '1d'), GRAINS['1d'])

    current = _resolve_tenant(request)
    pk = request.GET.get('tenant')
    if current is not None and not _is_fleet_master():
        tenant = current                      # a tenant only ever sees itself
    else:
        tenant = (Tenant.objects.filter(pk=pk).first() if pk
                  else Tenant.objects.filter(is_master=True).first())
    if tenant is None:
        return JR({'labels': [], 'series': []})

    step, count = grain['step'], grain['count']
    now = timezone.localtime()
    # Snap the newest bucket to the start of the current period so buckets line
    # up with the clock rather than with whenever the page happened to load.
    if step >= timedelta(days=1):
        latest = now.replace(hour=0, minute=0, second=0, microsecond=0)
    elif step >= timedelta(hours=1):
        latest = now.replace(minute=0, second=0, microsecond=0)
    else:
        latest = now.replace(second=0, microsecond=0)

    since = latest - step * (count - 1)
    rows = UptimeSample.objects.filter(tenant=tenant, checked_at__gte=since) \
                               .values_list('checked_at', 'ok')

    buckets = {}
    for when, ok in rows:
        local = timezone.localtime(when)
        offset = int((local - since) / step)
        if offset < 0:
            continue
        key = min(offset, count - 1)
        good, seen = buckets.get(key, (0, 0))
        buckets[key] = (good + (1 if ok else 0), seen + 1)

    def _clock(dt):
        # 2:30 PM, no leading zero, portable across platforms
        return dt.strftime('%I:%M %p').lstrip('0')

    labels, series, dates, ranges, ups, totals = [], [], [], [], [], []
    for i in range(count):
        start = since + step * i
        end   = start + step - timedelta(seconds=1)
        good, seen = buckets.get(i, (0, 0))
        labels.append(start.strftime(grain['fmt']))
        # A bucket with no probe in it is not a gap in service. Treating it as
        # 100 keeps the line unbroken edge to edge, and the request count in
        # the tooltip makes clear that nothing was actually measured.
        series.append(round(good / seen * 100, 2) if seen else 100.0)
        dates.append(start.strftime('%d %b %Y').lstrip('0'))
        if step >= timedelta(days=1):
            ranges.append(f"{start.strftime('%d %b').lstrip('0')} - {end.strftime('%d %b').lstrip('0')}"
                          if step > timedelta(days=1) else '')
        else:
            ranges.append(f'{_clock(start)} - {_clock(end)}')
        ups.append(good)
        totals.append(seen)

    # Nothing recorded before the first check, so start the axis there. Without
    # this a new install draws a month of blank space with one dot at the end.
    first = next((i for i, v in enumerate(totals) if v), None)
    if first:
        labels, series = labels[first:], series[first:]
        dates, ranges  = dates[first:], ranges[first:]
        ups, totals    = ups[first:], totals[first:]
    elif first is None:
        labels = series = dates = ranges = ups = totals = []

    return JR({
        'labels': labels, 'series': series, 'dates': dates,
        'ranges': ranges, 'ups': ups, 'totals': totals,
    })


@admin_required
def health(request):
    """Overview: this site's uptime, then every tenant underneath."""
    from apps.tenants.models import Tenant
    from apps.ftlink.models import UptimeSample

    # Fleet wide health belongs to YOUR COMPANY. A tenant sees only the summary
    # block on their own dashboard.
    current = _resolve_tenant(request)
    if current is not None and not _is_fleet_master():
        messages.error(request, f'Service health is managed by {_company()}.')
        return redirect('cms_dashboard')

    master = Tenant.objects.filter(is_master=True).first()
    master_health = _uptime_for(master) if master else None

    # Every install that has not been archived, not only the active ones, so
    # nothing quietly disappears from the health picture. Inactive rows are
    # dimmed in the template rather than hidden.
    rows, down_now, measured = [], 0, 0
    for t in Tenant.objects.filter(is_archived=False).order_by('name'):
        last = UptimeSample.objects.filter(tenant=t).first()
        since = timezone.now() - timedelta(days=30)
        agg = UptimeSample.objects.filter(tenant=t, checked_at__gte=since).aggregate(
            total=Count('id'), up=Count('id', filter=Q(ok=True)), lat=Avg('latency_ms'),
        )
        pct = round(agg['up'] / agg['total'] * 100, 2) if agg['total'] else None
        if pct is not None:
            measured += 1
        # Only sites we are actually watching can be "down". A site with
        # monitoring switched off keeps its last failed sample forever, since
        # nothing probes it again, so counting it would report a permanent
        # outage on a domain that was never live.
        if last and not last.ok and t.monitor_enabled:
            down_now += 1
        rows.append({
            'tenant': t,
            'uptime': pct,
            'latency': int(agg['lat']) if agg['lat'] else None,
            'last': last,
            'is_up': bool(last and last.ok),
            'never': last is None,
        })

    from apps.ftlink.models import Incident
    incidents = Incident.objects.select_related('tenant').order_by('-started_at')[:15]
    # An incident on a site nobody is watching is not an open incident. Without
    # this the header keeps a red count for sites that were switched off.
    open_count = Incident.objects.filter(
        ended_at__isnull=True, tenant__monitor_enabled=True
    ).count()

    # Counts above stay fleet wide; only the table is paged, with YOUR COMPANY
    # pinned to the top of every page the way Fleet does it.
    from django.core.paginator import Paginator
    total_sites = len(rows)
    reference   = [r for r in rows if r['tenant'].is_master]
    rest        = [r for r in rows if not r['tenant'].is_master]
    paginator   = Paginator(rest, 20)
    page        = paginator.get_page(request.GET.get('page', 1))
    page_rows   = reference + list(page.object_list)

    return render(request, 'cms/health/overview.html', {
        'master': master,
        'mh': master_health,
        'rows': page_rows,
        'page_obj': page,
        'down_now': down_now,
        'measured': measured,
        'total_sites': total_sites,
        'events': _get_service_events(10),
        'incidents': incidents,
        'open_incidents': open_count,
    })


@admin_required
def health_audit(request, pk):
    """
    Run every check against one site and hand back a Word report.

    Live probes, so this takes a few seconds. Nothing is stored: the document
    is built in memory and streamed to the browser.
    """
    from apps.tenants.models import Tenant

    current = _resolve_tenant(request)
    if current is not None and not _is_fleet_master() and current.pk != pk:
        messages.error(request, 'You can only audit your own site.')
        return redirect('cms_dashboard')

    tenant = get_object_or_404(Tenant, pk=pk)

    try:
        from apps.ftlink.audit import run_audit
        from apps.ftlink.report import build_report
        data = build_report(run_audit(tenant))
    except RuntimeError as exc:
        # Missing library: say so plainly rather than showing a stack trace
        messages.error(request, str(exc))
        return redirect('cms_health_detail', pk=pk)
    except Exception:
        log.exception('Audit failed for tenant %s', pk)
        messages.error(request, 'The audit could not be completed. The error has been logged.')
        return redirect('cms_health_detail', pk=pk)

    from django.http import HttpResponse
    site = (tenant.primary_domain or tenant.name or 'site').replace('.', '-')
    stamp = timezone.localtime().strftime('%Y-%m-%d')
    response = HttpResponse(data, content_type='application/pdf')
    # inline, so it opens in the browser's viewer and can be saved from there
    response['Content-Disposition'] = f'inline; filename="health-report-{site}-{stamp}.pdf"'
    return response


@admin_required
@require_POST
def health_monitor(request, pk):
    """
    Switch probing on or off for one site, from the Health screens.

    The same flag lives on the tenant edit page, but the moment you decide a
    site should stop reporting is the moment you are looking at its health, so
    the switch belongs here too.
    """
    from apps.tenants.models import Tenant

    current = _resolve_tenant(request)
    if current is not None and not _is_fleet_master():
        messages.error(request, f'Only {_company()} can change monitoring.')
        return redirect('cms_dashboard')

    tenant = get_object_or_404(Tenant, pk=pk)
    tenant.monitor_enabled = not tenant.monitor_enabled
    tenant.save(update_fields=['monitor_enabled'])

    label = tenant.primary_domain or tenant.name
    if tenant.monitor_enabled:
        messages.success(request, f'{label} is being monitored again.')
    else:
        # An open incident on a site nobody is watching stays red forever, so
        # close it here. The history is kept, it is simply no longer live.
        from apps.ftlink.models import Incident
        now = timezone.now()
        open_incidents = list(Incident.objects.filter(tenant=tenant, ended_at__isnull=True))
        for inc in open_incidents:
            inc.ended_at = now
            # Append, never overwrite: the original diagnosis is the useful part.
            inc.diagnosis = (inc.diagnosis + '\n\n' if inc.diagnosis else '') + \
                'Closed automatically when monitoring was switched off for this site.'
            inc.save(update_fields=['ended_at', 'diagnosis'])
        closed = len(open_incidents)
        note = f' {closed} open incident{"s" if closed != 1 else ""} closed.' if closed else ''
        messages.success(request, f'{label} will no longer be probed or raise incidents.{note}')

    # Back where they came from, on the row they just clicked, so a long list
    # does not throw them back to the top every time.
    nxt = request.POST.get('next') or ''
    if nxt.startswith('/') and not nxt.startswith('//'):
        return redirect(f'{nxt.split("#")[0]}#site-{pk}')
    return redirect('cms_health_detail', pk=pk)


@admin_required
def health_detail(request, pk):
    """One site, its own chart and its recent failures."""
    from apps.tenants.models import Tenant
    from apps.ftlink.models import UptimeSample

    current = _resolve_tenant(request)
    if current is not None and not _is_fleet_master() and current.pk != pk:
        messages.error(request, 'You can only see your own site.')
        return redirect('cms_dashboard')

    tenant = get_object_or_404(Tenant, pk=pk)
    data = _uptime_for(tenant)
    failures = UptimeSample.objects.filter(tenant=tenant, ok=False)[:20]
    checkins = []
    try:
        from apps.ftlink.models import CheckinLog
        checkins = CheckinLog.objects.filter(tenant=tenant).order_by('-created_at')[:10]
    except Exception:
        pass

    from apps.ftlink.models import Incident
    incidents = Incident.objects.filter(tenant=tenant).order_by('-started_at')[:20]

    # Deliberately not called 'tenant'. base.html reads that name for the
    # panel's own branding, so passing another tenant under it repaints the
    # sidebar in the client's colours.
    return render(request, 'cms/health/detail.html', {
        'site': tenant,
        'h': data,
        'failures': failures,
        'checkins': checkins,
        'incidents': incidents,
    })


# ─── Marketplace: Upgrade and Plugins ────────────────────────────────────────
# Two screens over one Plugin model. Upgrade holds what a tenant asks for and we
# invoice, server and panel work. Plugins holds catalogue items they buy online.

_UPGRADE_SECTIONS   = ('server', 'panel')
_CATALOGUE_SECTIONS = ('marketplace', 'product', 'service', 'subscription')


@admin_required
def plugin_list(request, mode='catalogue'):
    from apps.marketplace.models import Plugin
    sections = _UPGRADE_SECTIONS if mode == 'upgrade' else _CATALOGUE_SECTIONS
    plugins = Plugin.objects.filter(section__in=sections).select_related('product', 'service')
    return render(request, 'cms/marketplace/plugin_list.html', {
        'plugins': plugins,
        'mode': mode,
        'is_upgrade': mode == 'upgrade',
        'heading': 'Upgrade' if mode == 'upgrade' else 'Plugins',
        'blurb': ('Server and panel work. Tenants request it, you accept and invoice.'
                  if mode == 'upgrade' else
                  'Catalogue items tenants can buy from inside their own panel.'),
    })


@admin_required
def plugin_edit(request, pk=None, mode='catalogue'):
    from apps.marketplace.models import Plugin
    from apps.ecommerce.models import Product
    from apps.pages.models import Service
    from apps.tenants.models import MODULE_CHOICES

    obj = get_object_or_404(Plugin, pk=pk) if pk else Plugin()
    if pk:
        mode = 'upgrade' if obj.section in _UPGRADE_SECTIONS else 'catalogue'

    if request.method == 'POST':
        obj.section = request.POST.get('section', '')
        posted_upgrade = obj.section in _UPGRADE_SECTIONS

        # Exactly one target, so a plugin can never point two ways at once.
        obj.product = obj.service = None
        obj.module_key = obj.upgrade_key = ''

        if posted_upgrade:
            # An upgrade has no catalogue row and is never bought at a
            # checkout. It is requested, quoted and invoiced, so the buying
            # question is not asked and the answer is not read from the form.
            obj.fulfilment = Plugin.FULFIL_REQUEST
            obj.upgrade_key = request.POST.get('upgrade_key', '').strip()
        else:
            obj.fulfilment = request.POST.get('fulfilment', Plugin.FULFIL_REQUEST)
            target = request.POST.get('target', '')
            if target.startswith('product:'):
                obj.product = Product.objects.filter(pk=target.split(':', 1)[1]).first()
            elif target.startswith('service:'):
                obj.service = Service.objects.filter(pk=target.split(':', 1)[1]).first()
            elif target.startswith('module:'):
                obj.module_key = target.split(':', 1)[1]

        obj.title_override = request.POST.get('title_override', '').strip()
        obj.blurb          = request.POST.get('blurb', '').strip()
        obj.price_label    = request.POST.get('price_label', '').strip()
        raw_price = request.POST.get('price_override', '').strip()
        obj.price_override = Decimal(raw_price) if raw_price else None
        obj.visible_plans  = request.POST.get('visible_plans', '').strip()
        # Order is not on the form any more, it is set by dragging rows on the
        # list. Keep whatever the row already has so saving does not reset it.
        obj.order          = int(request.POST.get('order') or getattr(obj, 'order', None) or 1)
        obj.is_active      = 'is_active' in request.POST
        obj.is_featured    = 'is_featured' in request.POST

        if not obj.section:
            messages.error(request, 'Choose an upgrade.' if mode == 'upgrade' else 'Choose a section.')
        elif posted_upgrade and obj.upgrade_key not in {k for k, _ in Plugin.UPGRADE_CHOICES}:
            obj.upgrade_key = ''
            messages.error(request, 'Choose what this upgrade moves the site to.')
        elif not posted_upgrade and not (obj.product or obj.service or obj.module_key):
            messages.error(request, 'Choose what this plugin points at.')
        else:
            obj.save()
            messages.success(request, f'{obj.title} saved.')
            return redirect('cms_plugin_upgrades' if obj.section in _UPGRADE_SECTIONS else 'cms_plugins')

    return render(request, 'cms/marketplace/plugin_edit.html', {
        'obj': obj,
        'mode': mode,
        'is_upgrade': mode == 'upgrade',
        'sections': [c for c in Plugin.SECTION_CHOICES
                     if (c[0] in _UPGRADE_SECTIONS) == (mode == 'upgrade')],
        'fulfilments': Plugin.FULFIL_CHOICES,
        'products': Product.objects.filter(is_active=True).order_by('name'),
        'services': Service.objects.filter(is_active=True).order_by('title'),
        'modules': MODULE_CHOICES,
        # The Upgrade form swaps one list for the other as the section
        # changes, so both go to the template and the browser picks.
        'server_upgrades': Plugin.SERVER_UPGRADES,
        'plan_upgrades': Plugin.PLAN_UPGRADES,
    })


@admin_required
@cms_required
def plugin_toggle_visibility(request, pk):
    """Show or hide a catalogue entry without opening the edit form."""
    from apps.marketplace.models import Plugin
    obj = get_object_or_404(Plugin, pk=pk)
    Plugin.objects.filter(pk=pk).update(is_active=not obj.is_active)
    back = request.META.get('HTTP_REFERER', '')
    return redirect('cms_plugin_upgrades' if 'upgrade' in back else 'cms_plugins')


@require_POST
def plugin_delete(request, pk):
    from apps.marketplace.models import Plugin
    obj = get_object_or_404(Plugin, pk=pk)
    section = obj.section
    obj.delete()
    messages.success(request, 'Plugin removed.')
    return redirect('cms_plugin_upgrades' if section in _UPGRADE_SECTIONS else 'cms_plugins')


# ─── Marketplace ──────────────────────────────────────────────────────────────

@cms_required
def marketplace_list(request):
    services = MarketplaceService.objects.all()
    return render(request, 'cms/marketplace/list.html', {'services': services})


@admin_required
def marketplace_edit(request, pk=None):
    obj = get_object_or_404(MarketplaceService, pk=pk) if pk else MarketplaceService()
    if request.method == 'POST':
        obj.title = request.POST.get('title', '')
        obj.category = request.POST.get('category', 'marketing')
        obj.short_description = request.POST.get('short_description', '')
        obj.full_description = request.POST.get('full_description', '')
        obj.icon_class = request.POST.get('icon_class', '')
        obj.price_label = request.POST.get('price_label', '')
        obj.order = int(request.POST.get('order', 1))
        obj.is_active = 'is_active' in request.POST
        obj.is_featured = 'is_featured' in request.POST
        if 'icon_image' in request.FILES:
            obj.icon_image = request.FILES['icon_image']
        obj.save()
        messages.success(request, 'Service saved.')
        return redirect('cms_marketplace')
    return render(request, 'cms/marketplace/edit.html', {
        'obj': obj,
        'category_choices': MarketplaceService.CATEGORY_CHOICES,
    })


@admin_required
def marketplace_delete(request, pk):
    get_object_or_404(MarketplaceService, pk=pk).delete()
    messages.success(request, 'Deleted.')
    return redirect('cms_marketplace')


# ─── Project Assessments ──────────────────────────────────────────────────────

@cms_required
def assessments_list(request):
    tenant = getattr(request, 'tenant', None)
    show_archived = request.GET.get('archived') == '1'

    base_qs = ProjectAssessment.objects.all()
    if tenant and getattr(tenant, 'pk', 0) != 0:
        base_qs = base_qs.filter(tenant=tenant)

    archived_count = base_qs.filter(is_archived=True).count()
    all_qs = base_qs.filter(is_archived=show_archived)

    status_filter = request.GET.get('status', '')
    search = request.GET.get('q', '').strip()
    qs = all_qs
    if status_filter:
        qs = qs.filter(status=status_filter)
    if search:
        qs = qs.filter(
            Q(name__icontains=search) | Q(email__icontains=search) | Q(ai_label__icontains=search)
        )

    status_counts = {
        row['status']: row['cnt']
        for row in all_qs.values('status').annotate(cnt=Count('id'))
    }
    total_count = sum(status_counts.values())
    statuses_with_counts = [
        (val, label, status_counts.get(val, 0))
        for val, label in ProjectAssessment.STATUS_CHOICES
        if status_counts.get(val, 0) > 0
    ]

    from django.core.paginator import Paginator
    paginator = Paginator(qs.select_related('tenant').order_by('-created_at'), 20)
    assessments_page = paginator.get_page(request.GET.get('page'))

    return render(request, 'cms/assessments/list.html', {
        'assessments': assessments_page,
        'status_filter': status_filter,
        'search': search,
        'statuses_with_counts': statuses_with_counts,
        'total_count': total_count,
        'new_count': status_counts.get('new', 0),
        'show_archived': show_archived,
        'archived_count': archived_count,
    })


@cms_required
@require_POST
def assessment_archive(request, pk):
    """Client went quiet or cancelled. Keep the record, hide the row."""
    from django.utils import timezone
    obj = get_object_or_404(ProjectAssessment, pk=pk)
    obj.is_archived = True
    obj.archived_at = timezone.now()
    obj.save(update_fields=['is_archived', 'archived_at'])
    messages.success(request, f'Assessment from {obj.name} archived.')
    return redirect(request.POST.get('next') or 'cms_assessments')


@cms_required
@require_POST
def assessment_restore(request, pk):
    """Bring an archived assessment back into the pipeline."""
    obj = get_object_or_404(ProjectAssessment, pk=pk)
    obj.is_archived = False
    obj.archived_at = None
    obj.save(update_fields=['is_archived', 'archived_at'])
    messages.success(request, f'Assessment from {obj.name} restored.')
    return redirect(request.POST.get('next') or 'cms_assessments')


@cms_required
def assessment_detail(request, pk):
    from apps.sales.models import AssessmentExpenseItem
    from apps.ecommerce.models import Product
    from apps.pages.models import Service
    assessment = get_object_or_404(ProjectAssessment, pk=pk)
    # Catalog lists for the item picker (exclude domain type)
    products      = Product.objects.filter(is_active=True).exclude(product_type='domain').exclude(product_type='subscription').order_by('name')
    subscriptions = Product.objects.filter(is_active=True, product_type='subscription').order_by('name')
    services      = Service.objects.filter(is_active=True).order_by('title')
    expense_items = assessment.expense_items.select_related('product', 'service').all()
    _cost = sum(float(i.amount or 0) * int(i.quantity or 1) for i in expense_items)
    _bands = {'under5k':(0,5000),'5k-15k':(5000,15000),'15k-40k':(15000,40000),
              '40k-100k':(40000,100000),'100k+':(100000,250000),'under_2k':(0,2000),
              '2k_5k':(2000,5000),'5k_15k':(5000,15000),'15k_50k':(15000,50000),
              'over_50k':(50000,150000)}
    _lo, _hi = _bands.get(assessment.budget, (0, 0))
    _pagew = {'1-5':1,'1-10':1,'6-15':2,'10-50':3,'16-40':3}.get(assessment.page_count, 2)
    _mods = len([m for m in (assessment.features_requested or '').split(',') if m.strip()])
    _custom = (assessment.design_level or '') == 'custom'
    _pages = {'1-5':4,'1-10':8,'6-15':10,'10-50':30,'16-40':28}.get(assessment.page_count, 8)
    _mid = 3500 if _custom else 1200
    _mid += _pages * (320 if _custom else 180)
    _rates = {'user login':1200,'payment':900,'cms':700,'blog':700,'api':1200,
              'analytics':600,'booking':1200,'live chat':500,'membership':1500,
              'events':1500,'llm':4000,'mobile app':8000,'seo':900}
    _feat = (assessment.features_requested or '').lower()
    for _k, _v in _rates.items():
        if _k in _feat: _mid += _v
    _pc = assessment.product_count or ''
    if _pc in ('1-100','1-50','51-500'): _mid += 2500
    elif _pc in ('100+','500+'): _mid += 5000
    if (assessment.content_author or '') == 'us': _mid += _pages * 120
    _bm_low = int(_mid * 0.8)
    _bm_high = int(_mid * 1.3)
    _scope_weeks = round(1.5 + _pages * 0.25 + _mods * 0.5, 1)
    return render(request, 'cms/assessments/detail.html', {
        'assessment': assessment,
        'status_choices': ProjectAssessment.STATUS_CHOICES,
        'quote_versions': assessment.quote_versions.select_related('sent_by').all(),
        'products': products,
        'subscriptions': subscriptions,
        'services': services,
        'expense_items': expense_items,
        'billing_choices': AssessmentExpenseItem.BILLING_CHOICES,
        'fit_cost': round(_cost, 2),
        'fit_budget_low': _lo,
        'fit_budget_high': _hi,
        'fit_bm_low': _bm_low,
        'fit_bm_mid': int(_mid),
        'fit_bm_high': _bm_high,
        'fit_scope_weeks': _scope_weeks,
    })


@cms_required
def assessment_set_estimate(request, pk):
    """Update admin-only estimate fields on a project assessment."""
    assessment = get_object_or_404(ProjectAssessment, pk=pk)
    if request.method == 'POST':
        assessment.est_weeks_min  = request.POST.get('est_weeks_min') or None
        assessment.est_weeks_max  = request.POST.get('est_weeks_max') or None
        assessment.est_cost_min   = request.POST.get('est_cost_min') or None
        assessment.est_cost_max   = request.POST.get('est_cost_max') or None
        assessment.internal_notes = request.POST.get('internal_notes', '').strip()
        assessment.status         = request.POST.get('status', assessment.status)
        assessment.save()
        messages.success(request, 'Assessment updated.')
    return redirect('cms_assessment_detail', pk=pk)


@cms_required
def assessment_save_quote(request, pk):
    """Save the admin quotation fields (amount + message) without sending."""
    assessment = get_object_or_404(ProjectAssessment, pk=pk)
    if request.method == 'POST':
        assessment.quote_weeks  = request.POST.get('quote_weeks') or None
        assessment.quote_amount = request.POST.get('quote_amount') or None
        assessment.quote_notes  = request.POST.get('quote_notes', '').strip()
        assessment.save(update_fields=['quote_weeks', 'quote_amount', 'quote_notes', 'updated_at'])
        messages.success(request, 'Quotation saved.')
    return redirect('cms_assessment_detail', pk=pk)


@cms_required
def assessment_send_quote(request, pk):
    """Send the quotation to the client by email and record it in the quote history."""
    if request.method != 'POST':
        return redirect('cms_assessment_detail', pk=pk)
    assessment = get_object_or_404(ProjectAssessment, pk=pk)

    # Capture submitted values
    quote_weeks  = request.POST.get('quote_weeks') or assessment.quote_weeks
    quote_amount = request.POST.get('quote_amount') or assessment.quote_amount
    quote_notes  = request.POST.get('quote_notes', assessment.quote_notes or '').strip()

    # Update draft fields on the assessment
    assessment.quote_weeks  = quote_weeks
    assessment.quote_amount = quote_amount
    assessment.quote_notes  = quote_notes

    from django.utils import timezone as _tz
    from django.db.models import Max
    from apps.ecommerce.email_utils import send_quote_email

    try:
        # The record is created first, because the email needs its accept link
        # and validity date. Ten working days, weekends excluded.
        last_v = assessment.quote_versions.aggregate(max_v=Max('version'))['max_v'] or 0
        quote = AssessmentQuote.objects.create(
            assessment=assessment,
            version=last_v + 1,
            quote_weeks=quote_weeks or None,
            quote_amount=quote_amount or None,
            quote_notes=quote_notes,
            sent_by=request.user,
            expires_at=AssessmentQuote.working_days_from(_tz.now(), 10),
        )

        send_quote_email(assessment, request.user, quote=quote)

        assessment.quote_sent_at = _tz.now()
        assessment.quote_sent_by = request.user
        assessment.status = 'quoted'
        assessment.save(update_fields=['quote_weeks', 'quote_amount', 'quote_notes', 'quote_sent_at', 'quote_sent_by', 'status', 'updated_at'])

        messages.success(
            request,
            f'Quotation #{quote.version} emailed to {assessment.email}, '
            f'valid until {quote.expires_at:%d %b %Y}.',
        )
    except Exception as exc:
        messages.error(request, f'Failed to send email: {exc}')
        assessment.save(update_fields=['quote_amount', 'quote_notes', 'updated_at'])

    return redirect('cms_assessment_detail', pk=pk)


@cms_required
def assessment_ai_analyse(request, pk):
    """Re-run AI analysis on a project assessment."""
    if request.method != 'POST':
        return redirect('cms_assessment_detail', pk=pk)
    assessment = get_object_or_404(ProjectAssessment, pk=pk)
    try:
        import json as _json
        from django.utils import timezone as _tz
        from openai import OpenAI as _OpenAI
        from apps.pages.api_keys import get_openai_api_key
        api_key = get_openai_api_key(getattr(request, 'tenant', None))
        if not api_key:
            messages.error(request, 'OpenAI API key is not set. Add it in CMS Settings.')
            return redirect('cms_assessment_detail', pk=pk)
        features = ', '.join(assessment.features_list) if assessment.features_list else 'None specified'
        prompt = (
            f"You are a UK digital agency quoting expert. Analyse this project brief and respond ONLY with valid JSON.\n\n"
            f"Project brief:\n"
            f"- Service: {assessment.service_types_display}\n"
            f"- Budget: {assessment.get_budget_display()}\n"
            f"- Timeline: {assessment.get_timeline_display()}\n"
            f"- Features requested: {features}\n"
            f"- Description: {assessment.project_description or 'Not provided'}\n\n"
            f'Return JSON with exactly these keys:\n'
            f'{{"label":"5-10 word project label","feasibility_score":<int 0-100>,"market_note":"2-3 sentences on UK market cost and realism","verdict":"feasible|possible_reduced_scope|challenging|unrealistic"}}'
        )
        client = _OpenAI(api_key=api_key)
        resp = client.chat.completions.create(
            model='gpt-4o-mini',
            messages=[{'role': 'user', 'content': prompt}],
            max_tokens=400,
            temperature=0.3,
            response_format={'type': 'json_object'},
        )
        data = _json.loads(resp.choices[0].message.content)
        assessment.ai_label             = str(data.get('label', ''))[:200]
        assessment.ai_feasibility_score = max(0, min(100, int(data.get('feasibility_score', 0))))
        assessment.ai_market_note       = str(data.get('market_note', ''))
        assessment.ai_verdict           = str(data.get('verdict', ''))[:30]
        assessment.ai_analysed_at       = _tz.now()
        assessment.save(update_fields=[
            'ai_label', 'ai_feasibility_score', 'ai_market_note',
            'ai_verdict', 'ai_analysed_at',
        ])
        messages.success(request, f'AI analysis complete — {assessment.ai_label}')
    except Exception as e:
        messages.error(request, f'AI analysis failed: {e}')
    return redirect('cms_assessment_detail', pk=pk)


# ─── Tickets ──────────────────────────────────────────────────────────────────

@cms_required
def tickets_list(request):
    status_filter = request.GET.get('status', '')
    source_filter = request.GET.get('source', '')
    search = request.GET.get('q', '')
    show_archive = request.GET.get('archived') == '1'

    ARCHIVED_STATUSES = [Ticket.STATUS_CLOSED, Ticket.STATUS_RESOLVED]

    tickets = Ticket.objects.select_related('auction_listing', 'marketplace_service', 'assigned_to')

    if show_archive:
        # Archive view: closed/resolved OR from a blocked sender
        from apps.tickets.models import BlockedEmail as _BE
        _blocked_qs = _BE.objects.values_list('email', flat=True)
        tickets = tickets.filter(
            Q(status__in=ARCHIVED_STATUSES) | Q(sender_email__in=_blocked_qs)
        )
    else:
        # Active view: exclude closed/resolved unless explicitly filtered
        if not status_filter:
            tickets = tickets.exclude(status__in=ARCHIVED_STATUSES)

    if status_filter:
        tickets = tickets.filter(status=status_filter)
    if source_filter:
        tickets = tickets.filter(source=source_filter)
    if search:
        tickets = tickets.filter(
            Q(sender_name__icontains=search) |
            Q(sender_email__icontains=search) |
            Q(subject__icontains=search) |
            Q(ticket_number__icontains=search)
        )

    from apps.tickets.models import BlockedEmail
    from django.core.paginator import Paginator
    blocked_emails = set(
        e.lower() for e in BlockedEmail.objects.values_list('email', flat=True)
    )
    blocked_qs = BlockedEmail.objects.values_list('email', flat=True)

    # Hide blocked-sender tickets from the active inbox
    if not show_archive:
        tickets = tickets.exclude(sender_email__in=blocked_qs)

    active_tickets = Ticket.objects.exclude(status__in=ARCHIVED_STATUSES).exclude(sender_email__in=blocked_qs)
    all_tickets = Ticket.objects.all()
    status_counts = {
        row['status']: row['cnt']
        for row in active_tickets.values('status').annotate(cnt=Count('id'))
    }
    source_counts = {
        row['source']: row['cnt']
        for row in active_tickets.values('source').annotate(cnt=Count('id'))
    }
    total_count = sum(status_counts.values())
    statuses_with_counts = [
        (val, label, status_counts.get(val, 0))
        for val, label in Ticket.STATUS_CHOICES
        if status_counts.get(val, 0) > 0
    ]
    sources_with_counts = [
        (val, label, source_counts.get(val, 0))
        for val, label in Ticket.SOURCE_CHOICES
        if source_counts.get(val, 0) > 0
    ]

    closed_ticket_count = all_tickets.filter(
        Q(status__in=[Ticket.STATUS_CLOSED, Ticket.STATUS_RESOLVED]) |
        Q(sender_email__in=blocked_qs)
    ).distinct().count()

    paginator = Paginator(tickets.order_by('-created_at'), 20)
    tickets_page = paginator.get_page(request.GET.get('page'))

    return render(request, 'cms/tickets/list.html', {
        'tickets': tickets_page,
        'active_status': status_filter,
        'active_source': source_filter,
        'search': search,
        'statuses_with_counts': statuses_with_counts,
        'sources_with_counts': sources_with_counts,
        'total_count': total_count,
        'closed_ticket_count': closed_ticket_count,
        'show_archive': show_archive,
        'blocked_emails': blocked_emails,
    })


@cms_required
def ticket_detail(request, pk):
    from apps.tickets.models import BlockedEmail as _BlockedEmail
    ticket = get_object_or_404(Ticket, pk=pk)
    replies = ticket.replies.all()
    cms_users = User.objects.filter(
        role__in=[
            User.ROLE_SYSTEM_ADMIN,
            User.ROLE_SUPER_ADMIN,
            User.ROLE_BUSINESS_ADMIN,
            User.ROLE_SUPPORT_AGENT,
        ]
    ).order_by('first_name', 'last_name')

    # The translate button is always offered. The old test looked for non-ASCII
    # letters, which detects a different alphabet rather than a different
    # language: Finglish, Persian written in Latin letters, is pure ASCII and
    # was therefore treated as English. The model can judge better than we can.

    return render(request, 'cms/tickets/detail.html', {
        'ticket': ticket,
        'replies': replies,
        'cms_users': cms_users,
        'status_choices': Ticket.STATUS_CHOICES,
        'priority_choices': Ticket.PRIORITY_CHOICES,
        'sender_blocked': _BlockedEmail.is_blocked(ticket.sender_email) if ticket.sender_email else False,
    })


@cms_required
def ticket_reply(request, pk):
    ticket = get_object_or_404(Ticket, pk=pk)
    if request.method == 'POST':
        message = request.POST.get('message', '').strip()
        is_internal = 'is_internal' in request.POST
        send_email = 'send_email' in request.POST

        if message:
            reply = TicketReply.objects.create(
                ticket=ticket,
                author=request.user,
                message=message,
                is_internal=is_internal,
            )

            # Update ticket status
            new_status = request.POST.get('status', '').strip()
            if not new_status:
                # A real reply moves the ticket on by itself. The reply form
                # carries no status field, so this used to write back the
                # status the ticket already had, leaving it New for ever. The
                # list kept saying pending and the welcome card stayed amber
                # long after the client had been answered.
                if is_internal:
                    new_status = ticket.status
                elif ticket.status in (Ticket.STATUS_NEW, Ticket.STATUS_OPEN):
                    new_status = Ticket.STATUS_WAITING
                else:
                    new_status = ticket.status
            ticket.status = new_status
            ticket.save(update_fields=['status', 'updated_at'])

            if send_email and not is_internal:
                # Send HTML email to client using support@ credentials
                from django.core.mail import EmailMultiAlternatives
                from django.template.loader import render_to_string
                from django.conf import settings
                import random, re
                sent_ok, send_error, html_body = False, '', ''
                try:
                    # The host, port and SSL choice come from Settings, like
                    # every other credential. This used to read
                    # settings.EMAIL_HOST from .env, which is empty on every
                    # install since the move to the database, so the backend
                    # had nothing to connect to and smtplib answered "please
                    # run connect() first" with no clue as to why.
                    from apps.pages.email_backend import (
                        SiteSettingsEmailBackend, support_credentials,
                    )
                    from apps.pages.models import SiteSettings as _S
                    _site = _S.objects.first()
                    support_email, support_password = support_credentials()
                    connection = SiteSettingsEmailBackend(
                        host=((_site.smtp_host or '').strip() if _site else '') or settings.EMAIL_HOST,
                        port=(_site.smtp_port if _site and _site.smtp_port else 465),
                        username=support_email,
                        password=support_password,
                        use_ssl=(_site.smtp_use_ssl if _site else True),
                        use_tls=(_site.smtp_use_tls if _site else False),
                        fail_silently=False,
                    )
                    if not connection.host:
                        raise RuntimeError(
                            'No SMTP host set. Add it under Settings next to the '
                            'support mailbox.'
                        )
                    _HELPDESK_SIGS = [
                        ('Emily Carter',     'Client Services'),
                        ('James Harrington', 'Client Services'),
                        ('Sophie Bennett',   'Client Services'),
                    ]
                    sig_name, sig_dept = random.choice(_HELPDESK_SIGS)
                    from apps.pages.branding import brand
                    html_body = render_to_string('public/emails/ticket_reply_email.html', {
                        **brand(getattr(ticket, 'tenant', None)),
                        'subject':       ticket.subject,
                        'ticket_number': ticket.ticket_number,
                        'customer_name': ticket.sender_name.split()[0],
                        'message':       message,
                        'sig_name':      sig_name,
                        'sig_dept':      sig_dept,
                    })
                    plain = re.sub(r'<[^>]+>', ' ', html_body)
                    plain = re.sub(r'[ \t]+', ' ', plain).strip()
                    em = EmailMultiAlternatives(
                        subject=f'Re: {ticket.subject} [#{ticket.ticket_number}]',
                        body=plain,
                        from_email=f'{_company()} Helpdesk <{support_email}>',
                        to=[ticket.sender_email],
                        reply_to=[support_email],
                        connection=connection,
                    )
                    em.attach_alternative(html_body, 'text/html')
                    if not support_password:
                        # Nothing will authenticate without it, and a silent
                        # failure here is what made this look like it worked.
                        raise RuntimeError(
                            'No support mailbox password set. Add it under Settings, '
                            'so a value in .env.production has no effect.'
                        )
                    em.send(fail_silently=False)
                    sent_ok = True
                    reply.sent_by_email = True
                    reply.save(update_fields=['sent_by_email'])
                except Exception as exc:
                    send_error = str(exc)
                    log.exception('Ticket reply email failed for #%s', ticket.ticket_number)

                # Log it either way. Without this the reply never appears in
                # correspondence, which is where you look to check it went.
                try:
                    from apps.ecommerce.models import EmailLog
                    EmailLog.objects.create(
                        # Its own type rather than Other. The list labels any
                        # value it finds in the database, so this needs no
                        # migration and reads as "Ticket Reply".
                        email_type='ticket_reply',
                        subject=f'Re: {ticket.subject} [#{ticket.ticket_number}]',
                        to_email=ticket.sender_email or '',
                        from_email=_support_address(),
                        html_body=html_body,
                        ok=sent_ok,
                        error=send_error,
                    )
                except Exception:
                    log.exception('Could not write the email log for ticket #%s',
                                  ticket.ticket_number)

                if sent_ok:
                    messages.success(request, 'Reply saved and emailed to the client.')
                else:
                    # Say so plainly. The reply is still on the ticket.
                    messages.error(
                        request,
                        'Reply saved, but the email did not go out: '
                        f'{send_error or "no reason reported"}'
                    )
            else:
                messages.success(
                    request,
                    'Internal note saved.' if is_internal else 'Reply saved, no email sent.'
                )
    return redirect('cms_ticket_detail', pk=pk)


@cms_required
def ticket_translate(request, pk):
    """Translate the ticket message thread to English (JSON POST)."""
    from django.http import JsonResponse
    if request.method != 'POST':
        return JsonResponse({'error': 'Method not allowed'}, status=405)

    ticket = get_object_or_404(Ticket, pk=pk)
    replies = list(ticket.replies.select_related('author').order_by('created_at'))

    lines = [
        f"Subject: {ticket.subject}",
        f"From: {ticket.sender_name}",
        f"Message:\n{ticket.message}",
    ]
    for r in replies:
        author = (r.author.get_full_name() or r.author.username) if r.author else 'System'
        lines.append(f"Reply from {author}:\n{r.message}")

    combined = '\n\n'.join(lines)

    from django.conf import settings
    try:
        import openai
        # Same key source as the other AI features: Settings in the database
        # first, .env only as a fallback. Reading settings.OPENAI_API_KEY
        # directly meant this feature kept using a revoked key after the
        # key was rotated in the panel.
        from apps.pages.api_keys import get_openai_api_key
        client = openai.OpenAI(api_key=get_openai_api_key(getattr(request, 'tenant', None)))
        response = client.chat.completions.create(
            model='gpt-4o-mini',
            messages=[
                {
                    'role': 'system',
                    'content': (
                        'You are a translator. Translate ALL of the following text to English. '
                        'Preserve the field labels (Subject:, From:, Message:, Reply from X:) exactly. '
                        'Translate the values after each label. '
                        'If a section is already in English, keep it unchanged. '
                        'Return only the translated text, nothing else.'
                    ),
                },
                {'role': 'user', 'content': combined},
            ],
            max_tokens=1000,
            temperature=0.2,
        )
        translated = response.choices[0].message.content.strip()
        return JsonResponse({'translated': translated})
    except Exception as exc:
        return JsonResponse({'error': str(exc)}, status=500)


@cms_required
def ticket_ai_draft(request, pk):
    """Return an AI-generated reply draft for a ticket (JSON POST)."""
    from django.http import JsonResponse
    if request.method != 'POST':
        return JsonResponse({'error': 'Method not allowed'}, status=405)

    ticket = get_object_or_404(Ticket, pk=pk)
    replies = list(ticket.replies.select_related('author').order_by('created_at'))

    # Build a thread summary for the AI
    thread_parts = [
        f"Customer: {ticket.sender_name} <{ticket.sender_email}>",
        f"Subject: {ticket.subject}",
        f"Message:\n{ticket.message}",
    ]
    for r in replies:
        author = (r.author.get_full_name() or r.author.username) if r.author else 'System'
        prefix = '[Internal note]' if r.is_internal else f'[Reply from {author}]'
        thread_parts.append(f"{prefix}:\n{r.message}")

    thread_text = '\n\n---\n\n'.join(thread_parts)

    # Let the model judge the language rather than testing the alphabet. The
    # old check only spotted non-ASCII letters, so Finglish and other
    # romanised writing counted as English and never got a bilingual reply.
    bilingual_instruction = (
        'Work out which language the customer wrote in. This includes languages '
        'written in Latin letters, such as Persian written as Finglish. '
        'If they did not write in English, first write the full reply in English, '
        'then a divider line containing only "---", then the same reply in the '
        'language they used, written the same way they wrote it. '
        'Both sections must be complete, standalone replies. '
        'If they did write in English, reply in English only and do not add a divider.'
    )

    system_prompt = (
        f'You are a professional customer support agent for {_company()}, '
        'a web and app design and development company specialising in SEO and SEM. '
        'Write helpful, warm, and concise email replies. '
        'Address the customer by their first name. '
        'Do not use em-dashes. '
        'Do not include a subject line. '
        'Do not include a sign-off or signature — end the reply after the last sentence of the message body. '
        + bilingual_instruction
    )

    from django.conf import settings
    try:
        import openai
        # Same key source as the other AI features: Settings in the database
        # first, .env only as a fallback. Reading settings.OPENAI_API_KEY
        # directly meant this feature kept using a revoked key after the
        # key was rotated in the panel.
        from apps.pages.api_keys import get_openai_api_key
        client = openai.OpenAI(api_key=get_openai_api_key(getattr(request, 'tenant', None)))
        response = client.chat.completions.create(
            model='gpt-4o-mini',
            messages=[
                {'role': 'system', 'content': system_prompt},
                {
                    'role': 'user',
                    'content': f'Write a reply to this support ticket thread:\n\n{thread_text}',
                },
            ],
            max_tokens=1000,
            temperature=0.7,
        )
        draft = response.choices[0].message.content.strip()
        return JsonResponse({'draft': draft})
    except Exception as exc:
        return JsonResponse({'error': str(exc)}, status=500)


@cms_required
def ticket_status(request, pk):
    ticket = get_object_or_404(Ticket, pk=pk)
    if request.method == 'POST':
        ticket.status = request.POST.get('status', ticket.status)
        ticket.priority = request.POST.get('priority', ticket.priority)
        assigned = request.POST.get('assigned_to')
        ticket.assigned_to_id = int(assigned) if assigned else None
        ticket.internal_note = request.POST.get('internal_note', ticket.internal_note)
        if ticket.status == Ticket.STATUS_RESOLVED and not ticket.resolved_at:
            ticket.resolved_at = timezone.now()
        ticket.save()
        messages.success(request, 'Ticket updated.')
    return redirect('cms_ticket_detail', pk=pk)


@admin_required
def assessment_delete(request, pk):
    assessment = get_object_or_404(ProjectAssessment, pk=pk)
    if request.method == 'POST':
        assessment.delete()
        messages.success(request, 'Assessment deleted.')
        return redirect('cms_assessments')
    return redirect('cms_assessments')


@cms_required
def assessment_add_expense_item(request, pk):
    """Add a catalog item (Product/Service/Subscription) to the assessment cost estimate."""
    from apps.sales.models import AssessmentExpenseItem
    from apps.ecommerce.models import Product
    from apps.pages.models import Service
    from decimal import Decimal, InvalidOperation

    assessment = get_object_or_404(ProjectAssessment, pk=pk)
    if request.method != 'POST':
        return redirect('cms_assessment_detail', pk=pk)

    item_type      = request.POST.get('item_type', 'product')
    product_id     = request.POST.get('product_id', '').strip()
    service_id     = request.POST.get('service_id', '').strip()
    billing_period = request.POST.get('billing_period', 'once')
    notes          = request.POST.get('notes', '').strip()

    try:
        amount   = Decimal(request.POST.get('amount', '0') or '0')
        quantity = max(1, int(request.POST.get('quantity', '1') or '1'))
    except (InvalidOperation, ValueError):
        messages.error(request, 'Invalid amount or quantity.')
        return redirect('cms_assessment_detail', pk=pk)

    item = AssessmentExpenseItem(
        assessment=assessment,
        item_type=item_type,
        billing_period=billing_period,
        amount=amount,
        quantity=quantity,
        notes=notes,
    )

    if item_type == 'service' and service_id:
        svc = Service.objects.filter(pk=service_id).first()
        if svc:
            item.service = svc
            item.label   = request.POST.get('label', '').strip() or svc.title
        else:
            messages.error(request, 'Service not found.')
            return redirect('cms_assessment_detail', pk=pk)
    elif product_id:
        prod = Product.objects.filter(pk=product_id).first()
        if prod:
            item.product = prod
            item.label   = request.POST.get('label', '').strip() or prod.name
        else:
            messages.error(request, 'Item not found.')
            return redirect('cms_assessment_detail', pk=pk)
    else:
        item.label = request.POST.get('label', '').strip() or 'Unnamed item'

    item.save()
    messages.success(request, f'"{item.label}" added to cost estimate.')
    return redirect('cms_assessment_detail', pk=pk)


@cms_required
def assessment_remove_expense_item(request, pk, item_pk):
    """Remove a cost estimate item from the assessment."""
    from apps.sales.models import AssessmentExpenseItem
    item = get_object_or_404(AssessmentExpenseItem, pk=item_pk, assessment__pk=pk)
    if request.method == 'POST':
        label = item.label
        item.delete()
        messages.success(request, f'"{label}" removed.')
    return redirect('cms_assessment_detail', pk=pk)


@cms_required
def assessment_push_expense_item(request, pk, item_pk):
    """Push a single cost estimate item to the Expenses ledger."""
    from apps.sales.models import AssessmentExpenseItem
    from apps.ecommerce.models import Expense
    from django.utils import timezone

    item = get_object_or_404(AssessmentExpenseItem, pk=item_pk, assessment__pk=pk)
    if request.method != 'POST':
        return redirect('cms_assessment_detail', pk=pk)

    if item.pushed_to_expenses:
        messages.warning(request, f'"{item.label}" was already pushed to expenses.')
        return redirect('cms_assessment_detail', pk=pk)

    assessment = item.assessment
    # Map item billing_period to Expense category
    cat_map = {
        'contractor': item.item_type == 'service',
        'software':   item.item_type in ('product', 'subscription'),
    }
    category = 'contractor' if item.item_type == 'service' else 'software'

    project_tag = assessment.ai_label or assessment.name or ''
    Expense.objects.create(
        supplier=assessment.company or assessment.name,
        description=f'{item.label} — {project_tag}' if project_tag else item.label,
        category=category,
        billing_period=item.billing_period,
        amount=item.amount * item.quantity,
        date=timezone.now().date(),
    )
    item.pushed_to_expenses = True
    item.save(update_fields=['pushed_to_expenses'])
    messages.success(request, f'"{item.label}" pushed to Expenses.')
    return redirect('cms_assessment_detail', pk=pk)


@cms_required
def assessment_generate_quote_message(request, pk):
    """Generate an AI-written quote email message and return it as JSON."""
    import json as _json
    from django.http import JsonResponse
    from django.conf import settings as _settings

    if request.method != 'POST':
        return JsonResponse({'error': 'POST required'}, status=405)

    assessment = get_object_or_404(ProjectAssessment, pk=pk)

    # Collect context
    amount    = request.POST.get('quote_amount', '').strip()
    weeks     = request.POST.get('quote_weeks', '').strip()
    name      = assessment.name or 'the client'
    company   = assessment.company or ''
    project   = assessment.ai_label or assessment.service_types_display or 'the project'
    features  = ', '.join(assessment.features_list) if assessment.features_list else ''
    budget    = assessment.get_budget_display() if hasattr(assessment, 'get_budget_display') else ''
    ai_note   = assessment.ai_market_note or ''
    ref_sites = assessment.reference_sites or ''

    amount_str = f'£{amount}' if amount else 'TBC'
    weeks_str  = f'{weeks} weeks' if weeks else 'TBC'

    # Build first name for greeting
    first_name = name.split()[0] if name and name != 'the client' else name

    # Derive rough package pricing from Standard (quoted) amount
    try:
        std_amount = float(amount) if amount else None
        starter_amount   = f'£{int(round(std_amount*0.875/100)*100):,.0f}' if std_amount else 'TBC'
        standard_amount  = f'£{std_amount:,.0f}'          if std_amount else amount_str
        signature_amount = f'£{int(round(std_amount*1.125/100)*100):,.0f}' if std_amount else 'TBC'
    except Exception:
        starter_amount = standard_amount = signature_amount = 'TBC'

    # Derive starter/signature weeks from standard
    try:
        std_weeks = int(weeks) if weeks else None
        starter_weeks   = f'{max(1, int(round(std_weeks*1.1)))} weeks' if std_weeks else 'TBC'
        standard_weeks  = weeks_str
        signature_weeks = f'{max(1, int(round(std_weeks*0.9)))} weeks' if std_weeks else 'TBC'
    except Exception:
        starter_weeks = standard_weeks = signature_weeks = 'TBC'

    prompt = (
        f'You are a professional, no-nonsense proposals writer at {_company()}. '
        f'Your tone is direct, confident, and credible. You are writing to win the work, not to flatter. '
        f'Vary sentence structure and opening significantly each time so no two emails look alike.\n\n'

        f'Write an email body for a project quote. Use this exact structure.\n'
        f'CRITICAL RULES:\n'
        f'- Plain text only. NO markdown, NO asterisks, NO bold, NO em dashes.\n'
        f'- Do NOT write any greeting ("Hello", "Dear", "Hi"). The email template adds "Dear {first_name}," automatically.\n'
        f'- Do NOT write any sign-off, "Best regards", "Kind regards", "Yours", "Feel free to contact us", signature, or "[Your Name]". The template has a professional closing already.\n'
        f'- Separate each section with a blank line.\n\n'

        f'Section 1: One concise paragraph setting realistic expectations about the project complexity. '
        f'Mention the specific features requested and briefly explain the technical considerations involved. '
        f'Be honest about scope, not enthusiastic fluff.\n\n'

        f'Section 2: Present three packages. Write each as a new paragraph starting with the label on its own line, followed by 1-2 sentences:\n'
        f'   Starter Package ({starter_amount} / {starter_weeks})\n'
        f'   Standard Package ({standard_amount} / {standard_weeks})\n'
        f'   Signature Package ({signature_amount} / {signature_weeks})\n\n'

        f'Section 3: One paragraph explaining that the Standard Package is the basis of this quote and that scope adjustments are possible.\n\n'

        f'--- Project context ---\n'
        f'Client: {name}{"  |  " + company if company else ""}\n'
        f'Project type: {project}\n'
        f'{"Features requested: " + features + chr(10) if features else ""}'
        f'{"Client stated budget: " + budget + chr(10) if budget else ""}'
        f'{"Market context: " + ai_note + chr(10) if ai_note else ""}'
        f'{"Reference sites: " + ref_sites.strip() + chr(10) if ref_sites.strip() else ""}'
        f'Standard quote: {standard_amount} over {standard_weeks}\n'
    )

    try:
        from openai import OpenAI as _OpenAI
        from apps.pages.api_keys import get_openai_api_key
        api_key = get_openai_api_key(getattr(request, 'tenant', None))
        if not api_key:
            return JsonResponse({'error': 'OpenAI API key not set in CMS Settings'}, status=500)
        client = _OpenAI(api_key=api_key)
        resp = client.chat.completions.create(
            model='gpt-4o-mini',
            messages=[{'role': 'user', 'content': prompt}],
            temperature=0.85,
            max_tokens=700,
        )
        import re as _re
        message = resp.choices[0].message.content.strip()
        # Strip markdown bold (**text** or __text__)
        message = _re.sub(r'\*\*(.+?)\*\*', r'\1', message)
        message = _re.sub(r'__(.+?)__', r'\1', message)
        # Remove any trailing sign-off lines (Best regards / Kind regards / [Your Name] etc.)
        lines = message.splitlines()
        cutoff = len(lines)
        for i, line in enumerate(lines):
            l = line.strip().lower()
            if l.startswith(('best regards', 'kind regards', 'yours sincerely', 'warm regards', 'regards,', '[your name]')):
                cutoff = i
                break
        message = '\n'.join(lines[:cutoff]).strip()
        return JsonResponse({'message': message})
    except Exception as e:
        return JsonResponse({'error': str(e)}, status=500)


@admin_required
def ticket_delete(request, pk):
    ticket = get_object_or_404(Ticket, pk=pk)
    if request.method == 'POST':
        ticket.delete()
        # The list runs the countdown and has already said so in red.
        if 'silent' not in request.POST:
            messages.success(request, 'Ticket deleted.')
        return redirect('cms_tickets')
    return redirect('cms_tickets')


@cms_required
def ticket_archive(request, pk):
    """Archive (close) a single ticket."""
    ticket = get_object_or_404(Ticket, pk=pk)
    if request.method == 'POST':
        ticket.status = Ticket.STATUS_CLOSED
        ticket.save(update_fields=['status'])
        messages.success(request, f'Ticket #{ticket.ticket_number} archived.')
    return redirect('cms_tickets')


@cms_required
def ticket_unarchive(request, pk):
    """Restore an archived ticket back to open/active."""
    ticket = get_object_or_404(Ticket, pk=pk)
    if request.method == 'POST':
        ticket.status = Ticket.STATUS_NEW
        ticket.save(update_fields=['status'])
        messages.success(request, f'Ticket #{ticket.ticket_number} restored to inbox.')
    return redirect(reverse('cms_tickets') + '?archived=1')


@cms_required
def ticket_block_sender(request, pk):
    """Block the sender email of a ticket so they can no longer submit tickets."""
    from apps.tickets.models import BlockedEmail
    ticket = get_object_or_404(Ticket, pk=pk)
    if request.method == 'POST':
        email = ticket.sender_email.strip().lower()
        if not BlockedEmail.objects.filter(email__iexact=email).exists():
            BlockedEmail.objects.create(email=email, blocked_by=request.user)
            messages.success(request, f'{email} blocked and ticket archived.')
        else:
            messages.info(request, f'{email} is already blocked.')
        # Archive this ticket and all others from same email
        Ticket.objects.filter(sender_email__iexact=email).update(status=Ticket.STATUS_CLOSED)
    return redirect(request.META.get('HTTP_REFERER', reverse('cms_tickets')))


@admin_required
def ticket_unblock_email(request):
    """Unblock an email address (POST with ?email=...)."""
    from apps.tickets.models import BlockedEmail
    if request.method == 'POST':
        email = request.POST.get('email', '').strip()
        deleted, _ = BlockedEmail.objects.filter(email__iexact=email).delete()
        if deleted:
            # Blocking closes every ticket from the address, and closed means
            # archived. Undo that here, or unblocking looks like it worked
            # while the tickets stay out of sight. Only closed ones are
            # reopened; resolved ones were finished deliberately.
            reopened = Ticket.objects.filter(
                sender_email__iexact=email, status=Ticket.STATUS_CLOSED,
            ).update(status=Ticket.STATUS_OPEN)
            if reopened:
                messages.success(
                    request,
                    f'{email} has been unblocked and {reopened} '
                    f'ticket{"s" if reopened != 1 else ""} moved back to the inbox.',
                )
            else:
                messages.success(request, f'{email} has been unblocked.')
        else:
            messages.warning(request, f'{email} was not in the block list.')
    return redirect(request.META.get('HTTP_REFERER', reverse('cms_tickets')))


@cms_required
def tickets_archive_closed(request):
    """Bulk delete all closed/resolved tickets (called from archive view)."""
    if request.method == 'POST':
        deleted, _ = Ticket.objects.filter(
            status__in=[Ticket.STATUS_CLOSED, Ticket.STATUS_RESOLVED]
        ).delete()
        messages.success(request, f'{deleted} ticket(s) permanently deleted.')
    return redirect(reverse('cms_tickets') + '?archived=1')


@cms_required
def chat_session_close(request, pk):
    """Archive (close) a single chat session."""
    tenant = getattr(request, 'tenant', None)
    session = get_object_or_404(ChatSession, pk=pk, tenant=tenant)
    if request.method == 'POST':
        from apps.chat.models import ChatSession as CS
        session.status = CS.STATUS_CLOSED
        session.save(update_fields=['status'])
        messages.success(request, 'Session archived.')
    next_tab = request.POST.get('next_tab', 'sessions')
    return redirect(reverse('cms_chat') + f'?tab={next_tab}')


@cms_required
def chat_archive_closed(request):
    """Bulk delete all closed chat sessions (optionally WA-only)."""
    if request.method == 'POST':
        tenant = getattr(request, 'tenant', None)
        from apps.chat.models import ChatSession as CS
        wa_only = request.POST.get('wa_only') == '1'
        qs = CS.objects.filter(tenant=tenant, status=CS.STATUS_CLOSED)
        if wa_only:
            qs = qs.filter(handoff_triggered=True)
        deleted, _ = qs.delete()
        messages.success(request, f'{deleted} closed session(s) permanently removed.')
    next_tab = request.POST.get('next_tab', 'sessions')
    return redirect(reverse('cms_chat') + f'?tab={next_tab}')


# ─── FAQ ──────────────────────────────────────────────────────────────────────

@cms_required
@module_required('faq')
def faq_list(request):
    q     = request.GET.get('q', '').strip()
    cat_f = request.GET.get('cat', '').strip()
    categories = FAQCategory.objects.annotate(item_count=Count('items')).all()
    total_items = FAQItem.objects.count()
    qs = FAQItem.objects.select_related('category').order_by('category__order', 'order')
    if q:
        qs = qs.filter(Q(question__icontains=q) | Q(answer__icontains=q))
    if cat_f:
        qs = qs.filter(category_id=cat_f)
    from django.core.paginator import Paginator
    paginator = Paginator(qs, 20)
    all_items = paginator.get_page(request.GET.get('page'))
    return render(request, 'cms/faq/list.html', {
        'categories': categories,
        'total_items': total_items,
        'all_items': all_items,
        'q': q,
        'cat_f': cat_f,
    })


@admin_required
def faq_category_edit(request, pk=None):
    obj = get_object_or_404(FAQCategory, pk=pk) if pk else FAQCategory()
    if request.method == 'POST':
        obj.name = request.POST.get('name', '')
        obj.description = request.POST.get('description', '')
        obj.icon_class = request.POST.get('icon_class', '')
        obj.order = int(request.POST.get('order', 1))
        obj.is_active = 'is_active' in request.POST
        if not obj.slug:
            from django.utils.text import slugify
            obj.slug = slugify(obj.name)
        obj.save()
        messages.success(request, 'Category saved.')
        return redirect('cms_faq')
    return render(request, 'cms/faq/category_edit.html', {'obj': obj})


@admin_required
def faq_category_delete(request, pk):
    get_object_or_404(FAQCategory, pk=pk).delete()
    messages.success(request, 'Category deleted.')
    return redirect('cms_faq')


@admin_required
def faq_item_edit(request, pk=None):
    obj = get_object_or_404(FAQItem, pk=pk) if pk else FAQItem()
    categories = FAQCategory.objects.all()
    cms_users = User.objects.filter(is_staff=True).order_by('first_name', 'last_name')
    if request.method == 'POST':
        obj.question = request.POST.get('question', '')
        obj.answer = request.POST.get('answer', '')
        if 'order' in request.POST:
            obj.order = int(request.POST.get('order') or 1)
        obj.is_active = 'is_active' in request.POST
        cat_id = request.POST.get('category')
        obj.category_id = int(cat_id) if cat_id else None
        added_by_id = request.POST.get('added_by')
        obj.added_by_id = int(added_by_id) if added_by_id else None
        missing = []
        if not obj.category_id:
            missing.append('category')
        if not obj.question.strip():
            missing.append('question')
        if not obj.answer.strip():
            missing.append('answer')
        if missing:
            messages.error(request, 'Please fill in the required fields: ' + ', '.join(missing) + '.')
        else:
            obj.save()
            messages.success(request, 'FAQ item saved.')
            return redirect('cms_faq')
    return render(request, 'cms/faq/item_edit.html', {
        'obj': obj,
        'categories': categories,
        'cms_users': cms_users,
    })


@admin_required
def faq_item_toggle_visibility(request, pk):
    item = get_object_or_404(FAQItem, pk=pk)
    FAQItem.objects.filter(pk=pk).update(is_active=not item.is_active)
    return redirect('cms_faq')


@admin_required
def faq_item_delete(request, pk):
    get_object_or_404(FAQItem, pk=pk).delete()
    messages.success(request, 'Deleted.')
    return redirect('cms_faq')


# ─── Users ────────────────────────────────────────────────────────────────────

def _allowed_role_choices(editor):
    """
    Return role choices the editor may assign.
    system_admin : can assign any role
    super_admin  : can assign business_admin and below (not system_admin)
    business_admin: can assign support_agent, content_manager, analyst, client
    Others       : cannot assign roles
    """
    all_roles = User.ROLE_CHOICES
    if editor.is_system_admin:
        return all_roles
    if editor.role == User.ROLE_SUPER_ADMIN:
        excluded = {User.ROLE_SYSTEM_ADMIN}
        return [(v, l) for v, l in all_roles if v not in excluded]
    if editor.role == User.ROLE_BUSINESS_ADMIN:
        allowed = {
            User.ROLE_SUPPORT_AGENT,
            User.ROLE_CONTENT_MANAGER,
            User.ROLE_ANALYST,
        }
        return [(v, l) for v, l in all_roles if v in allowed]
    return []


@admin_required
def users_list(request):
    from django.db.models import Case, When, IntegerField, Count
    role_filter = request.GET.get('role', '')
    role_order = Case(
        When(role='system_admin',    then=0),
        When(role='super_admin',     then=1),
        When(role='business_admin',  then=2),
        When(role='finance_admin',   then=3),
        When(role='support_agent',   then=4),
        When(role='content_manager', then=5),
        When(role='analyst',         then=6),
        default=7,
        output_field=IntegerField(),
    )
    # Count per role for the filter chips
    role_counts = {row['role']: row['count'] for row in User.objects.values('role').annotate(count=Count('pk'))}
    all_users = User.objects.annotate(role_order=role_order).order_by('role_order', 'first_name').prefetch_related('twofa')
    if role_filter:
        users = all_users.filter(role=role_filter)
    else:
        users = all_users
    role_choices = [(val, label, role_counts.get(val, 0)) for val, label in User.ROLE_CHOICES]
    return render(request, 'cms/users/list.html', {
        'users': users,
        'role_choices': role_choices,
        'role_filter': role_filter,
        'total_count': all_users.count(),
    })


@admin_required
def user_toggle_active(request, pk):
    if request.method == 'POST':
        from django.shortcuts import get_object_or_404
        u = get_object_or_404(User, pk=pk)
        if u.pk != request.user.pk:
            u.is_active = not u.is_active
            u.save(update_fields=['is_active'])
            status = 'activated' if u.is_active else 'deactivated'
            messages.success(request, f'{u.get_full_name() or u.username} {status}.')
        else:
            messages.error(request, 'You cannot deactivate your own account.')
    return redirect('cms_users')


@admin_required
def user_add(request):
    role_choices = _allowed_role_choices(request.user)
    if request.method == 'POST':
        username = request.POST.get('username', '').strip()
        email = request.POST.get('email', '').strip()
        password = request.POST.get('password', '').strip()
        role = request.POST.get('role', 'client')
        # Prevent admin from assigning super_admin/admin
        allowed_vals = [v for v, _ in role_choices]
        if role not in allowed_vals:
            role = 'client'
        import re as _re
        def _pw_score(pw):
            s = 0
            if len(pw) >= 10:              s += 25
            if _re.search(r'[A-Z]', pw):   s += 20
            if _re.search(r'[a-z]', pw):   s += 15
            if _re.search(r'[0-9]', pw):   s += 20
            if _re.search(r'[^A-Za-z0-9]', pw): s += 20
            return s
        if not username or not email or not password:
            messages.error(request, 'Username, email and password are required.')
        elif _pw_score(password) < 80:
            messages.error(request, f'Password too weak ({_pw_score(password)}/100). Use 10+ chars with uppercase, number and special character.')
        elif User.objects.filter(username=username).exists():
            messages.error(request, 'Username already taken.')
        else:
            u = User(
                username=username,
                email=email,
                first_name=request.POST.get('first_name', ''),
                last_name=request.POST.get('last_name', ''),
                role=role,
                phone=request.POST.get('phone', ''),
                company=request.POST.get('company', ''),
                position=request.POST.get('position', ''),
                is_active='is_active' in request.POST,
            )
            u.set_password(password)
            u.save()
            messages.success(request, f'User {username} created.')
            return redirect('cms_users')
    return render(request, 'cms/users/edit.html', {
        'obj': None,
        'role_choices': role_choices,
        'adding': True,
    })


@admin_required
def user_edit(request, pk):
    obj = get_object_or_404(User, pk=pk)
    editor = request.user
    role_choices = _allowed_role_choices(editor)
    # business_admin cannot edit super_admin or system_admin users
    if not editor.is_super_admin and obj.role in (User.ROLE_SYSTEM_ADMIN, User.ROLE_SUPER_ADMIN):
        messages.error(request, 'You do not have permission to edit this user.')
        return redirect('cms_users')
    if request.method == 'POST':
        new_username = request.POST.get('username', '').strip()
        if new_username and new_username != obj.username:
            if User.objects.filter(username=new_username).exclude(pk=obj.pk).exists():
                messages.error(request, 'Username already taken.')
                return render(request, 'cms/users/edit.html', {'obj': obj, 'role_choices': role_choices, 'adding': False, 'is_own': obj.pk == editor.pk})
            obj.username = new_username
        obj.first_name = request.POST.get('first_name', '')
        obj.last_name = request.POST.get('last_name', '')
        obj.email = request.POST.get('email', '')
        new_role = request.POST.get('role', obj.role)
        allowed_vals = [v for v, _ in role_choices]
        if new_role in allowed_vals:
            obj.role = new_role
        obj.phone = request.POST.get('phone', '')
        obj.company = request.POST.get('company', '')
        obj.position = request.POST.get('position', '')
        obj.is_active = 'is_active' in request.POST
        if 'avatar' in request.FILES:
            obj.avatar = request.FILES['avatar']
        elif 'clear_avatar' in request.POST:
            obj.avatar = None
        new_pw = request.POST.get('new_password', '').strip()
        repeat_pw = request.POST.get('repeat_password', '').strip()
        if new_pw:
            if new_pw != repeat_pw:
                messages.error(request, 'New passwords do not match.')
                return render(request, 'cms/users/edit.html', {'obj': obj, 'role_choices': role_choices, 'adding': False, 'is_own': obj.pk == editor.pk})
            if obj.pk == editor.pk:
                old_pw = request.POST.get('old_password', '').strip()
                if not obj.check_password(old_pw):
                    messages.error(request, 'Current password is incorrect.')
                    return render(request, 'cms/users/edit.html', {'obj': obj, 'role_choices': role_choices, 'adding': False, 'is_own': obj.pk == editor.pk})
            obj.set_password(new_pw)
            messages.success(request, 'Password updated.')
        obj.save()
        if not new_pw:
            messages.success(request, 'User updated.')
        return redirect('cms_user_edit', pk=obj.pk)
    from .models import TwoFactorAuth
    fa, _ = TwoFactorAuth.objects.get_or_create(user=obj)
    action_url = f'/cms/users/{obj.pk}/security/'
    sec_ctx = _user_security_ctx(request, obj, fa, action_url)
    ctx = {
        'obj': obj,
        'role_choices': role_choices,
        'adding': False,
        'is_own': obj.pk == request.user.pk,
    }
    ctx.update(sec_ctx)
    return render(request, 'cms/users/edit.html', ctx)


@admin_required
def user_security(request, pk):
    """
    Handle 2FA setup/management for a specific user.
    Self-service: any CMS user can manage their own 2FA.
    Super admins can manage any user's 2FA.
    """
    from .models import TwoFactorAuth
    from .twofa import (
        generate_totp_secret, get_totp_qr_base64, verify_totp,
        send_email_otp, verify_email_otp,
        send_sms_otp, verify_sms_otp,
        generate_backup_codes, hash_backup_code,
    )

    obj = get_object_or_404(User, pk=pk)
    editor = request.user

    # Only super_admin can manage other users' 2FA; everyone can manage own
    if obj.pk != editor.pk and not editor.is_super_admin:
        messages.error(request, 'You do not have permission to manage this user\'s security.')
        return redirect('cms_user_edit', pk=pk)

    fa, _ = TwoFactorAuth.objects.get_or_create(user=obj)
    action_url = f'/cms/users/{pk}/security/'

    if request.method == 'POST':
        action = request.POST.get('action')

        if action == 'setup_totp':
            secret = generate_totp_secret()
            request.session[f'totp_pending_{pk}'] = secret
            ctx = _user_security_ctx(request, obj, fa, action_url)
            ctx['totp_qr'] = get_totp_qr_base64(secret, obj.email)
            ctx['totp_secret'] = secret
            return render(request, 'cms/users/edit.html', ctx)

        elif action == 'confirm_totp':
            secret = request.POST.get('totp_secret') or request.session.get(f'totp_pending_{pk}', '')
            code = request.POST.get('totp_code', '').strip()
            if verify_totp(secret, code):
                fa.totp_secret = secret
                fa.totp_confirmed = True
                fa.is_enabled = True
                fa.preferred_method = TwoFactorAuth.METHOD_TOTP
                _sec_ensure_backup_codes(fa)
                fa.save()
                request.session.pop(f'totp_pending_{pk}', None)
                messages.success(request, 'Authenticator app enrolled. 2FA is now active.')
            else:
                messages.error(request, 'Invalid code. Please try again.')
            return redirect('cms_user_edit', pk=pk)

        elif action == 'setup_email':
            send_email_otp(obj)
            request.session[f'email_otp_sent_{pk}'] = True
            messages.success(request, f'Verification code sent to {obj.email}')
            return redirect('cms_user_edit', pk=pk)

        elif action == 'confirm_email':
            code = request.POST.get('email_code', '').strip()
            if verify_email_otp(obj, code):
                fa.email_confirmed = True
                fa.is_enabled = True
                if not fa.preferred_method or fa.preferred_method not in fa.available_methods:
                    fa.preferred_method = TwoFactorAuth.METHOD_EMAIL
                _sec_ensure_backup_codes(fa)
                fa.save()
                request.session.pop(f'email_otp_sent_{pk}', None)
                messages.success(request, 'Email OTP enrolled. 2FA is now active.')
            else:
                messages.error(request, 'Invalid code. Please try again.')
            return redirect('cms_user_edit', pk=pk)

        elif action == 'setup_sms':
            phone = request.POST.get('sms_phone', '').strip()
            if not phone:
                messages.error(request, 'Please enter a phone number.')
                return redirect('cms_user_edit', pk=pk)
            fa.sms_phone = phone
            fa.save(update_fields=['sms_phone'])
            obj.phone = phone
            obj.save(update_fields=['phone'])
            ok = send_sms_otp(obj)
            if ok:
                request.session[f'sms_otp_sent_{pk}'] = True
                request.session[f'sms_phone_{pk}'] = phone
                messages.success(request, f'Code sent to {phone}')
            else:
                messages.error(request, 'Could not send SMS. Check the phone number.')
            return redirect('cms_user_edit', pk=pk)

        elif action == 'confirm_sms':
            code = request.POST.get('sms_code', '').strip()
            if verify_sms_otp(obj, code):
                fa.sms_confirmed = True
                fa.is_enabled = True
                if not fa.preferred_method or fa.preferred_method not in fa.available_methods:
                    fa.preferred_method = TwoFactorAuth.METHOD_SMS
                _sec_ensure_backup_codes(fa)
                fa.save()
                request.session.pop(f'sms_otp_sent_{pk}', None)
                request.session.pop(f'sms_phone_{pk}', None)
                messages.success(request, 'SMS OTP enrolled. 2FA is now active.')
            else:
                messages.error(request, 'Invalid code. Please try again.')
            return redirect('cms_user_edit', pk=pk)

        elif action == 'set_preferred':
            method = request.POST.get('method')
            if method in fa.available_methods:
                fa.preferred_method = method
                fa.save(update_fields=['preferred_method'])
                messages.success(request, 'Default 2FA method updated.')
            return redirect('cms_user_edit', pk=pk)

        elif action == 'regenerate_backup_codes':
            plain_codes = generate_backup_codes(8)
            fa.backup_codes_list = [{'hash': hash_backup_code(c), 'used': False} for c in plain_codes]
            fa.save(update_fields=['backup_codes'])
            request.session[f'new_backup_codes_{pk}'] = plain_codes
            messages.success(request, 'New backup codes generated. Save them now.')
            return redirect('cms_user_edit', pk=pk)

        elif action == 'disable_2fa':
            fa.is_enabled = False
            fa.totp_confirmed = False
            fa.totp_secret = ''
            fa.email_confirmed = False
            fa.sms_confirmed = False
            fa.sms_phone = ''
            fa.backup_codes = '[]'
            fa.save()
            messages.success(request, '2FA has been disabled for this account.')
            return redirect('cms_user_edit', pk=pk)

    return redirect('cms_user_edit', pk=pk)


def _user_security_ctx(request, obj, fa, action_url):
    """Build the security panel context for a given user."""
    from .models import TwoFactorAuth
    from .twofa import get_totp_qr_base64
    pk = obj.pk
    ctx = {
        'obj': obj,
        'security_user': obj,
        'security_action_url': action_url,
        'fa': fa,
        'totp_qr': None,
        'totp_secret': None,
        'email_otp_sent': request.session.get(f'email_otp_sent_{pk}', False),
        'sms_otp_sent': request.session.get(f'sms_otp_sent_{pk}', False),
        'sms_phone': request.session.get(f'sms_phone_{pk}', ''),
        'new_backup_codes': request.session.pop(f'new_backup_codes_{pk}', None),
    }
    if request.session.get(f'totp_pending_{pk}'):
        secret = request.session[f'totp_pending_{pk}']
        ctx['totp_qr'] = get_totp_qr_base64(secret, obj.email)
        ctx['totp_secret'] = secret
    return ctx


def _sec_ensure_backup_codes(fa):
    from .twofa import generate_backup_codes, hash_backup_code
    if not fa.backup_codes_list:
        plain_codes = generate_backup_codes(8)
        fa.backup_codes_list = [{'hash': hash_backup_code(c), 'used': False} for c in plain_codes]


@super_admin_required
def user_send_2fa_request(request, pk):
    from django.core.mail import send_mail
    from django.conf import settings as django_settings
    obj = get_object_or_404(User, pk=pk)
    if request.method == 'POST' and obj.email:
        security_url = request.build_absolute_uri('/cms/security/')
        send_mail(
            subject='Action Required: Set Up Two-Factor Authentication',
            message=(
                f'Hi {obj.first_name or obj.username},\n\n'
                f'Your account administrator has requested that you enable Two-Factor Authentication (2FA) '
                f'to keep your CMS access secure.\n\n'
                f'Please log in and visit the following page to set it up:\n{security_url}\n\n'
                f'If you have any questions, contact your administrator.\n\n'
                f'{_company()} Security'
            ),
            from_email=_outgoing(),
            recipient_list=[obj.email],
            fail_silently=False,
        )
        messages.success(request, f'2FA setup request sent to {obj.email}.')
    return redirect('cms_user_edit', pk=pk)


@super_admin_required
def user_delete(request, pk):
    obj = get_object_or_404(User, pk=pk)
    if obj.pk == request.user.pk:
        messages.error(request, 'You cannot delete your own account.')
        return redirect('cms_users')
    if request.method == 'POST':
        username = obj.username
        obj.delete()
        messages.success(request, f'User {username} deleted.')
    return redirect('cms_users')



# ─── Cookie Consent, a subscription add-on ───────────────────────────────────

@super_admin_required
def consent_settings(request):
    """
    Configure the cookie consent banner.

    Deliberately not part of Site Settings: this is a paid module, and a tenant
    without it should not see a switch it cannot use. The page refuses rather
    than hiding fields, so an install that lost the subscription gets a clear
    answer instead of a form that silently does nothing.
    """
    tenant = _resolve_tenant(request)
    if tenant and not tenant.has_module('consent'):
        messages.info(request, 'Cookie Consent is an add-on. It is not on this plan.')
        return redirect('cms_plugins')

    obj = SiteSettings.load(tenant)
    if request.method == 'POST':
        obj.cookie_consent_enabled = 'cookie_consent_enabled' in request.POST
        obj.cookie_consent_message = request.POST.get('cookie_consent_message', '').strip()
        obj.cookie_consent_accept_label = request.POST.get('cookie_consent_accept_label', '').strip()[:40]
        obj.cookie_consent_essential_label = request.POST.get('cookie_consent_essential_label', '').strip()[:40]
        if tenant and not getattr(obj, 'tenant_id', None):
            obj.tenant = tenant
        obj.save()
        messages.success(request, 'Cookie consent updated.')
        return redirect('cms_consent')

    # The message is a rich text field now, so the editor needs its config the
    # same way the product and services pages get it.
    import json as _json
    from django.conf import settings as _s
    ck_config = _json.dumps(getattr(_s, 'CKEDITOR_5_CONFIGS', {}).get('extended', {}))
    return render(request, 'cms/consent.html', {'obj': obj, 'ck_config': ck_config})


# ─── Site Settings ────────────────────────────────────────────────────────────

@super_admin_required
def site_settings(request):
    tenant = _resolve_tenant(request)
    obj = SiteSettings.load(tenant)
    # Fields whose values are masked in the template — only save if changed
    _SECRET_FIELDS = {
        'recaptcha_secret_key', 'recaptcha_v2_secret_key',
        'stripe_secret_key', 'stripe_webhook_secret',
        'twilio_auth_token', 'smtp_password', 'support_password', 'openai_api_key',
        'fab_oauth_client_secret', 'ga_credentials_json',
        'openai_admin_key',
    }
    _MASK_PLACEHOLDER = '••••••••'

    if request.method == 'POST':
        fields = [
            'site_name', 'site_title_suffix', 'phone', 'email', 'address',
            'working_hours', 'google_maps_url', 'google_maps_embed',
            'instagram', 'facebook', 'twitter', 'linkedin', 'youtube',
            'founded_year',
            'google_analytics_id', 'google_tag_manager_id',
            'recaptcha_site_key', 'recaptcha_secret_key',
            'recaptcha_v2_site_key', 'recaptcha_v2_secret_key',
            'meta_description', 'meta_keywords',
            'stripe_public_key', 'stripe_secret_key', 'stripe_webhook_secret',
            'twilio_account_sid', 'twilio_auth_token', 'twilio_sms_from', 'twilio_from_number',
            'openai_api_key', 'openai_admin_key',
            'fab_oauth_client_id', 'fab_oauth_client_secret', 'ga_credentials_json',
            'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password',
            'support_email', 'support_password',
        ]
        for f in fields:
            if f in request.POST:
                val = request.POST[f]
                # Skip masked placeholder — means user didn't change the secret
                if f in _SECRET_FIELDS and (val == _MASK_PLACEHOLDER or val == ''):
                    continue
                setattr(obj, f, val)
        # Checkboxes (unchecked = not in POST)
        obj.smtp_use_ssl = 'smtp_use_ssl' in request.POST
        obj.smtp_use_tls = 'smtp_use_tls' in request.POST
        # Brand images. Each field accepts either a plain upload or a base64
        # payload from the crop window, and SVG is passed through untouched
        # because converting a vector logo to WebP would throw away the point
        # of uploading a vector.
        for _field in ('logo', 'favicon', 'apple_touch_icon'):
            if f'clear_{_field}' in request.POST:
                setattr(obj, _field, None)
                continue
            cropped = request.POST.get(f'{_field}_b64', '').strip()
            upload = request.FILES.get(_field)
            if not cropped and not upload:
                continue
            if upload and (upload.content_type == 'image/svg+xml'
                           or upload.name.lower().endswith('.svg')):
                setattr(obj, _field, upload)
                continue
            source = cropped if cropped else upload
            orig = f'{_field}.png' if cropped else upload.name
            try:
                webp_file, _w, _h = _convert_to_webp(source, orig)
                setattr(obj, _field, webp_file)
            except Exception:
                if upload:
                    setattr(obj, _field, upload)
        credit = request.POST.get('openai_credit', '').strip()
        if credit:
            try:
                obj.openai_credit = Decimal(credit)
            except Exception:
                messages.error(request, 'OpenAI credit must be a number.')
        credit_date = request.POST.get('openai_credit_date', '').strip()
        obj.openai_credit_date = credit_date or None
        # Cookie Consent lives on its own page, see consent_settings below.
        # Assign tenant FK on new (unsaved) objects
        if tenant and not getattr(obj, 'tenant_id', None):
            obj.tenant = tenant
        obj.save()
        messages.success(request, 'Site settings saved.')
        return redirect('cms_settings')
    # Fetch Twilio + OpenAI balances concurrently to avoid blocking the worker
    import requests as _req
    from concurrent.futures import ThreadPoolExecutor, as_completed

    twilio_balance  = None
    twilio_currency = None

    def _openai_costs(since_date, admin_key):
        """
        OpenAI has no balance endpoint. Their own guidance is to read the costs
        endpoint and subtract, which is what this does. Needs an admin key
        (sk-admin-...), which is read only for billing.
        """
        try:
            import time
            start = int(time.mktime(since_date.timetuple()))
            total = 0.0
            page = None
            for _ in range(12):          # pages are one bucket each, cap the walk
                params = {'start_time': start, 'bucket_width': '1d', 'limit': 31}
                if page:
                    params['page'] = page
                r = _req.get(
                    'https://api.openai.com/v1/organization/costs',
                    headers={'Authorization': f'Bearer {admin_key}'},
                    params=params, timeout=8,
                )
                if r.status_code != 200:
                    return None
                d = r.json()
                for bucket in d.get('data', []):
                    for item in bucket.get('results', []):
                        total += float((item.get('amount') or {}).get('value') or 0)
                if not d.get('has_more'):
                    break
                page = d.get('next_page')
                if not page:
                    break
            return round(total, 2)
        except Exception:
            return None

    def _chat_spend(since_dt):
        """
        Fallback when no admin key is set. Prices the tokens we log ourselves,
        so it covers the chat widget only, not any other OpenAI use.
        """
        try:
            from django.db.models import Sum
            from apps.chat.models import ChatMessage
            from apps.chat.openai_client import MODEL

            pricing = {'gpt-4o': 4.00, 'gpt-4o-mini': 0.25}
            rate = pricing.get(MODEL, pricing['gpt-4o'])
            qs = ChatMessage.objects.filter(created_at__gte=since_dt, tokens__gt=0)
            if tenant:
                qs = qs.filter(session__tenant=tenant)
            tokens = qs.aggregate(t=Sum('tokens'))['t'] or 0
            return round(tokens / 1_000_000 * rate, 2)
        except Exception:
            return None

    def _fetch_twilio(sid, token):
        try:
            r = _req.get(
                f'https://api.twilio.com/2010-04-01/Accounts/{sid}/Balance.json',
                auth=(sid, token),
                timeout=5,
            )
            if r.status_code == 200:
                d = r.json()
                return d.get('balance'), d.get('currency', 'USD')
        except Exception:
            pass
        return None, None

    futures = {}
    with ThreadPoolExecutor(max_workers=1) as ex:
        if obj and obj.twilio_account_sid and obj.twilio_auth_token:
            futures['twilio'] = ex.submit(_fetch_twilio, obj.twilio_account_sid, obj.twilio_auth_token)

    if 'twilio' in futures:
        twilio_balance, twilio_currency = futures['twilio'].result()

    openai_spend = openai_balance = openai_source = None
    if obj and obj.openai_credit_date:
        from datetime import datetime, time as _time
        since_dt = timezone.make_aware(datetime.combine(obj.openai_credit_date, _time.min))
        if obj.openai_admin_key:
            openai_spend = _openai_costs(obj.openai_credit_date, obj.openai_admin_key)
            openai_source = 'OpenAI costs API'
        if openai_spend is None:
            openai_spend = _chat_spend(since_dt)
            openai_source = 'chat tokens logged here'
        if openai_spend is not None:
            openai_balance = round(float(obj.openai_credit) - openai_spend, 2)

    return render(request, 'cms/settings.html', {
        'obj': obj,
        'twilio_balance':  twilio_balance,
        'twilio_currency': twilio_currency,
        'openai_spend':    openai_spend,
        'openai_balance':  openai_balance,
        'openai_source':   openai_source,
    })


@cms_required
def social_links_list(request):
    tenant = _resolve_tenant(request)
    links = SocialLink.objects.filter(tenant=tenant).order_by('order', 'platform')
    return render(request, 'cms/social/list.html', {
        'links': links,
        'platform_choices': SocialLink.PLATFORM_CHOICES,
    })


@cms_required
def social_link_edit(request, pk=None):
    tenant = _resolve_tenant(request)
    obj = get_object_or_404(SocialLink, pk=pk, tenant=tenant) if pk else None
    if request.method == 'POST':
        platform = request.POST.get('platform', '')
        url      = request.POST.get('url', '').strip()
        is_active = request.POST.get('is_active') == 'on'
        if not platform or not url:
            messages.error(request, 'Platform and URL are required.')
        else:
            if obj is None:
                max_order = SocialLink.objects.filter(tenant=tenant).count()
                obj = SocialLink(tenant=tenant, order=max_order)
            obj.platform  = platform
            obj.url       = url
            obj.is_active = is_active
            obj.save()
            messages.success(request, 'Social link saved.')
            return redirect('cms_social_links')
    return render(request, 'cms/social/edit.html', {
        'obj': obj,
        'platform_choices': SocialLink.PLATFORM_CHOICES,
    })


@cms_required
def social_link_toggle_visibility(request, pk):
    tenant = _resolve_tenant(request)
    link = get_object_or_404(SocialLink, pk=pk, tenant=tenant)
    SocialLink.objects.filter(pk=pk).update(is_active=not link.is_active)
    return redirect('cms_social_links')


@cms_required
def social_link_delete(request, pk):
    tenant = _resolve_tenant(request)
    link = get_object_or_404(SocialLink, pk=pk, tenant=tenant)
    if request.method == 'POST':
        link.delete()
        messages.success(request, 'Social link deleted.')
    return redirect('cms_social_links')


# ─── Legal Pages ─────────────────────────────────────────────────────────────

@cms_required
def legal_pages_list(request):
    from django_ckeditor_5.widgets import CKEditor5Widget
    tenant = _resolve_tenant(request)

    # Purchase Policy is not offered.
    #
    # It is the terms of a sale, and a site that sells nothing has no sale to
    # set terms for, so it sat in this list on every install as a page nobody
    # would ever write. The choice stays in LegalPage.SLUG_CHOICES and any row
    # already written keeps its content, so bringing it back is deleting these
    # two references rather than a migration.
    HIDDEN = {'purchase-policy'}

    # Deliberately not creating anything here.
    #
    # This used to call LegalPage.load() for every slug on every visit, so a
    # page deleted from this screen was recreated the moment the list
    # reloaded. Add Page is how a missing one comes back.
    pages = LegalPage.objects.filter(tenant=tenant).exclude(slug__in=HIDDEN).order_by('order', 'slug')
    return render(request, 'cms/legal/list.html', {'pages': pages})


@cms_required
def legal_page_edit(request, slug):
    from django import forms as dj_forms
    from django_ckeditor_5.widgets import CKEditor5Widget

    tenant = _resolve_tenant(request)
    obj = LegalPage.load(slug, tenant)

    class LegalPageForm(dj_forms.ModelForm):
        content = dj_forms.CharField(
            widget=CKEditor5Widget(config_name='extended'),
            required=False,
        )
        class Meta:
            model = LegalPage
            fields = ['title', 'content']
            widgets = {
                'title': dj_forms.TextInput(attrs={'class': 'form-control'}),
            }

    if request.method == 'POST':
        form = LegalPageForm(request.POST, instance=obj)
        if form.is_valid():
            form.save()
            messages.success(request, f'{obj.title} saved.')
            return redirect('cms_legal_pages')
    else:
        form = LegalPageForm(instance=obj)

    return render(request, 'cms/legal/edit.html', {'obj': obj, 'form': form})


@cms_required
def legal_page_add(request):
    from django import forms as dj_forms
    from django_ckeditor_5.widgets import CKEditor5Widget
    import re

    tenant = _resolve_tenant(request)

    class LegalPageAddForm(dj_forms.Form):
        title   = dj_forms.CharField(max_length=200, widget=dj_forms.TextInput(attrs={'class': 'form-control'}))
        slug    = dj_forms.SlugField(max_length=60, required=False, widget=dj_forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'auto-generated if left blank'}))
        content = dj_forms.CharField(widget=CKEditor5Widget(config_name='extended'), required=False)

    if request.method == 'POST':
        form = LegalPageAddForm(request.POST)
        if form.is_valid():
            title   = form.cleaned_data['title']
            slug    = form.cleaned_data['slug'] or re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')
            content = form.cleaned_data['content']
            if LegalPage.objects.filter(slug=slug, tenant=tenant).exists():
                messages.error(request, f'A page with slug "{slug}" already exists.')
            else:
                LegalPage.objects.create(tenant=tenant, slug=slug, title=title, content=content)
                messages.success(request, f'"{title}" created.')
                return redirect('cms_legal_pages')
    else:
        form = LegalPageAddForm()

    return render(request, 'cms/legal/edit.html', {'obj': None, 'form': form, 'adding': True})


@cms_required
def legal_page_toggle(request, pk):
    page = get_object_or_404(LegalPage, pk=pk)
    LegalPage.objects.filter(pk=pk).update(is_visible=not page.is_visible)
    return redirect('cms_legal_pages')


@cms_required
@require_POST
def legal_page_delete(request, pk):
    """
    Remove a legal page.

    Only useful because legal_pages_list no longer recreates the standard
    pages on every visit. It used to, so anything deleted came straight back
    on the next page load, which is why there was no delete button at all.

    The public route still exists, so /privacy-policy/ 404s once its record is
    gone rather than erroring. Add Page puts one back.
    """
    page = get_object_or_404(LegalPage, pk=pk)
    title = page.title or page.slug
    page.delete()
    messages.success(request, f'{title} deleted.')
    return redirect('cms_legal_pages')


# ─── Tenants (Super Admin only) ───────────────────────────────────────────────

def _is_fleet_master():
    """True only on the install that runs the fleet.

    Tenant.is_master marks the row holding a site's own identity and is set on
    every install, so `not tenant.is_master` was False everywhere and five
    guards in the health screens never fired. A client could read the uptime of
    every other client on the estate. The fleet master is the install with no
    upstream to check in to.
    """
    from django.conf import settings as _s
    return not (getattr(_s, 'FT_CONTROL_URL', '') or '')


def _fleet_master_only(request):
    """Refuse fleet screens on an install that is not the fleet master.

    Tenant.is_master marks the row carrying a site's own identity, and every
    install has one, so it never distinguished the master from a client. The
    fleet master is the install with no upstream to report to. Hiding the menu
    entry was not enough: /cms/tenants/ and /cms/fleet/ answered to anyone who
    typed them, and both list every other client on the estate.
    """
    from django.http import Http404
    if not _is_fleet_master():
        raise Http404



@super_admin_required
def tenants_list(request):
    _fleet_master_only(request)
    from django.core.paginator import Paginator
    from django.db.models import Count
    q           = request.GET.get('q', '').strip()
    active_plan = request.GET.get('plan', '').strip()

    show_archived = request.GET.get('archived') == '1'

    # YOUR COMPANY itself is not a tenant. It is the master install that sets
    # the release, so it is not listed, counted or billed here. Fleet is where
    # it appears, as the reference row.
    qs = Tenant.objects.filter(is_master=False).select_related('owner', 'account_manager').order_by('name')
    qs = qs.filter(is_archived=show_archived)
    if q:
        qs = qs.filter(name__icontains=q) | qs.filter(primary_domain__icontains=q)
    if active_plan:
        qs = qs.filter(plan=active_plan)

    # counts per plan for filter chips
    all_qs = Tenant.objects.filter(is_archived=show_archived, is_master=False)
    if q:
        all_qs = all_qs.filter(name__icontains=q) | all_qs.filter(primary_domain__icontains=q)
    plan_counts = {
        row['plan']: row['cnt']
        for row in all_qs.values('plan').annotate(cnt=Count('id'))
    }
    plans_with_counts = [
        (val, label, plan_counts.get(val, 0))
        for val, label in Tenant.PLAN_CHOICES
        if plan_counts.get(val, 0) > 0
    ]

    paginator = Paginator(qs, 20)
    tenants   = paginator.get_page(request.GET.get('page', 1))

    # Recent activity, shown as two panels below the table. Upgrade invoices
    # live here rather than on System Info, which describes this one install.
    from apps.tenants.models import TenantUpgradeCharge
    upgrade_charges = TenantUpgradeCharge.objects.select_related('tenant').order_by('-created_at')[:10]

    # Plugins are a newer app, so a missing table must not take the page down.
    try:
        from apps.marketplace.models import PluginRequest
        plugin_requests = list(
            PluginRequest.objects.select_related('tenant').order_by('-created_at')[:10]
        )
    except Exception:
        plugin_requests = []

    return render(request, 'cms/tenants/list.html', {
        'tenants': tenants,
        'upgrade_charges': upgrade_charges,
        'plugin_requests': plugin_requests,
        'q': q,
        'active_plan': active_plan,
        'plans_with_counts': plans_with_counts,
        'total_count': all_qs.count(),
        'latest_version': _LATEST_PLATFORM_VERSION,
        'show_archived': show_archived,
        'archived_count': Tenant.objects.filter(is_archived=True, is_master=False).count(),
    })


@super_admin_required
def versions_list(request):
    from django.conf import settings as _s
    current = getattr(_s, 'CMS_VERSION', '')
    tenants = Tenant.objects.all().order_by('name')
    up_to_date = sum(1 for t in tenants if t.cms_version == current)
    outdated   = sum(1 for t in tenants if t.cms_version and t.cms_version != current)
    return render(request, 'cms/versions/list.html', {
        'tenants': tenants,
        'up_to_date': up_to_date,
        'outdated': outdated,
    })


def _back_to_row(request, pk):
    """
    Return to the list on the row that was just acted on.

    Without this, every action bounces you to the top of page one, which on a
    long list means finding your place again after every click.
    """
    nxt = (request.POST.get('next') or '').split('#')[0]
    if nxt.startswith('/') and not nxt.startswith('//'):
        return redirect(f'{nxt}#trow-{pk}')
    return redirect('cms_tenants')


@super_admin_required
def version_upgrade_free(request, pk):
    """Grant a free upgrade to a tenant — no charge."""
    from apps.tenants.models import TenantUpgradeCharge
    t = get_object_or_404(Tenant, pk=pk)
    if request.method == 'POST':
        # Record it as a waived charge. A free upgrade is still something the
        # client received, and without a row there is no evidence it happened.
        TenantUpgradeCharge.objects.create(
            tenant=t,
            version=_LATEST_PLATFORM_VERSION,
            fee=Decimal('0'),
            status=TenantUpgradeCharge.STATUS_WAIVED,
            notes=f'Granted free by {request.user.get_full_name() or request.user.username}',
        )
        t.cms_version = _LATEST_PLATFORM_VERSION
        t.save(update_fields=['cms_version'])
        messages.success(request, f'{t.name} upgraded to v{_LATEST_PLATFORM_VERSION}, granted free.')
    return _back_to_row(request, t.pk)


@super_admin_required
def version_upgrade_paid(request, pk):
    """Create an upgrade invoice and stamp the tenant with the latest version."""
    from apps.tenants.models import TenantUpgradeCharge
    t = get_object_or_404(Tenant, pk=pk)
    if request.method == 'POST':
        try:
            fee = Decimal(request.POST.get('fee', '0'))
        except Exception:
            fee = Decimal('0')
        notes = request.POST.get('notes', '').strip()
        TenantUpgradeCharge.objects.create(
            tenant=t,
            version=_LATEST_PLATFORM_VERSION,
            fee=fee,
            status=TenantUpgradeCharge.STATUS_PENDING,
            notes=notes,
        )
        t.cms_version = _LATEST_PLATFORM_VERSION
        t.save(update_fields=['cms_version'])
        messages.success(request, f'{t.name} upgraded to v{_LATEST_PLATFORM_VERSION}. Invoice of £{fee} created.')
    return _back_to_row(request, t.pk)


@super_admin_required
def upgrade_charge_status(request, charge_pk):
    """Mark an upgrade charge as paid or waived."""
    from apps.tenants.models import TenantUpgradeCharge
    charge = get_object_or_404(TenantUpgradeCharge, pk=charge_pk)
    if request.method == 'POST':
        new_status = request.POST.get('status', '')
        if new_status in (TenantUpgradeCharge.STATUS_PAID, TenantUpgradeCharge.STATUS_WAIVED):
            charge.status = new_status
            charge.save(update_fields=['status'])
            messages.success(request, f'Charge for {charge.tenant.name} marked as {charge.get_status_display()}.')
    return redirect('cms_tenants')


# Legacy alias kept for any old links
@super_admin_required
def version_upgrade(request, pk):
    return version_upgrade_free(request, pk)


@super_admin_required
def tenant_edit(request, pk=None):
    obj = get_object_or_404(Tenant, pk=pk) if pk else None
    if request.method == 'POST':
        name   = request.POST.get('name', '').strip()
        domain = request.POST.get('primary_domain', '').strip()
        cms_sub = request.POST.get('cms_subdomain', '').strip()
        plan    = request.POST.get('plan', '').strip()
        missing = []
        if not name:
            missing.append('site name')
        if not domain:
            missing.append('primary domain')
        if not cms_sub:
            missing.append('panel subdomain')
        if not plan:
            missing.append('plan')
        if missing:
            messages.error(request, 'Please fill in the required fields: ' + ', '.join(missing) + '.')
        else:
            is_new = obj is None
            if is_new:
                obj = provision_tenant(
                    name=name,
                    primary_domain=domain,
                    cms_subdomain=cms_sub,
                    plan=plan,
                )
                if request.user.is_authenticated and not obj.created_by_id:
                    obj.created_by = request.user
            else:
                obj.name = name
                obj.primary_domain = domain
                obj.cms_subdomain = cms_sub
            obj.plan = plan
            # Letters only, upper case, four at most. A blank box means the
            # slug decides, which is handled by Tenant.prefix.
            import re as _re
            obj.ref_prefix = _re.sub(r'[^A-Za-z]', '', request.POST.get('ref_prefix', ''))[:4].upper()
            obj.accent_colour = request.POST.get('accent_colour', '#e8b400')
            obj.cms_dark_colour = request.POST.get('cms_dark_colour', '#0d0d0d')
            obj.cms_text_colour = request.POST.get('cms_text_colour', '#b8d4ea')
            obj.notes = request.POST.get('notes', '')
            obj.is_active  = 'is_active' in request.POST
            obj.is_master  = 'is_master' in request.POST
            obj.chat_enabled = 'chat_enabled' in request.POST
            obj.monitor_enabled = 'monitor_enabled' in request.POST
            obj.whatsapp_enabled = 'whatsapp_enabled' in request.POST
            logo_b64 = request.POST.get('logo_b64', '').strip()
            if 'clear_logo' in request.POST:
                obj.logo = None
            elif logo_b64 or 'logo' in request.FILES:
                source = logo_b64 if logo_b64 else request.FILES['logo']
                orig   = (name or 'logo') if logo_b64 else request.FILES['logo'].name
                try:
                    webp_file, _w, _h = _convert_to_webp(source, orig)
                    obj.logo = webp_file
                except Exception:
                    if 'logo' in request.FILES:
                        obj.logo = request.FILES['logo']
            obj.save()
            try:
                from django.core.cache import cache
                cache.delete('ft:panel_hosts')
            except Exception:
                pass
            messages.success(request, f'Tenant "{obj.name}" saved.')
            return redirect('cms_tenants')
    modules_summary = None
    if obj:
        from apps.tenants.models import (
            MODULE_CHOICES, ALWAYS_ON_MODULES, PLAN_MODULES, TenantModule,
        )
        labels = dict(MODULE_CHOICES)
        enabled_keys = set(
            TenantModule.objects.filter(tenant=obj, is_enabled=True)
            .values_list('module', flat=True)
        ) | set(ALWAYS_ON_MODULES)
        plan_keys = set(PLAN_MODULES.get(obj.plan, set())) | set(ALWAYS_ON_MODULES)
        all_keys = [k for k, _ in MODULE_CHOICES]

        # Anything switched on that the plan does not include is an extra the
        # client is getting beyond their tier, worth seeing at a glance.
        extras = sorted(labels[k] for k in enabled_keys - plan_keys if k in labels)

        modules_summary = {
            'enabled':       sorted(labels[k] for k in enabled_keys if k in labels),
            'disabled':      sorted(labels[k] for k in all_keys if k not in enabled_keys),
            'enabled_count': len(enabled_keys & set(all_keys)),
            'total':         len(all_keys),
            'extras':        extras,
        }

    billing = None
    if obj:
        from django.db.models import Sum, Count
        from apps.tenants.models import TenantInvoice
        invoices = TenantInvoice.objects.filter(tenant=obj)

        def _total(status):
            return invoices.filter(status=status).aggregate(s=Sum('amount'))['s'] or Decimal('0')

        billing = {
            'monthly_fee':  obj.monthly_fee,
            'billing_day':  obj.billing_day,
            'next_renewal': obj.next_renewal_date,
            'email':        obj.billing_email,
            'count':        invoices.count(),
            'paid':         _total(TenantInvoice.STATUS_PAID),
            'pending':      _total(TenantInvoice.STATUS_PENDING),
            'overdue':      _total(TenantInvoice.STATUS_OVERDUE),
            'overdue_n':    invoices.filter(status=TenantInvoice.STATUS_OVERDUE).count(),
            'last':         invoices.order_by('-created_at').first(),
        }

    return render(request, 'cms/tenants/edit.html', {
        'obj': obj,
        'plan_choices': Tenant.PLAN_CHOICES,
        'billing': billing,
        'modules_summary': modules_summary,
    })


@super_admin_required
def tenant_modules(request, pk):
    from apps.tenants.models import TenantModuleLog

    obj = get_object_or_404(Tenant, pk=pk)
    if request.method == 'POST':
        before = obj.enabled_module_keys
        changed = 0

        # How this change should be recorded. Anything unexpected falls back to
        # a plain admin change rather than inventing a purchase.
        valid_sources = {
            TenantModuleLog.SOURCE_MANUAL,
            TenantModuleLog.SOURCE_FREE,
            TenantModuleLog.SOURCE_PURCHASE,
        }
        reason = request.POST.get('change_reason', TenantModuleLog.SOURCE_MANUAL)
        if reason not in valid_sources:
            reason = TenantModuleLog.SOURCE_MANUAL

        amount = None
        if reason == TenantModuleLog.SOURCE_PURCHASE:
            raw = (request.POST.get('change_amount') or '').strip()
            if raw:
                try:
                    amount = Decimal(raw)
                except Exception:
                    amount = None
        note = (request.POST.get('change_note') or '').strip()[:200]
        for module_key, _ in MODULE_CHOICES:
            if module_key in ALWAYS_ON_MODULES:
                continue  # always-on cannot be toggled
            enabled = f'module_{module_key}' in request.POST
            TenantModule.objects.update_or_create(
                tenant=obj, module=module_key,
                defaults={'is_enabled': enabled}
            )
            # Only a real change is worth a history entry, otherwise every
            # save would write eighteen rows saying nothing happened.
            if enabled != (module_key in before):
                TenantModuleLog.objects.create(
                    tenant=obj, module=module_key, is_enabled=enabled,
                    # Turning something off is never an acquisition, so it is
                    # always recorded as a plain admin change.
                    source=reason if enabled else TenantModuleLog.SOURCE_MANUAL,
                    amount=amount if enabled else None,
                    note=note,
                    changed_by=request.user if request.user.is_authenticated else None,
                )
                changed += 1
        if changed:
            messages.success(request, f'Modules updated, {changed} change(s) recorded.')
        else:
            messages.info(request, 'No module changes to save.')
        return redirect('cms_tenant_modules', pk=pk)
    return render(request, 'cms/tenants/modules.html', {
        'obj': obj,
        'module_choices': MODULE_CHOICES,
        'always_on': ALWAYS_ON_MODULES,
        'enabled': obj.enabled_module_keys,
        'module_logs': obj.module_logs.select_related('changed_by')[:40],
        'acquisitions': obj.module_logs.filter(
            is_enabled=True,
            source__in=TenantModuleLog.ACQUISITION_SOURCES,
        ).select_related('changed_by'),
        'source_choices': [
            (TenantModuleLog.SOURCE_MANUAL,   'Added by your provider'),
            (TenantModuleLog.SOURCE_FREE,     'Granted free'),
            (TenantModuleLog.SOURCE_PURCHASE, 'Purchased by client'),
        ],
    })


@super_admin_required
def tenant_delete(request, pk):
    obj = get_object_or_404(Tenant, pk=pk)
    blockers = obj.delete_blockers
    if blockers:
        messages.error(
            request,
            'This tenant cannot be deleted because ' + ', and '.join(blockers) + '. '
            'Archive them instead, which keeps every record.'
        )
        return redirect('cms_tenant_edit', pk=obj.pk)
    if request.method == 'POST':
        name = obj.name
        # Read everything worth reporting before the row goes, since none of it
        # can be looked up afterwards.
        detail = [
            ('Tenant', name),
            ('Domain', obj.primary_domain or 'not set'),
            ('Plan', obj.get_plan_display()),
            ('Created', obj.created_at.strftime('%d %b %Y')),
            ('Deleted by', request.user.get_full_name() or request.user.username),
            ('When', timezone.localtime().strftime('%d %b %Y, %H:%M')),
        ]
        obj.delete()
        # After the delete, never before: an alert about something that then
        # failed is worse than no alert.
        try:
            from apps.ftlink.alerts import send_admin_alert
            send_admin_alert(
                headline=f'Tenant deleted: {name}',
                intro=('A tenant record and everything held against it in this panel have been '
                       'permanently removed. This cannot be undone from the panel.'),
                rows=detail,
                panel_path='tenants/',
                short=f'ALERT: tenant {name} deleted by '
                      f'{request.user.get_full_name() or request.user.username}',
                kicker='Irreversible Action',
                accent='#b91c1c',
            )
        except Exception:
            log.exception('Tenant delete alert failed for %s', name)
        # The list page runs its own countdown and has already told the user in
        # red. A green success banner arriving afterwards reads as a second,
        # cheerier event, so the caller can ask for silence.
        if 'silent' not in request.POST:
            messages.success(request, f'Tenant "{name}" deleted.')
        return redirect('cms_tenants')
    return render(request, 'cms/tenants/confirm_delete.html', {
        'obj': obj,
        'blockers': blockers,
    })


@super_admin_required
@require_POST
def tenant_archive(request, pk):
    """Client has left. Stop the site, keep every record."""
    obj = get_object_or_404(Tenant, pk=pk)
    if obj.is_master:
        messages.error(request, 'The master tenant cannot be archived.')
        return redirect('cms_tenant_edit', pk=obj.pk)
    obj.archive(request.POST.get('reason', ''))
    try:
        from django.core.cache import cache
        cache.delete('ft:panel_hosts')
    except Exception:
        pass
    messages.success(
        request,
        f'"{obj.name}" archived. Their site is switched off and all records are kept.'
    )
    return redirect('cms_tenants')


@super_admin_required
@require_POST
def tenant_restore(request, pk):
    """Bring an archived client back."""
    obj = get_object_or_404(Tenant, pk=pk)
    obj.restore()
    try:
        from django.core.cache import cache
        cache.delete('ft:panel_hosts')
    except Exception:
        pass
    messages.success(request, f'"{obj.name}" restored and switched back on.')
    return redirect('cms_tenant_edit', pk=obj.pk)


@super_admin_required
def tenant_toggle_active(request, pk):
    """Toggle is_active on a non-master tenant."""
    obj = get_object_or_404(Tenant, pk=pk)
    if obj.is_master:
        messages.error(request, 'Cannot disable the master tenant.')
        return redirect('cms_tenants')
    if request.method == 'POST':
        obj.is_active = not obj.is_active
        fields = ['is_active']

        # Enabling an archived tenant has to clear the archive too, otherwise
        # they read as Active while still sitting in the archived list.
        restored = False
        if obj.is_active and obj.is_archived:
            obj.is_archived = False
            obj.archived_at = None
            obj.archived_reason = ''
            fields += ['is_archived', 'archived_at', 'archived_reason']
            restored = True

        obj.save(update_fields=fields)
        try:
            from django.core.cache import cache
            cache.delete('ft:panel_hosts')
        except Exception:
            pass

        if restored:
            messages.success(request, f'Tenant "{obj.name}" enabled and restored from the archive.')
        else:
            state = 'enabled' if obj.is_active else 'disabled'
            messages.success(request, f'Tenant "{obj.name}" {state}.')
    return _back_to_row(request, obj.pk)


@super_admin_required
def tenant_billing(request, pk):
    from apps.tenants.models import TenantInvoice
    obj = get_object_or_404(Tenant, pk=pk)
    if request.method == 'POST':
        obj.monthly_fee       = request.POST.get('monthly_fee', 0) or 0
        obj.billing_day       = int(request.POST.get('billing_day', 1) or 1)
        obj.billing_email     = request.POST.get('billing_email', '').strip()
        renewal = request.POST.get('next_renewal_date', '').strip()
        if renewal:
            from datetime import date
            try:
                obj.next_renewal_date = date.fromisoformat(renewal)
            except ValueError:
                pass
        else:
            obj.next_renewal_date = None

        obj.save(update_fields=['monthly_fee', 'billing_day', 'billing_email', 'next_renewal_date'])
        messages.success(request, 'Billing settings saved.')
        return redirect('cms_tenant_billing', pk=pk)
    from apps.tenants.models import TenantUpgradeCharge
    invoices = TenantInvoice.objects.filter(tenant=obj).order_by('-created_at')
    upgrade_charges = TenantUpgradeCharge.objects.filter(tenant=obj).order_by('-created_at')
    return render(request, 'cms/tenants/billing.html', {
        'upgrade_charges': upgrade_charges,
        'obj': obj,
        'invoices': invoices,
        'status_choices': TenantInvoice.STATUS_CHOICES,
        'type_choices': TenantInvoice.TYPE_CHOICES,
    })


@super_admin_required
def tenant_invoice_add(request, pk):
    from apps.tenants.models import TenantInvoice
    from datetime import date
    obj = get_object_or_404(Tenant, pk=pk)
    if request.method == 'POST':
        try:
            amount = Decimal(request.POST.get('amount', '0'))
        except Exception:
            amount = Decimal('0')
        due_raw = request.POST.get('due_date', '').strip()
        try:
            due_date = date.fromisoformat(due_raw)
        except ValueError:
            due_date = date.today()
        p_start = request.POST.get('period_start', '').strip()
        p_end   = request.POST.get('period_end', '').strip()
        TenantInvoice.objects.create(
            tenant=obj,
            invoice_type=request.POST.get('invoice_type', TenantInvoice.TYPE_MONTHLY),
            description=request.POST.get('description', '').strip(),
            amount=amount,
            status=request.POST.get('status', TenantInvoice.STATUS_PENDING),
            due_date=due_date,
            period_start=date.fromisoformat(p_start) if p_start else None,
            period_end=date.fromisoformat(p_end) if p_end else None,
            notes=request.POST.get('notes', '').strip(),
        )
        messages.success(request, 'Invoice created.')
    return redirect('cms_tenant_billing', pk=pk)


@super_admin_required
def tenant_invoice_detail(request, invoice_pk):
    """Read-only view of a single invoice."""
    from apps.tenants.models import TenantInvoice
    invoice = get_object_or_404(TenantInvoice.objects.select_related('tenant'), pk=invoice_pk)
    return render(request, 'cms/tenants/invoice_detail.html', {
        'invoice': invoice,
        'obj': invoice.tenant,
    })


@super_admin_required
@require_POST
def tenant_invoice_delete(request, invoice_pk):
    """Remove an invoice raised in error."""
    from apps.tenants.models import TenantInvoice
    invoice = get_object_or_404(TenantInvoice, pk=invoice_pk)
    tenant_pk = invoice.tenant_id
    number = invoice.invoice_number
    if invoice.status == TenantInvoice.STATUS_PAID:
        messages.error(
            request,
            f'Invoice {number} is marked as paid, so it cannot be deleted. '
            'Set it to cancelled instead to keep the record.'
        )
    else:
        invoice.delete()
        messages.success(request, f'Invoice {number} deleted.')
    return redirect('cms_tenant_billing', pk=tenant_pk)


@super_admin_required
@require_POST
def tenant_invoice_send(request, invoice_pk):
    """Email the invoice to the client, for example when they never got it."""
    from apps.tenants.models import TenantInvoice
    from apps.ecommerce.email_utils import send_tenant_invoice
    invoice = get_object_or_404(TenantInvoice.objects.select_related('tenant', 'tenant__owner'), pk=invoice_pk)
    try:
        to = send_tenant_invoice(invoice)
        messages.success(request, f'Invoice {invoice.invoice_number} sent to {to}.')
    except ValueError as exc:
        messages.error(request, str(exc))
    except Exception as exc:
        messages.error(request, f'Could not send invoice {invoice.invoice_number}: {exc}')
    return redirect('cms_tenant_billing', pk=invoice.tenant_id)


@super_admin_required
def tenant_invoice_status(request, invoice_pk):
    from apps.tenants.models import TenantInvoice
    from django.utils import timezone
    invoice = get_object_or_404(TenantInvoice, pk=invoice_pk)
    if request.method == 'POST':
        new_status = request.POST.get('status', '')
        valid = [s for s, _ in TenantInvoice.STATUS_CHOICES]
        if new_status in valid:
            invoice.status = new_status
            if new_status == TenantInvoice.STATUS_PAID and not invoice.paid_at:
                invoice.paid_at = timezone.now()
            invoice.save(update_fields=['status', 'paid_at'])
            messages.success(request, f'Invoice {invoice.invoice_number} marked {invoice.get_status_display()}.')
    return redirect('cms_tenant_billing', pk=invoice.tenant_id)


@super_admin_required
def tenant_apply_plan(request, pk):
    """Apply the tenant's current plan module set — syncs TenantModule rows."""
    obj = get_object_or_404(Tenant, pk=pk)
    if request.method == 'POST':
        from apps.tenants.models import TenantModuleLog

        before = obj.enabled_module_keys
        new_plan = request.POST.get('plan', obj.plan)
        obj.plan = new_plan
        obj.save(update_fields=['plan'])
        obj.apply_plan_modules()

        after = obj.enabled_module_keys
        for key in sorted((before | after) - ALWAYS_ON_MODULES):
            now_on = key in after
            if now_on != (key in before):
                TenantModuleLog.objects.create(
                    tenant=obj, module=key, is_enabled=now_on,
                    source=TenantModuleLog.SOURCE_PLAN,
                    changed_by=request.user if request.user.is_authenticated else None,
                )
        messages.success(request, f'Plan changed to {obj.get_plan_display()}, modules updated.')
    return redirect('cms_tenant_modules', pk=pk)


# ─── AI Chat CMS views ────────────────────────────────────────────────────────

@cms_required
def chat_hub(request):
    """Unified AI Chat + WhatsApp hub (sessions / settings / whatsapp tabs)."""
    from apps.tenants.models import Tenant as TenantModel
    from apps.chat.models import ChatSession
    from django.urls import reverse

    tenant = getattr(request, 'tenant', None)
    if tenant is None or not hasattr(tenant, 'save'):
        try:
            tenant = TenantModel.objects.get(is_master=True)
        except TenantModel.DoesNotExist:
            messages.error(request, 'No master tenant found.')
            return redirect('cms_tenants')

    tab = request.GET.get('tab', 'sessions')

    if request.method == 'POST':
        form_type = request.POST.get('form_type', 'chat')
        if form_type == 'chat':
            tenant.chat_enabled = 'chat_enabled' in request.POST
            tenant.chat_greeting = request.POST.get('chat_greeting', '').strip()
            tenant.chat_system_prompt = request.POST.get('chat_system_prompt', '').strip()
            try:
                tenant.chat_inactivity_minutes = max(5, int(request.POST.get('chat_inactivity_minutes', 30)))
            except (ValueError, TypeError):
                tenant.chat_inactivity_minutes = 30
            tenant.chat_farewell_message = request.POST.get('chat_farewell_message', '').strip()
            try:
                tenant.chat_daily_token_budget = max(0, int(request.POST.get('chat_daily_token_budget', 150000)))
            except (ValueError, TypeError):
                tenant.chat_daily_token_budget = 150000
            tenant.chat_header_bg = request.POST.get('chat_header_bg', '#033152').strip() or '#033152'
            tenant.chat_accent_color = request.POST.get('chat_accent_color', '').strip()
            tenant.chat_bot_bubble_bg = request.POST.get('chat_bot_bubble_bg', '#f0f4ff').strip() or '#f0f4ff'
            tenant.save(update_fields=[
                'chat_enabled', 'chat_greeting', 'chat_system_prompt',
                'chat_inactivity_minutes', 'chat_farewell_message',
                'chat_daily_token_budget',
                'chat_header_bg', 'chat_accent_color', 'chat_bot_bubble_bg',
            ])
            messages.success(request, 'AI Chat settings saved.')
            tab = 'sessions'
        elif form_type == 'whatsapp':
            tenant.chat_whatsapp = request.POST.get('chat_whatsapp', '').strip()
            tenant.chat_whatsapp_trigger = request.POST.get('chat_whatsapp_trigger', '').strip()
            tenant.save(update_fields=['chat_whatsapp', 'chat_whatsapp_trigger'])
            messages.success(request, 'WhatsApp settings saved.')
            tab = 'whatsapp'
        from django.http import HttpResponseRedirect
        return HttpResponseRedirect(reverse('cms_chat') + '?tab=' + tab)

    # ── AI Chat sessions ──
    search       = request.GET.get('q', '')
    show_archive = request.GET.get('archived') == '1'

    base_ai = ChatSession.objects.filter(tenant=tenant).select_related('ticket')
    if show_archive:
        qs = base_ai.filter(status=ChatSession.STATUS_CLOSED)
    else:
        qs = base_ai.exclude(status=ChatSession.STATUS_CLOSED)
    if search:
        qs = qs.filter(
            Q(visitor_name__icontains=search) |
            Q(topic__icontains=search) |
            Q(visitor_email__icontains=search) |
            Q(visitor_ip__icontains=search)
        )
    # Annotate last message time for timing display
    qs = qs.annotate(last_msg_at=Max('messages__created_at'))
    active_count = base_ai.exclude(status=ChatSession.STATUS_CLOSED).count()
    closed_count = base_ai.filter(status=ChatSession.STATUS_CLOSED).count()

    # ── WhatsApp sessions ──
    wa_search      = request.GET.get('wa_q', '')
    wa_show_archive = request.GET.get('wa_archived') == '1'

    base_wa = ChatSession.objects.filter(tenant=tenant, handoff_triggered=True).select_related('ticket')
    if wa_show_archive:
        wa_qs = base_wa.filter(status=ChatSession.STATUS_CLOSED)
    else:
        wa_qs = base_wa.exclude(status=ChatSession.STATUS_CLOSED)
    if wa_search:
        wa_qs = wa_qs.filter(
            Q(visitor_name__icontains=wa_search) |
            Q(visitor_email__icontains=wa_search)
        )
    wa_active_count = base_wa.exclude(status=ChatSession.STATUS_CLOSED).count()
    wa_closed_count = base_wa.filter(status=ChatSession.STATUS_CLOSED).count()

    from django.core.paginator import Paginator
    ai_paginator = Paginator(qs.order_by('-last_msg_at'), 20)
    sessions_page = ai_paginator.get_page(request.GET.get('page'))
    wa_paginator = Paginator(wa_qs.order_by('-updated_at'), 20)
    wa_sessions_page = wa_paginator.get_page(request.GET.get('page'))

    return render(request, 'cms/chat/hub.html', {
        'tenant':           tenant,
        'tab':              tab,
        # AI Chat
        'sessions':         sessions_page,
        'search':           search,
        'show_archive':     show_archive,
        'active_count':     active_count,
        'closed_count':     closed_count,
        # WA Chat
        'wa_sessions':      wa_sessions_page,
        'wa_search':        wa_search,
        'wa_show_archive':  wa_show_archive,
        'wa_active_count':  wa_active_count,
        'wa_closed_count':  wa_closed_count,
    })


@cms_required
def chat_settings(request):
    """Legacy — redirect to unified hub (settings tab)."""
    from django.http import HttpResponseRedirect
    from django.urls import reverse
    return HttpResponseRedirect(reverse('cms_chat') + '?tab=settings')


@cms_required
def chat_sessions_list(request):
    """Legacy — redirect to unified hub (sessions tab)."""
    return redirect('cms_chat')


@cms_required
def chat_close_inactive(request):
    """Run the inactive-session cleanup immediately from the CMS."""
    if request.method == 'POST':
        from apps.tenants.models import Tenant as TenantModel
        from apps.chat.models import ChatSession as CS, ChatMessage
        from django.utils import timezone
        from datetime import timedelta

        tenant = getattr(request, 'tenant', None)
        if tenant is None or not hasattr(tenant, 'save'):
            try:
                tenant = TenantModel.objects.get(is_master=True)
            except TenantModel.DoesNotExist:
                messages.error(request, 'No master tenant found.')
                return redirect(reverse('cms_chat') + '?tab=sessions')

        timeout_minutes = getattr(tenant, 'chat_inactivity_minutes', 30) or 30
        threshold = timezone.now() - timedelta(minutes=timeout_minutes)
        farewell = (
            getattr(tenant, 'chat_farewell_message', '') or
            "Thank you for contacting us, I'll be happy to help you at your return, have a good one!"
        )

        active = CS.objects.filter(tenant=tenant, status=CS.STATUS_ACTIVE).prefetch_related('messages')
        closed = 0
        for session in active:
            last_msg = session.messages.order_by('-created_at').first()
            last_activity = last_msg.created_at if last_msg else session.created_at
            if last_activity < threshold:
                ChatMessage.objects.create(
                    session=session,
                    role=ChatMessage.ROLE_ASSISTANT,
                    content=farewell,
                )
                session.status = CS.STATUS_CLOSED
                session.save(update_fields=['status'])
                closed += 1

        if closed:
            messages.success(request, f'{closed} inactive session(s) closed.')
        else:
            messages.info(request, 'No inactive sessions found.')

    return redirect(reverse('cms_chat') + '?tab=sessions')


@cms_required
def chat_toggle_enabled(request):
    """Quick toggle for chat_enabled via POST."""
    from apps.tenants.models import Tenant as TenantModel
    tenant = getattr(request, 'tenant', None)
    if tenant is None or not hasattr(tenant, 'save'):
        try:
            tenant = TenantModel.objects.get(is_master=True)
        except TenantModel.DoesNotExist:
            pass
    if request.method == 'POST' and tenant:
        tenant.chat_enabled = not tenant.chat_enabled
        tenant.save(update_fields=['chat_enabled'])
    return redirect(reverse('cms_chat') + '?tab=sessions')


@cms_required
def chat_live(request):
    """Live chat panel — two-panel messaging interface."""
    from django.http import JsonResponse as JR
    tenant = getattr(request, 'tenant', None)
    sessions_qs = ChatSession.objects.filter(
        tenant=tenant, status=ChatSession.STATUS_ACTIVE
    ).annotate(last_msg_at=Max('messages__created_at')).order_by('-last_msg_at')
    return render(request, 'cms/chat/live.html', {
        'active_sessions': sessions_qs,
        'tenant': tenant,
    })


@cms_required
def chat_live_sessions_json(request):
    """JSON list of active sessions for polling."""
    from django.http import JsonResponse as JR
    tenant = getattr(request, 'tenant', None)
    qs = ChatSession.objects.filter(
        tenant=tenant, status=ChatSession.STATUS_ACTIVE
    ).annotate(
        last_msg_at=Max('messages__created_at'),
        msg_count=Count('messages'),
    ).order_by('-last_msg_at')

    data = []
    for s in qs:
        last_msg = s.messages.order_by('-created_at').first()
        preview = ''
        if last_msg:
            preview = last_msg.content[:70] + ('…' if len(last_msg.content) > 70 else '')
        data.append({
            'pk': s.pk,
            'visitor_name': s.display_label,
            'topic': s.topic,
            'is_named': bool(s.visitor_name),
            'visitor_email': s.visitor_email or '',
            'status': s.status,
            'last_msg_at': s.last_msg_at.isoformat() if s.last_msg_at else s.created_at.isoformat(),
            'last_msg_preview': preview,
            'last_msg_role': last_msg.role if last_msg else '',
            'msg_count': s.msg_count,
        })
    return JR({'sessions': data})


@cms_required
def chat_session_messages_json(request, pk):
    """JSON messages for a session — supports ?after=<msg_pk> for polling."""
    from django.http import JsonResponse as JR
    tenant = getattr(request, 'tenant', None)
    session = get_object_or_404(ChatSession, pk=pk, tenant=tenant)

    qs = session.messages.order_by('created_at')
    after = request.GET.get('after')
    if after:
        try:
            qs = qs.filter(pk__gt=int(after))
        except (ValueError, TypeError):
            pass

    msgs = [
        {
            'pk': m.pk,
            'role': m.role,
            'content': m.content,
            'created_at': m.created_at.isoformat(),
        }
        for m in qs
    ]
    return JR({
        'messages': msgs,
        'session_status': session.status,
        'visitor_name': session.display_label,
        'topic': session.topic,
        'is_named': bool(session.visitor_name),
        'visitor_email': session.visitor_email or '',
    })


@cms_required
def chat_session_reply(request, pk):
    """CMS agent sends a message into a visitor session."""
    from django.http import JsonResponse as JR
    if request.method != 'POST':
        return JR({'error': 'POST required'}, status=405)

    tenant = getattr(request, 'tenant', None)
    session = get_object_or_404(ChatSession, pk=pk, tenant=tenant)

    if session.status == ChatSession.STATUS_CLOSED:
        return JR({'error': 'Session is closed'}, status=400)

    try:
        data = json.loads(request.body)
        content = data.get('message', '').strip()
    except (json.JSONDecodeError, AttributeError):
        content = request.POST.get('message', '').strip()

    if not content:
        return JR({'error': 'Empty message'}, status=400)

    msg = ChatMessage.objects.create(
        session=session,
        role=ChatMessage.ROLE_ASSISTANT,
        content=content,
    )
    return JR({
        'ok': True,
        'pk': msg.pk,
        'content': msg.content,
        'created_at': msg.created_at.isoformat(),
    })


@cms_required
def chat_session_delete(request, pk):
    tenant = getattr(request, 'tenant', None)
    session = get_object_or_404(ChatSession, pk=pk, tenant=tenant)
    if request.method == 'POST':
        session.delete()
        messages.success(request, 'Chat session deleted.')
    return redirect('cms_chat')


@cms_required
def chat_session_detail(request, pk):
    tenant = getattr(request, 'tenant', None)
    session = get_object_or_404(ChatSession, pk=pk, tenant=tenant)
    return render(request, 'cms/chat/session_detail.html', {'session': session})


# ─── System Info (super admin only) ──────────────────────────────────────────

@super_admin_required
def system_info(request):
    import sys
    import django
    import platform
    from django.conf import settings as django_settings

    # Version from settings — single source of truth
    CURRENT_VERSION = getattr(django_settings, 'CMS_VERSION', '1.0.0')
    LATEST_VERSION  = _LATEST_PLATFORM_VERSION
    # Three distinct states, not two. Running ahead of the published release is
    # normal during development and must never offer a downgrade.
    UPDATE_AVAILABLE = _is_newer(LATEST_VERSION, CURRENT_VERSION)
    AHEAD_OF_RELEASE = _is_newer(CURRENT_VERSION, LATEST_VERSION)

    db_cfg    = django_settings.DATABASES.get('default', {})
    db_engine = db_cfg.get('ENGINE', '').split('.')[-1].upper()
    db_name   = str(db_cfg.get('NAME', 'N/A'))

    # The server version, not the client library. Knowing the database is
    # PostgreSQL says little when a restore has to land on a matching major.
    db_version = ''
    try:
        from django.db import connection as _conn
        db_version = (_conn.get_connection_params() and
                      getattr(_conn, 'pg_version', None))
        if db_version:
            # PostgreSQL 10 and later pack the version as MMmmmm: 150004 is
            # 15.4, not 15.0. The minor is the last two digits.
            v = int(db_version)
            db_version = f'{v // 10000}.{v % 100}'
        else:
            with _conn.cursor() as cur:
                cur.execute('SELECT version()')
                db_version = (cur.fetchone() or [''])[0].split()[1]
    except Exception:
        db_version = ''

    # Read the same way the senders do: panel Settings first, .env as a
    # fallback. These used to read django settings only, so credentials
    # entered in the panel showed as Off while SMS was demonstrably working.
    # Distribution name, not the kernel. platform.release() returns
    # 4.18.0-553.el8_10, which is the kernel version and reads as generic
    # Linux. os_details() reads /etc/os-release, which is what Fleet shows.
    try:
        from apps.ftlink.collect import os_details as _os_details
        _os_name, _os_version = _os_details()
    except Exception:
        _os_name, _os_version = platform.system(), platform.release()

    # cPanel version, straight from the file it maintains.
    _cpanel_version = ''
    try:
        with open('/usr/local/cpanel/version') as fh:
            _cpanel_version = fh.read().strip()
    except OSError:
        pass

    # Security posture of the host. Presence of the config is what we can see
    # without root, so this reports what is installed, not whether it is
    # currently running.
    import os as _os
    _sec_parts = []
    # The config file survives a disabled or half removed install, so its
    # presence alone had the panel claiming a firewall that was not running.
    # csf writes csf.disable when switched off, and lfd is the daemon that
    # does the work, so require the config, the absence of the disable flag,
    # and a live lfd before saying so.
    if (_os.path.exists('/etc/csf/csf.conf')
            and not _os.path.exists('/etc/csf/csf.disable')
            and (_os.path.exists('/var/run/lfd.pid')
                 or _os.path.exists('/run/lfd.pid'))):
        _sec_parts.append('CSF firewall')
    if (_os.path.exists('/etc/apache2/conf.d/modsec2.conf')
            or _os.path.isdir('/etc/apache2/conf.d/modsec_vendor_configs')):
        _sec_parts.append('ModSecurity')
    if _os.path.exists('/var/cpanel/cpanel.config'):
        try:
            with open('/var/cpanel/cpanel.config') as fh:
                _cfg = fh.read()
            if 'skipclamav=0' in _cfg:
                _sec_parts.append('ClamAV')
        except OSError:
            pass

    _sms_creds = _wa_creds = ('', '', '')
    try:
        from apps.pages.sms_utils import _credentials as _sms_credentials
        _c = _sms_credentials()
        _sms_creds = (_c.get('account_sid', ''), _c.get('auth_token', ''), _c.get('sms_from', ''))
    except Exception:
        pass
    try:
        from apps.pages.whatsapp_utils import _credentials as _wa_credentials
        _c = _wa_credentials()
        _wa_creds = (_c.get('account_sid', ''), _c.get('auth_token', ''), _c.get('from_number', ''))
    except Exception:
        pass

    # Outgoing mail, read the same way the mail backend reads it: the Settings
    # page wins, .env is only the fallback. Reading .env here was why the card
    # showed YOUR COMPANY's mail server on a tenant that had its own configured.
    _smtp_host = _smtp_port = _smtp_from = ''
    _smtp_source = 'env'
    try:
        from apps.pages.models import SiteSettings
        _site = SiteSettings.load(_resolve_tenant(request))
        if _site and _site.smtp_host:
            _smtp_host = _site.smtp_host
            _smtp_port = _site.smtp_port or ''
            _smtp_from = _site.smtp_from_email or _site.smtp_user or ''
            _smtp_source = 'settings'
    except Exception:
        pass
    if not _smtp_host:
        _smtp_host = getattr(django_settings, 'EMAIL_HOST', '') or ''
        _smtp_port = getattr(django_settings, 'EMAIL_PORT', '') or ''
        _smtp_from = (_outgoing())

    # Resolve the Stripe key once. An install with no key is Off, not "Test
    # mode": a card that always claims a payment gateway is how a tenant ends
    # up believing it can take money when nothing is configured.
    # The real Bootstrap version, read from the file that is actually served.
    # The card used to say "Bootstrap 5" whatever was on disk.
    _bootstrap_version = ''
    try:
        import re as _re
        from pathlib import Path as _P
        for _base in (getattr(django_settings, 'STATIC_ROOT', ''), *(getattr(django_settings, 'STATICFILES_DIRS', []) or [])):
            if not _base:
                continue
            _css = _P(_base) / 'css' / 'bootstrap.min.css'
            if _css.exists():
                _m = _re.search(r'Bootstrap\s+v([0-9]+\.[0-9]+\.[0-9]+)', _css.read_text(errors='ignore')[:400])
                if _m:
                    _bootstrap_version = _m.group(1)
                break
    except Exception:
        _bootstrap_version = ''

    # Analytics. Only fires once the visitor accepts cookies, so "configured"
    # and "collecting" are not the same thing, and the card says which.
    _ga_id = ''
    _ga_consent = False
    try:
        from apps.pages.models import SiteSettings as _SS
        _site_row = _SS.load(_resolve_tenant(request))
        _ga_id = (getattr(_site_row, 'google_analytics_id', '') or '').strip()
        _ga_consent = bool(getattr(_site_row, 'cookie_consent_enabled', False))
    except Exception:
        pass

    _stripe_key = ''
    try:
        from apps.ecommerce.stripe_utils import _get_stripe_secret_key
        _stripe_key = _get_stripe_secret_key(_resolve_tenant(request)) or ''
    except Exception:
        _stripe_key = ''

    # Read from the same place the chat widget reads, so the card cannot say
    # "Not configured" while the feature is running, which is what the .env
    # read used to do.
    _openai_key = ''
    try:
        from apps.pages.api_keys import get_openai_api_key
        _openai_key = get_openai_api_key(_resolve_tenant(request)) or ''
    except Exception:
        _openai_key = ''

    # Which Stripe account this key belongs to. "Live" alone does not tell you
    # whose account the money lands in, which is the thing worth knowing at a
    # glance. Cached for ten minutes so the page does not wait on Stripe on
    # every load, and only ever the last four characters of the key.
    _stripe_who = ''
    if _stripe_key:
        _stripe_who = f'key ...{_stripe_key[-4:]}'
        try:
            from django.core.cache import cache
            _ck = f'stripe_acct_{_stripe_key[-8:]}'
            _acct = cache.get(_ck)
            if _acct is None:
                import stripe as _stripe_lib
                _stripe_lib.api_key = _stripe_key
                _a = _stripe_lib.Account.retrieve(timeout=6)
                _acct = (
                    (_a.get('email') or '')
                    or ((_a.get('business_profile') or {}).get('name') or '')
                    or (_a.get('id') or '')
                )
                cache.set(_ck, _acct, 600)
            if _acct:
                _stripe_who = f'{_acct} - key ...{_stripe_key[-4:]}'
        except Exception:
            # No network, revoked key, or an older library. The key tail alone
            # is still more use than the word Live.
            pass

    # ── Web server and app server ────────────────────────────────────────────
    # Every request crosses both of these before Django sees it, and neither
    # appeared on this page. That is why the redirect loop in August looked
    # like a Django fault when it was an Apache header that was not being set.
    _gunicorn_version = ''
    try:
        from importlib.metadata import version as _pkg_version
        _gunicorn_version = _pkg_version('gunicorn')
    except Exception:
        pass

    # Shelling out on every page load would be wasteful, so this is cached for
    # an hour. Apache does not change between lunch and dinner.
    _apache_version = ''
    try:
        from django.core.cache import cache as _cache
        _apache_version = _cache.get('sys_apache_version')
        if _apache_version is None:
            import re as _re2
            import subprocess as _sp
            _apache_version = ''
            for _bin in ('/usr/local/apache/bin/httpd', '/usr/sbin/httpd', '/usr/sbin/apache2'):
                if not _os.path.exists(_bin):
                    continue
                try:
                    _out = _sp.run([_bin, '-v'], capture_output=True, text=True, timeout=3).stdout
                    _m = _re2.search(r'Apache/([0-9][0-9.]*)', _out or '')
                    if _m:
                        _apache_version = _m.group(1)
                except Exception:
                    pass
                break
            _cache.set('sys_apache_version', _apache_version, 3600)
    except Exception:
        _apache_version = ''

    # Whether this request actually arrived through a proxy, rather than
    # assuming it did because a config file exists somewhere on the box.
    _behind_proxy = bool(request.META.get('HTTP_X_FORWARDED_FOR')
                         or request.META.get('HTTP_X_FORWARDED_PROTO'))

    # ── TLS certificate ──────────────────────────────────────────────────────
    # The failure mode here is silence. A certificate works perfectly until the
    # morning it does not, and nothing in the panel counted the days.
    _ssl_issuer, _ssl_days, _ssl_expires, _ssl_algo = '', None, '', ''
    _ssl_version = ''
    # The site's own domain, not whatever Host header arrived. Apache's
    # ProxyPass rewrites Host to the backend unless ProxyPreserveHost is on,
    # so request.get_host() reads 127.0.0.1 on these installs and the card
    # reported "Could not read the certificate for 127.0.0.1" while the real
    # certificate was perfectly valid. The tenant knows its own domain.
    _ssl_host = ''
    try:
        _ssl_t = _resolve_tenant(request)
        _ssl_host = (getattr(_ssl_t, 'primary_domain', '') or '').strip()
    except Exception:
        pass
    if not _ssl_host or _ssl_host in ('127.0.0.1', 'localhost'):
        _ssl_host = request.get_host().split(':')[0]
    try:
        from django.core.cache import cache as _cache
        # The number on the end is the shape of what gets stored. Adding a
        # field to the cached row without changing it leaves the old entry in
        # place, and the card silently falls back for six hours while the code
        # looks perfectly correct. Bump it whenever this dict gains a key.
        _ck_ssl = f'sys_ssl3_{_ssl_host}'
        _ssl_row = _cache.get(_ck_ssl)
        if _ssl_row is None:
            import socket as _socket
            import ssl as _ssl_lib
            from datetime import datetime as _dt
            _ctx = _ssl_lib.create_default_context()
            with _socket.create_connection((_ssl_host, 443), timeout=4) as _sock:
                with _ctx.wrap_socket(_sock, server_hostname=_ssl_host) as _wrapped:
                    _cert = _wrapped.getpeercert() or {}
                    _der = _wrapped.getpeercert(binary_form=True)
            _iss = dict(x[0] for x in _cert.get('issuer', ()))
            _after = _dt.strptime(_cert['notAfter'], '%b %d %H:%M:%S %Y %Z')

            # What the certificate is actually signed with. "71 days left" says
            # nothing about strength, and a weak signature is a real finding
            # that no expiry date would ever surface.
            _algo = ''
            _cert_version = ''
            try:
                from cryptography import x509 as _x509
                from cryptography.hazmat.primitives.asymmetric import ec as _ec, rsa as _rsa
                _parsed = _x509.load_der_x509_certificate(_der)
                _pk = _parsed.public_key()
                _kind = ('ECDSA' if isinstance(_pk, _ec.EllipticCurvePublicKey)
                         else 'RSA' if isinstance(_pk, _rsa.RSAPublicKey) else '')
                _hash = (_parsed.signature_hash_algorithm.name or '').upper()
                if _hash.startswith('SHA') and not _hash.startswith('SHA-'):
                    _hash = 'SHA-' + _hash[3:]
                _algo = ' with '.join(p for p in (_kind, _hash) if p)
                # X.509 v3 has been universal since 1996, so this belongs on
                # the detail line rather than the headline. It is here because
                # its absence would be the finding, not its presence.
                _ver = str(getattr(_parsed.version, 'name', '') or '').lstrip('v')
                if _ver:
                    _cert_version = f'Version {_ver}'
            except Exception:
                _algo = ''

            _ssl_row = {
                'issuer': _iss.get('organizationName') or _iss.get('commonName') or 'Unknown issuer',
                'days': (_after - _dt.utcnow()).days,
                'expires': _after.strftime('%d %b %Y'),
                'algo': _algo,
                'version': _cert_version,
            }
            _cache.set(_ck_ssl, _ssl_row, 21600)
        _ssl_issuer  = _ssl_row.get('issuer', '')
        _ssl_days    = _ssl_row.get('days')
        _ssl_expires = _ssl_row.get('expires', '')
        _ssl_algo    = _ssl_row.get('algo', '')
        _ssl_version = _ssl_row.get('version', '')
    except Exception:
        _ssl_issuer, _ssl_days, _ssl_expires, _ssl_algo = '', None, '', ''
        _ssl_version = ''

    if _ssl_days is None:
        _ssl_status = 'off'
    elif _ssl_days <= 0:
        _ssl_status = 'alert'
    elif _ssl_days <= 7:
        _ssl_status = 'alert'
    elif _ssl_days <= 21:
        _ssl_status = 'warn'
    else:
        _ssl_status = 'live'

    # The headline is what it is signed with. The expiry still drives the
    # status tag, so a certificate running out is not hidden by a strong
    # algorithm sitting where the countdown used to be.
    if _ssl_days is None:
        _ssl_value = 'Not readable'
    elif _ssl_days <= 0:
        _ssl_value = 'Expired'
    elif _ssl_algo:
        _ssl_value = _ssl_algo
    else:
        # The algorithm is the better headline, but if it cannot be read the
        # countdown is the next most useful thing. "Valid" only repeated the tag.
        _ssl_value = f'{_ssl_days} days left'

    # ── Domain ───────────────────────────────────────────────────────────────
    # The only thing on this page that cannot be undone. A lapsed certificate
    # costs an afternoon. A lapsed domain goes to grace, then redemption at
    # several hundred pounds, then to whoever registers it next. So it warns
    # far earlier than the certificate does, and it does not care whether
    # auto-renew is switched on, because auto-renew fails silently when the
    # card on file has expired.
    # The site's own domain, not the panel subdomain. Resolved here rather than
    # reusing a variable created further down, which is how this card ended up
    # showing the panel subdomain instead of the site's own domain.
    try:
        _dom_tenant = _resolve_tenant(request)
    except Exception:
        _dom_tenant = None
    _dom_name = (getattr(_dom_tenant, 'primary_domain', '') or '').strip() or _ssl_host

    # Set here rather than further down, because this block is now the first
    # thing on the page that needs to know today's date.
    _now = timezone.now()

    _dom_registrar = (getattr(django_settings, 'DOMAIN_REGISTRAR', '') or '').strip()
    _dom_owner     = (getattr(django_settings, 'DOMAIN_OWNER', '') or '').strip()
    _dom_expires   = None
    _raw_dom = (getattr(django_settings, 'DOMAIN_EXPIRES_ON', '') or '').strip()
    if _raw_dom:
        try:
            from datetime import date as _date2
            _dom_expires = _date2.fromisoformat(_raw_dom)
        except ValueError:
            _dom_expires = None
    _dom_days = (_dom_expires - _now.date()).days if _dom_expires else None

    if _dom_days is None:
        _dom_status = 'off'
    elif _dom_days <= 0:
        _dom_status = 'alert'
    elif _dom_days <= 30:
        _dom_status = 'alert'
    elif _dom_days <= 60:
        _dom_status = 'warn'
    else:
        _dom_status = 'live'

    _dom_bits = []
    if _dom_owner and _dom_registrar:
        _dom_bits.append(f'{_dom_owner} via {_dom_registrar}')
    elif _dom_owner or _dom_registrar:
        _dom_bits.append(_dom_owner or _dom_registrar)
    if _dom_expires:
        _dom_bits.append(
            f'expired {_dom_expires:%d %b %Y}' if _dom_days is not None and _dom_days < 0
            else f'expires {_dom_expires:%d %b %Y}'
        )

    # ── Cloudflare ───────────────────────────────────────────────────────────
    # Read from the request rather than from DNS, so it reports what is
    # actually happening to traffic right now.
    _cf_ray = request.META.get('HTTP_CF_RAY', '')
    _cf_colo = _cf_ray.rsplit('-', 1)[-1] if '-' in _cf_ray else ''
    _cf_country = request.META.get('HTTP_CF_IPCOUNTRY', '')

    # ── reCAPTCHA ────────────────────────────────────────────────────────────
    # The contact and assessment pages render site.recaptcha_site_key from the
    # Settings row, so reading .env here was the SMTP mistake again: a tenant
    # with a key in Settings saw "Not configured" while reCAPTCHA was running.
    # The site key is public, it ships in the HTML of every page that uses it.
    _rc_key = ''
    _rc_source = 'settings'
    try:
        from apps.pages.models import SiteSettings as _SS2
        _rc_row = _SS2.load(_resolve_tenant(request))
        _rc_key = (getattr(_rc_row, 'recaptcha_site_key', '') or '').strip()
        if _rc_key:
            _rc_source = 'settings'
    except Exception:
        pass
    if _rc_key in ('CHANGEME',):
        _rc_key = ''
    # Forty characters will not fit the headline, so the card shows enough to
    # tell two keys apart and the full value sits on the line below.
    _rc_short = f'{_rc_key[:14]}…{_rc_key[-4:]}' if len(_rc_key) > 22 else _rc_key

    # ── Which package this panel is on ───────────────────────────────────────
    # Every other tenant lookup on this page is already wrapped. This one was
    # not, and an unreadable tenant row took the whole page down with a 500
    # while the rest of the panel carried on. A diagnostics page is the last
    # thing that should fail when something is wrong.
    try:
        _plan_tenant = _resolve_tenant(request)
    except Exception:
        _plan_tenant = None
    try:
        _plan_label = _plan_tenant.get_plan_display()
    except Exception:
        _plan_label = (getattr(_plan_tenant, 'plan', '') or '').title()
    _plan_label = _plan_label or 'Unconfigured'

    # When this panel's subscription runs out. The tenant row wins, because
    # that is the date billing actually invoices against. PANEL_EXPIRES_ON is
    # the fallback for an install billed elsewhere, so the card is never blank
    # on a panel that does have an end date, just not one this database knows.
    _plan_expires = getattr(_plan_tenant, 'next_renewal_date', None)
    if not _plan_expires:
        _raw_plan = (getattr(django_settings, 'PANEL_EXPIRES_ON', '') or '').strip()
        if _raw_plan:
            try:
                from datetime import date as _date3
                _plan_expires = _date3.fromisoformat(_raw_plan)
            except ValueError:
                _plan_expires = None

    # ── Fleet link ───────────────────────────────────────────────────────────
    # Only a tenant install checks in. On the master this setting is empty, and
    # "which sites stopped reporting" belongs on the Fleet page with the tenant
    # table, not duplicated here where it would drift out of step with it.
    _ft_control_url = getattr(django_settings, 'FT_CONTROL_URL', '') or ''
    _is_tenant_install = bool(_ft_control_url)

    # ── Scheduled jobs ───────────────────────────────────────────────────────
    # Cron stopping is invisible. Each command writes a JobRun row as it
    # finishes, and this reads them back against the cadence each one is
    # supposed to keep. A job with no row has never reported at all.
    _JOB_SPECS = [
        ('fetch_ticket_replies', 'Incoming ticket replies', 'Every 5 minutes', 30),
        ('publish_scheduled',    'Publish scheduled posts', 'Every 5 minutes', 30),
        ('ping_sites',           'Uptime checks',           'Every 5 minutes', 30),
        ('close_inactive_chats', 'Close idle chats',        'Every 5 minutes', 30),
        ('release_reservations', 'Release auction holds',   'Hourly',          180),
        ('ft_checkin',           'Fleet check-in',          'Hourly',          180),
    ]
    _job_rows = {}
    try:
        from apps.ftlink.models import JobRun as _JobRun
        _job_rows = {j.name: j for j in _JobRun.objects.all()}
    except Exception:
        _job_rows = {}

    from django.utils.timesince import timesince

    _now = timezone.now()
    scheduled_jobs = []
    _jobs_stale = 0
    for _name, _label, _cadence, _grace in _JOB_SPECS:
        if _name == 'ft_checkin' and not _is_tenant_install:
            continue
        _row = _job_rows.get(_name)
        if _row is None:
            _state, _when = 'off', 'Never run'
        else:
            _mins = (_now - _row.last_run_at).total_seconds() / 60
            _when = f'{timesince(_row.last_run_at)} ago'
            if not _row.ok:
                _state = 'alert'
            elif _mins > _grace:
                _state = 'warn'
            else:
                _state = 'live'
        if _state in ('warn', 'alert', 'off'):
            _jobs_stale += 1
        scheduled_jobs.append({
            'command': _name,
            'label': _label,
            'cadence': _cadence,
            'when': _when,
            'note': getattr(_row, 'note', '') if _row else '',
            'status': _state,
        })

    _ft_job = _job_rows.get('ft_checkin')

    # ── Hosting ──────────────────────────────────────────────────────────────
    # Disk first. On cPanel the number that matters is the account's own quota,
    # because that is what the client was sold and what fills up first. A box
    # you own outright usually has no quota set, so the partition is the
    # honest fallback rather than reporting nothing.
    _disk_used = _disk_total = 0
    _disk_scope = ''
    try:
        import subprocess as _sp2
        _quota = _sp2.run(['quota', '-w'], capture_output=True, text=True, timeout=3).stdout
        for _line in (_quota or '').splitlines():
            _parts = _line.split()
            # blocks, soft, hard: a hard limit of 0 means no quota is applied
            if len(_parts) >= 4 and _parts[0].startswith('/') and _parts[2].isdigit():
                _limit = int(_parts[2]) or int(_parts[3] or 0)
                if _limit:
                    _disk_used = int(_parts[1]) * 1024
                    _disk_total = _limit * 1024
                    _disk_scope = 'account quota'
                break
    except Exception:
        pass
    if not _disk_total:
        try:
            import shutil as _shutil
            _usage = _shutil.disk_usage(str(django_settings.BASE_DIR))
            _disk_used, _disk_total = _usage.used, _usage.total
            _disk_scope = 'server partition'
        except Exception:
            _disk_used = _disk_total = 0

    # The package entitlement, which is a different fact from the measured
    # disk. On a dedicated server the kernel quota is usually not set, so the
    # partition is all that can be measured, and cPanel is the only place that
    # knows what the account was actually sold. Reporting one as the other
    # would overstate the space on a reseller account and understate it here.
    _quota_mb = 0
    try:
        import pwd as _pwd
        _acct = _pwd.getpwuid(_os.getuid()).pw_name
        with open(f'/var/cpanel/users/{_acct}') as _fh:
            for _line in _fh:
                if _line.startswith('MAXQUOTA='):
                    _raw = _line.split('=', 1)[1].strip()
                    _quota_mb = int(_raw) if _raw.isdigit() else 0
                    break
    except Exception:
        _quota_mb = 0

    _disk_pct = round(_disk_used / _disk_total * 100) if _disk_total else 0
    _gb = lambda _n: f'{_n / 1024 ** 3:.0f} GB'

    # Read from settings, so a missing value is an empty string rather than a
    # database column that does not exist. This card cost the whole site a day
    # when it lived on the model, and nothing here can repeat that.
    _host_provider = (getattr(django_settings, 'HOSTING_PROVIDER', '') or '').strip()
    _host_package  = (getattr(django_settings, 'HOSTING_PACKAGE', '') or '').strip()
    _host_type     = (getattr(django_settings, 'HOSTING_TYPE', '') or '').strip()
    _host_ip       = (getattr(django_settings, 'HOSTING_IP', '') or '').strip()

    _host_ends = None
    _raw_ends = (getattr(django_settings, 'HOSTING_ENDS_ON', '') or '').strip()
    if _raw_ends:
        try:
            from datetime import date as _date
            _host_ends = _date.fromisoformat(_raw_ends)
        except ValueError:
            # A typo in .env should show as no date, never as a broken page.
            _host_ends = None
    _host_days = (_host_ends - _now.date()).days if _host_ends else None

    # The card goes amber on whichever is worse, a filling disk or an
    # agreement running out. Either one ends the site, so neither outranks
    # the other.
    if _disk_pct >= 95 or (_host_days is not None and _host_days <= 0):
        _host_status = 'alert'
    elif _disk_pct >= 85 or (_host_days is not None and _host_days <= 30):
        _host_status = 'warn'
    elif _host_provider or _disk_total:
        _host_status = 'live'
    else:
        _host_status = 'off'

    _host_bits = []
    if _host_ip:
        _host_bits.append(_host_ip)
    # Prefer what the account was sold over what the partition holds. On a
    # shared or reseller account the partition is the whole machine, so
    # reporting it tells the client they have 900 GB when they bought 5.
    _q_gb = (getattr(django_settings, 'HOSTING_QUOTA_GB', '') or '').strip()
    _u_gb = (getattr(django_settings, 'HOSTING_USED_GB', '') or '').strip()
    if _q_gb and _u_gb:
        # Recorded, because a reseller account cannot see its own entitlement
        # and the partition is the whole machine. Stale is better than wrong
        # by an order of magnitude.
        _host_bits.append(f'{_u_gb} GB of {_q_gb} GB used')
    elif _quota_mb:
        _host_bits.append(f'{_disk_used / 1024 ** 3:.0f} GB of {_quota_mb / 1024:.0f} GB used')
    elif _disk_total:
        _host_bits.append(f'{_gb(_disk_used)} of {_gb(_disk_total)} used')
    if _host_ends:
        _host_bits.append(
            f'expired {_host_ends:%d %b %Y}' if _host_days is not None and _host_days < 0
            else f'expires {_host_ends:%d %b %Y}'
        )
    elif _host_provider:
        _host_bits.append('no expiry date set')

    # When the account was opened. Not derivable from anything on the box, and
    # the first thing asked when a renewal is queried.
    _raw_started = (getattr(django_settings, 'HOSTING_STARTED_ON', '') or '').strip()
    if _raw_started:
        try:
            from datetime import datetime as _dt2
            _host_bits.append(
                f'created {_dt2.strptime(_raw_started, "%Y-%m-%d").date():%d %b %Y}')
        except ValueError:
            _host_bits.append(f'created {_raw_started}')

    # Say which of the two silences this is. "Nothing entered yet" and "the
    # tenant row could not be read" look identical on a card and need very
    # different actions.


    tech_stack = [
        {
            'label': 'Domain',
            'value': _dom_name or 'Unknown',
            'detail': (', '.join(_dom_bits)
                       or 'Set DOMAIN_REGISTRAR and DOMAIN_EXPIRES_ON in .env'),
            'icon': 'fa-solid fa-globe',
            'color': '#0f766e',
            'status': _dom_status,
        },
        {
            'label': 'Hosting',
            'value': _host_package or _host_provider or 'Not recorded',
            'detail': (', '.join(_host_bits)
                       or 'Set HOSTING_PROVIDER and HOSTING_PACKAGE in .env'),
            'icon': 'fa-solid fa-hard-drive',
            'color': '#854d0e',
            'status': _host_status,
        },
        {
            'label': 'Server OS',
            'value': f'{_os_name} {_os_version}'.strip(),
            'detail': f'Kernel {platform.release()}, {platform.machine()}',
            'icon': 'fa-solid fa-server',
            'color': '#7c2d12',
        },
        {
            'label': 'Hosting Panel',
            'value': f'cPanel {_cpanel_version}' if _cpanel_version else 'cPanel',
            'detail': 'WHM and cPanel manage accounts, DNS and mail',
            'icon': 'fa-solid fa-gauge-high',
            'color': '#a16207',
        },
        {
            'label': 'Web Server',
            'value': f'Apache {_apache_version}'.strip() if _apache_version else 'Apache',
            'detail': (
                (f'Reverse proxy to gunicorn {_gunicorn_version}' if _gunicorn_version
                 else 'Reverse proxy to gunicorn')
                + (', this request came through it' if _behind_proxy
                   else ', this request did not come through it')
            ),
            'icon': 'fa-solid fa-network-wired',
            'color': '#be123c',
            'status': 'live' if _behind_proxy else 'warn',
        },
        {
            'label': 'SSL Certificate',
            'status_label': 'Valid',
            'value': _ssl_value,
            'detail': (', '.join(p for p in (
                           _ssl_version, _ssl_issuer, f'expires {_ssl_expires}') if p)
                       if _ssl_days is not None
                       else f'Could not read the certificate for {_ssl_host}'),
            'icon': 'fa-solid fa-lock',
            'color': '#4d7c0f',
            'status': _ssl_status,
        },
        {
            'label': 'Server Security',
            'value': ', '.join(_sec_parts) if _sec_parts else 'Not detected',
            'detail': ('Installed on this host' if _sec_parts
                       else 'No firewall or WAF config found from here'),
            'icon': 'fa-solid fa-shield-halved',
            'color': '#166534',
            'status': 'live' if _sec_parts else 'off',
        },
        {
            'label': 'FT OS',
            'status_label': 'Active',
            'value': f'{_plan_label}  v{CURRENT_VERSION}'.strip(),
            'detail': ((f'Latest release v{LATEST_VERSION}' if UPDATE_AVAILABLE
                        else 'Running the current release')
                       + (('' if not _plan_expires else
                           f', expired {_plan_expires:%d %b %Y}'
                           if _plan_expires < _now.date() else
                           f', expires {_plan_expires:%d %b %Y}'))),
            'icon': 'fa-solid fa-cubes',
            'color': '#033152',
            # Stays Live either way. The amber "vX ready" tag beside it is what
            # asks for attention, so the box does not look degraded when the
            # platform is running perfectly well.
            'status': 'live',
        },
        *([{
            # Only a tenant install has an upstream to lose. On the master this
            # card would be describing itself.
            'label': 'Fleet Link',
            'value': (f'{timesince(_ft_job.last_run_at)} ago' if _ft_job else 'Never checked in'),
            'detail': (
                f'Reports to {_ft_control_url.split("/")[2]}' if '://' in _ft_control_url
                else 'Reports to the fleet master'
            ),
            'icon': 'fa-solid fa-tower-broadcast',
            'color': '#0369a1',
            'status': (
                'live' if _ft_job and (_now - _ft_job.last_run_at).total_seconds() < 10800
                else ('warn' if _ft_job else 'off')
            ),
        }] if _is_tenant_install else []),
        {
            'label': 'Backend Language',
            'value': f'Python {sys.version.split()[0]}',
            'detail': f'Python {sys.version.split()[0]} — {platform.python_implementation()}',
            'icon': 'fa-brands fa-python',
            'color': '#3776ab',   # Python blue
        },
        {
            'label': 'Web Framework',
            'value': f'Django {django.get_version()}',
            'detail': 'High-level Python web framework',
            'icon': 'fa-solid fa-layer-group',
            'color': '#2ba24c',   # Django green (bright, not dark)
        },
        {
            'label': 'Database',
            'value': f'{db_engine} {db_version}'.strip(),
            'detail': db_name,
            'icon': 'fa-solid fa-database',
            'color': '#0e7490',   # Cyan (distinct from Python blue)
        },
        {
            'label': 'Frontend',
            'value': f'Bootstrap {_bootstrap_version}' if _bootstrap_version else 'Bootstrap',
            'detail': f'Bootstrap {_bootstrap_version} + FontAwesome 6 + Chart.js'.replace('  ', ' '),
            'icon': 'fa-brands fa-bootstrap',
            'color': '#7952b3',   # Bootstrap purple
        },
        {
            'label': 'Architecture',
            'value': 'Multi-Tenant SaaS CMS',
            'detail': 'Per-tenant modules, singleton content, role-based access',
            'icon': 'fa-solid fa-sitemap',
            'color': '#334155',   # Brand navy
        },
        {
            'label': 'Cloudflare',
            'status_label': 'Proxied',
            'value': 'Origin hidden' if _cf_ray else 'Origin exposed',
            'detail': (
                (f'Edge {_cf_colo}' if _cf_colo else 'Traffic proxied through Cloudflare')
                + (f', visitor in {_cf_country}' if _cf_country else '')
                if _cf_ray else
                'This request reached the server without passing through Cloudflare'
            ),
            'icon': 'fa-solid fa-cloud',
            'color': '#4338ca',
            'status': 'live' if _cf_ray else 'off',
        },
        {
            'label': 'Google Analytics',
            'value': _ga_id if _ga_id else 'Not configured',
            'detail': (
                ('Waits for cookie consent before loading' if _ga_consent
                 else 'Loads on every visit, no consent gate')
                if _ga_id else 'Add the measurement ID in Settings'
            ),
            'icon': 'fa-solid fa-chart-line',
            'color': '#e37400',
            'status': 'live' if _ga_id else 'off',
        },
        {
            'label': 'Payment Gateway',
            'value': 'Stripe',
            'detail': (
                (('Live' if _stripe_key.startswith('sk_live_') else 'Test mode')
                 + (f' - {_stripe_who}' if _stripe_who else ''))
                if _stripe_key else 'Add a Stripe secret key under Settings'
            ),
            'icon': 'fa-brands fa-stripe-s',
            'color': '#635bff',
            'status': (
                ('live' if _stripe_key.startswith('sk_live_') else 'test')
                if _stripe_key else 'off'
            ),
        },
        {
            'label': 'AI Assistant',
            'value': 'OpenAI GPT-4o' if _openai_key else 'Not configured',
            'detail': 'GPT-4o — Chat widget + Assessment AI' if _openai_key else 'Add an OpenAI key under Settings',
            'icon': 'fa-solid fa-robot',
            'color': '#10a37f',
            'status': 'live' if _openai_key else 'off',
        },
        {
            'label': 'Google reCAPTCHA',
            'value': _rc_short if _rc_key else 'Not configured',
            'detail': ('v3 with v2 fallback' if _rc_key
                       else 'Add the reCAPTCHA site key in Settings'),
            'icon': 'fa-brands fa-google',
            'color': '#4285f4',
            'status': 'live' if _rc_key else 'off',
        },
        {
            'label': 'Email (SMTP)',
            'value': _smtp_host or 'Not configured',
            'detail': (
                (f"Port {_smtp_port} — {_smtp_from}".rstrip(' —')
                 )
                if _smtp_host else 'Set the SMTP host in Settings'
            ),
            'icon': 'fa-solid fa-envelope',
            'color': '#b91c1c',
            'status': 'live' if _smtp_host else 'off',
        },
        {
            'label': 'SMS Provider',
            'value': 'Twilio' if _sms_creds[0] and _sms_creds[2] else 'Not configured',
            'detail': (f'From: {_sms_creds[2]}' if _sms_creds[0] and _sms_creds[2]
                       else 'Add the Twilio SID, token and SMS number in Settings'),
            'icon': 'fa-solid fa-comment-sms',
            'color': '#d946ef',
            'status': 'live' if _sms_creds[0] and _sms_creds[2] else 'off',
        },
        {
            # Credentials and a from number are not enough for WhatsApp: the
            # number also has to be an approved sender with Meta. Until it is,
            # a send fails with 63007, so reporting Live here would be a lie.
            # Flip this to live once the Meta registration completes.
            'label': 'WhatsApp Provider',
            'value': 'Twilio' if _wa_creds[0] and _wa_creds[2] else 'Not configured',
            'detail': (f'{_wa_creds[2]}, waiting on Meta sender approval'
                       if _wa_creds[0] and _wa_creds[2]
                       else 'Add the Twilio credentials and a WhatsApp sender in Settings'),
            'icon': 'fa-brands fa-whatsapp',
            'color': '#25d366',
            'status': 'pending' if _wa_creds[0] and _wa_creds[2] else 'off',
        },
    ]

    # A site that sells nothing has no payment gateway, and a card saying
    # "Add a Stripe secret key" reads as a task left undone rather than a
    # feature this client never bought. Drop it unless the shop is on.
    _t = _resolve_tenant(request)
    if not (_t and _t.has_module('ecommerce')):
        tech_stack = [c for c in tech_stack if c.get('label') != 'Payment Gateway']

    # ── Grouping ─────────────────────────────────────────────────────────────
    # The cards are read in blocks, not one by one, so colour carries meaning
    # rather than just telling one card from its neighbour. Everything in a
    # block shares a colour, and the blocks run from the machine outwards:
    # where it runs, what it runs on, who sees it, who is paid, who is
    # contacted. A card not listed here keeps its own colour and sits at the
    # end, so adding one never silently disappears.
    _CARD_GROUPS = [
        # What the world reaches first: the name, the edge in front of it, and
        # the certificate that proves it. All three fail on a date rather than
        # from load, which is why they are read together and read first.
        ('#15803d', ['Domain', 'Cloudflare', 'SSL Certificate']),
        # The machine and the way in
        ('#0369a1', ['Hosting', 'Server OS', 'Hosting Panel', 'Web Server',
                     'Server Security']),
        # Money. Sits here rather than lower down so the machine block fills
        # two clean rows and the platform block starts a new one. Keeps
        # Stripe's own colour, which works because it is the only card in it
        # and will still read correctly beside a second gateway one day.
        ('#635bff', ['Payment Gateway']),
        # The platform itself, in the brand navy
        ('#033152', ['FT OS', 'Fleet Link', 'Backend Language', 'Web Framework',
                     'Database', 'Frontend', 'Architecture']),
        # What runs inside the visitor's browser while they are on the site.
        # Fuchsia because it has to be told apart from the crimson below it at
        # a glance, and orange was not far enough away to manage that.
        ('#a21caf', ['Google Analytics', 'Google reCAPTCHA', 'AI Assistant']),
        # Ways this site reaches people who are not on it. All three are
        # transports now that the chat widget has moved up, which is what
        # makes this block hold together.
        ('#be123c', ['Email (SMTP)', 'SMS Provider', 'WhatsApp Provider']),
    ]
    _by_label = {c['label']: c for c in tech_stack}
    _ordered = []
    for _colour, _labels in _CARD_GROUPS:
        for _lb in _labels:
            _card = _by_label.pop(_lb, None)
            if _card is not None:
                _card['color'] = _colour
                _ordered.append(_card)
    tech_stack = _ordered + list(_by_label.values())

    # No tenant queries here. System Info describes this install only. The
    # tenant table and the upgrade invoices moved to the Tenants page, where
    # they are not duplicated and cannot drift out of step with it.
    return render(request, 'cms/system_info.html', {
        'tech_stack': tech_stack,
        'scheduled_jobs': scheduled_jobs,
        'jobs_stale': _jobs_stale,
        'current_version': CURRENT_VERSION,
        'latest_version': LATEST_VERSION,
        'up_to_date': not UPDATE_AVAILABLE,
        'update_available': UPDATE_AVAILABLE,
        'ahead_of_release': AHEAD_OF_RELEASE,
    })


# ─── Ecommerce — Products ─────────────────────────────────────────────────────

@cms_required
def products_list(request):
    from apps.ecommerce.models import Product, ProductCategory
    from django.db.models import Count as _Count
    q           = request.GET.get('q', '').strip()
    cat_filter  = request.GET.get('cat', '')
    # The dashboard's Premium Domains box links here with ?type=domain. Without
    # it the box would open a list of everything, which is not a link, it is a
    # dead end with a number on it.
    type_filter = request.GET.get('type', '').strip()
    products    = Product.objects.select_related('category').exclude(product_type='subscription').order_by('sort_order', 'name')
    if type_filter:
        products = products.filter(product_type=type_filter)
    if q:
        products = products.filter(name__icontains=q)
    if cat_filter:
        products = products.filter(category__slug=cat_filter)
    categories = ProductCategory.objects.filter(category_type='product').annotate(
        product_count=_Count('products', filter=__import__('django.db.models', fromlist=['Q']).Q(products__product_type__in=['service','domain','product','hosting']))
    ).order_by('sort_order', 'name')
    from django.core.paginator import Paginator
    paginator = Paginator(products, 20)
    page_obj  = paginator.get_page(request.GET.get('page'))
    _fqs = request.GET.copy(); _fqs.pop('page', None)
    return render(request, 'cms/ecommerce/products_list.html', {
        'products':      page_obj,
        'page_obj':      page_obj,
        'product_count': Product.objects.exclude(product_type='subscription').count(),
        'q':             q,
        'cat_filter':    cat_filter,
        'categories':    categories,
        'filter_qs':     _fqs.urlencode(),
    })


@cms_required
def subscriptions_list(request):
    from apps.ecommerce.models import Product, ProductCategory
    from django.db.models import Count as _Count, Q
    q          = request.GET.get('q', '').strip()
    cat_filter = request.GET.get('cat', '')
    products   = Product.objects.select_related('category').filter(product_type='subscription').order_by('sort_order', 'name')
    if q:
        products = products.filter(name__icontains=q)
    if cat_filter:
        products = products.filter(category__slug=cat_filter)
    # Only subscription-type categories
    categories = ProductCategory.objects.filter(category_type='subscription').annotate(
        product_count=_Count('products', filter=Q(products__product_type='subscription'))
    ).order_by('sort_order', 'name')
    from django.core.paginator import Paginator
    paginator = Paginator(products, 20)
    page_obj  = paginator.get_page(request.GET.get('page'))
    _fqs = request.GET.copy(); _fqs.pop('page', None)
    return render(request, 'cms/ecommerce/subscriptions_list.html', {
        'products':      page_obj,
        'page_obj':      page_obj,
        'product_count': Product.objects.filter(product_type='subscription').count(),
        'q':             q,
        'cat_filter':    cat_filter,
        'categories':    categories,
        'filter_qs':     _fqs.urlencode(),
    })


@cms_required
def product_interests_list(request):
    from apps.ecommerce.models import ProductInterest
    interests = ProductInterest.objects.select_related('product').order_by('-created_at')
    # Mark all as read when viewed
    ProductInterest.objects.filter(is_read=False).update(is_read=True)
    return render(request, 'cms/ecommerce/interests_list.html', {'interests': interests})


@cms_required
def product_toggle_visibility(request, pk):
    from apps.ecommerce.models import Product
    product = get_object_or_404(Product, pk=pk)
    Product.objects.filter(pk=pk).update(is_active=not product.is_active)
    back = request.META.get('HTTP_REFERER', '')
    if 'subscriptions' in back:
        return redirect('cms_subscriptions')
    return redirect('cms_products')


@cms_required
def product_toggle_show_in_shop(request, pk):
    from apps.ecommerce.models import Product
    product = get_object_or_404(Product, pk=pk)
    Product.objects.filter(pk=pk).update(show_in_shop=not product.show_in_shop)
    return redirect(request.META.get('HTTP_REFERER', '') or 'cms_products')


@cms_required
def product_category_edit(request, pk=None):
    from apps.ecommerce.models import ProductCategory
    cat = get_object_or_404(ProductCategory, pk=pk) if pk else None
    # ?for=subscription routes to subscriptions, else products
    for_type = request.GET.get('for') or request.POST.get('for_type') or (cat.category_type if cat else 'product')
    back = 'cms_subscriptions' if for_type == 'subscription' else 'cms_products'
    if request.method == 'POST':
        name = request.POST.get('name', '').strip()
        if name:
            if not cat:
                cat = ProductCategory()
            cat.name          = name
            cat.category_type = for_type
            cat.icon_class    = request.POST.get('icon_class', '').strip()
            cat.save()
            return redirect(back)
    return render(request, 'cms/ecommerce/product_category_edit.html', {
        'cat':      cat,
        'for_type': for_type,
    })


@cms_required
def product_category_delete(request, pk):
    from apps.ecommerce.models import ProductCategory
    cat = get_object_or_404(ProductCategory, pk=pk)
    back = 'cms_subscriptions' if cat.category_type == 'subscription' else 'cms_products'
    if request.method == 'POST':
        cat.delete()
    return redirect(back)


BADGE_PRESETS = [
    ('Most Popular',   '#0891b2', '#ecfeff'),
    ('Best Value',     '#15803d', '#f0fdf4'),
    ('New',            '#7c3aed', '#faf5ff'),
    ('Limited',        '#dc2626', '#fef2f2'),
    ('Featured',       '#b45309', '#fffbeb'),
    ('Free Trial',     '#0d9488', '#f0fdfa'),
    ('Top Pick',       '#033152', '#eff6ff'),
]


@cms_required
def product_edit(request, pk=None):
    from apps.ecommerce.models import Product, ProductCategory
    product = get_object_or_404(Product, pk=pk) if pk else None

    if request.method == 'POST':
        data = request.POST
        files = request.FILES

        if not product:
            product = Product()
            product.tenant = _resolve_tenant(request)

        product.name              = data.get('name', '').strip()

        from django.utils.text import slugify as _slugify
        _slug = _slugify(data.get('slug', '') or product.name)
        if _slug:
            _base, _i = _slug, 2
            while Product.objects.filter(slug=_slug).exclude(pk=product.pk).exists():
                _slug = '%s-%d' % (_base, _i)
                _i += 1
            product.slug = _slug

        product.product_type      = data.get('product_type', 'service')
        product.billing_period    = 'one_time'
        product.website_url       = data.get('website_url', '').strip()
        product.enquiry_only      = 'enquiry_only' in data
        product.short_description = data.get('short_description', '').strip()
        product.description       = data.get('description', '').strip()
        product.features          = data.get('features', '').strip()
        product.price             = data.get('price') or 0
        product.annual_price      = None
        product.compare_at_price  = data.get('compare_at_price') or None
        product.deposit_amount    = data.get('deposit_amount') or None
        product.tax_included      = 'tax_included' in data
        product.stripe_price_id   = ''
        product.icon_class        = data.get('icon_class', '').strip()
        product.badge             = data.get('badge', '').strip()
        product.sort_order        = data.get('sort_order', 0) or 0
        product.is_active         = 'is_active' in data
        product.is_featured       = 'is_featured' in data
        product.domain_tld        = data.get('domain_tld', '').strip()
        product.reg_years_default = data.get('reg_years_default', 1) or 1
        product.disk_space        = data.get('disk_space', '').strip()
        product.bandwidth         = data.get('bandwidth', '').strip()
        product.domains_included  = data.get('domains_included') or None
        cat_id = data.get('category_id')
        product.category_id = int(cat_id) if cat_id else None
        product.meta_title       = data.get('meta_title', '').strip()
        product.meta_description = data.get('meta_description', '').strip()
        product.focus_keyword    = data.get('focus_keyword', '').strip()
        product.canonical_url    = data.get('canonical_url', '').strip()
        product.og_image_url     = data.get('og_image_url', '').strip()
        product.target_region    = data.get('target_region', 'global').strip()
        product.is_auction         = 'is_auction' in data
        product.sale_method        = data.get('sale_method', 'fixed').strip() or 'fixed'
        product.reservation_fee    = data.get('reservation_fee') or None
        product.auction_reserve    = data.get('auction_reserve') or None
        product.auction_min_offer  = data.get('auction_min_offer') or None
        product.auction_status     = data.get('auction_status', 'available')
        auction_ends = data.get('auction_ends_at', '').strip()
        if auction_ends:
            from django.utils.dateparse import parse_datetime
            product.auction_ends_at = parse_datetime(auction_ends)
        else:
            product.auction_ends_at = None

        image_b64 = data.get('image_b64', '').strip()
        if image_b64 or 'image' in files:
            source = image_b64 if image_b64 else files['image']
            orig_name = (product.name or 'product') if image_b64 else files['image'].name
            try:
                webp_file, img_w, img_h = _convert_to_webp(source, orig_name)
                if img_w < 800 or img_h < 800:
                    messages.warning(
                        request,
                        f'Image is {img_w}x{img_h}px. The recommended minimum is 800x800, '
                        'so it may look soft on the shop page.'
                    )
                product.image = webp_file
            except Exception:
                if 'image' in files:
                    product.image = files['image']

        missing = []
        if not product.name:
            missing.append('name')
        if not product.category_id:
            missing.append('category')
        if not product.product_type:
            missing.append('product type')
        if not product.features.strip():
            missing.append('features')
        if missing:
            messages.error(request, 'Please fill in the required fields: ' + ', '.join(missing) + '.')
        else:
            product.save()
            messages.success(request, f'Product "{product.name}" saved.')
            if product.product_type == 'subscription':
                return redirect('cms_subscriptions')
            return redirect('cms_products')

    categories = ProductCategory.objects.filter(category_type='product').order_by('sort_order', 'name')
    product_types = [
        ('service',               'Service'),
        ('technical_service',     'Technical Service'),
        ('technical_consultation','Technical Consultation'),
        ('product',               'Product'),
        ('domain',                'Premium Domain'),
    ]
    badge_presets = BADGE_PRESETS
    from django.conf import settings as _settings
    import json as _json
    ck_config = _json.dumps(_settings.CKEDITOR_5_CONFIGS.get('extended', {}))
    return render(request, 'cms/ecommerce/product_edit.html', {
        'product':       product,
        'categories':    categories,
        'product_types': product_types,
        'badge_presets': badge_presets,
        'ck_config':     ck_config,
    })


@cms_required
def subscription_edit(request, pk=None):
    """Dedicated edit/add view for subscription products."""
    from apps.ecommerce.models import Product, ProductCategory
    import json
    product = get_object_or_404(Product, pk=pk) if pk else None

    if request.method == 'POST':
        data = request.POST
        files = request.FILES

        if not product:
            product = Product()
            product.tenant = _resolve_tenant(request)

        product.product_type      = 'subscription'
        product.billing_period    = 'monthly'
        product.name              = data.get('name', '').strip()

        from django.utils.text import slugify as _slugify
        _slug = _slugify(data.get('slug', '') or product.name)
        if _slug:
            _base, _i = _slug, 2
            while Product.objects.filter(slug=_slug).exclude(pk=product.pk).exists():
                _slug = '%s-%d' % (_base, _i)
                _i += 1
            product.slug = _slug
        product.badge             = data.get('badge', '').strip()
        product.website_url       = data.get('website_url', '').strip()
        product.enquiry_only      = 'enquiry_only' in data
        product.short_description = data.get('short_description', '').strip()
        product.description       = data.get('description', '').strip()
        product.features          = data.get('features', '').strip()
        product.price             = data.get('price') or 0
        product.annual_price      = data.get('annual_price') or None
        product.tax_included      = 'tax_included' in data
        product.stripe_price_id   = data.get('stripe_price_id', '').strip()
        product.icon_class        = data.get('icon_class', '').strip()
        product.is_active         = 'is_active' in data
        product.show_in_shop      = 'show_in_shop' in data
        product.is_featured       = 'is_featured' in data
        cat_id = data.get('category_id')
        product.category_id = int(cat_id) if cat_id else None
        product.meta_title       = data.get('meta_title', '').strip()
        product.meta_description = data.get('meta_description', '').strip()
        product.focus_keyword    = data.get('focus_keyword', '').strip()
        product.canonical_url    = data.get('canonical_url', '').strip()
        product.og_image_url     = data.get('og_image_url', '').strip()
        product.target_region    = data.get('target_region', 'global').strip()

        image_b64 = data.get('image_b64', '').strip()
        if image_b64 or 'image' in files:
            source = image_b64 if image_b64 else files['image']
            orig_name = (product.name or 'subscription') if image_b64 else files['image'].name
            try:
                webp_file, img_w, img_h = _convert_to_webp(source, orig_name)
                if img_w < 800 or img_h < 800:
                    messages.warning(
                        request,
                        f'Image is {img_w}x{img_h}px. The recommended minimum is 800x800, '
                        'so it may look soft on the shop page.'
                    )
                product.image = webp_file
            except Exception:
                if 'image' in files:
                    product.image = files['image']

        missing = []
        if not product.name:
            missing.append('name')
        if not product.category_id:
            missing.append('category')
        if not product.short_description.strip():
            missing.append('short description')
        if not product.features.strip():
            missing.append('features')
        if missing:
            messages.error(request, 'Please fill in the required fields: ' + ', '.join(missing) + '.')
        else:
            product.save()
            messages.success(request, f'Subscription "{product.name}" saved.')
            return redirect('cms_subscriptions')

    from django.conf import settings as _settings
    ck_config = json.dumps(_settings.CKEDITOR_5_CONFIGS.get('extended', {}))
    categories = ProductCategory.objects.filter(category_type='subscription').order_by('sort_order', 'name')
    return render(request, 'cms/ecommerce/subscription_edit.html', {
        'product':       product,
        'categories':    categories,
        'badge_presets': BADGE_PRESETS,
        'ck_config':     ck_config,
    })


@cms_required
def my_profile(request):
    """Profile page — every CMS user can view and edit their own account."""
    user = request.user
    if request.method == 'POST':
        user.first_name = request.POST.get('first_name', '').strip()
        user.last_name  = request.POST.get('last_name', '').strip()
        user.phone      = request.POST.get('phone', '').strip()
        user.company    = request.POST.get('company', '').strip()
        new_pw = request.POST.get('new_password', '').strip()
        if new_pw:
            import re as _re
            score = 0
            if len(new_pw) >= 10:              score += 25
            if _re.search(r'[A-Z]', new_pw):   score += 20
            if _re.search(r'[a-z]', new_pw):   score += 15
            if _re.search(r'[0-9]', new_pw):   score += 20
            if _re.search(r'[^A-Za-z0-9]', new_pw): score += 20
            if score < 80:
                messages.error(request, f'Password too weak (score {score}/100). Use at least 10 characters including uppercase, lowercase, number, and special character.')
                return redirect('cms_my_profile')
            user.set_password(new_pw)
        user.save()
        messages.success(request, 'Profile updated.')
        return redirect('cms_my_profile')
    return render(request, 'cms/profile.html', {'obj': user})


@cms_required
def notifications_dismiss(request):
    """Store current time in session so notification bar resets."""
    from django.utils import timezone
    from django.utils.http import url_has_allowed_host_and_scheme
    request.session['notif_cleared_at'] = timezone.now().isoformat()
    raw_next = request.GET.get('next', '/cms/')
    next_url = raw_next if url_has_allowed_host_and_scheme(
        raw_next, allowed_hosts={request.get_host()}
    ) else '/cms/'
    from django.http import HttpResponseRedirect
    return HttpResponseRedirect(next_url)


@cms_required
def sort_items(request):
    """
    Generic AJAX sort endpoint.
    POST body: {"model": "Service", "order": [1, 3, 2, ...]}
    Supported models: Service, WhyChooseUsBox, Testimonial, PartnerLogo,
                      PortfolioItem, Insight, MarketplaceService, FAQCategory, FAQItem
    """
    from django.http import JsonResponse
    if request.method != 'POST':
        return JsonResponse({'ok': False}, status=405)
    try:
        data = json.loads(request.body)
        model_name = data.get('model', '')
        order = data.get('order', [])

        MODEL_MAP = {
            'Service':           ('apps.pages.models',       'Service',           'order'),
            'WhyChooseUsBox':    ('apps.pages.models',       'WhyChooseUsBox',    'order'),
            'Testimonial':       ('apps.pages.models',       'Testimonial',       'order'),
            'PartnerLogo':       ('apps.pages.models',       'PartnerLogo',       'order'),
            'PortfolioItem':     ('apps.portfolio.models',   'PortfolioItem',     'order'),
            'Insight':           ('apps.insights.models',    'Insight',           'order'),
            'MarketplaceService':('apps.marketplace.models', 'MarketplaceService','order'),
            'FAQCategory':       ('apps.faq.models',         'FAQCategory',       'order'),
            'FAQItem':           ('apps.faq.models',         'FAQItem',           'order'),
            'SocialLink':        ('apps.pages.models',       'SocialLink',        'order'),
            'HomeCounter':       ('apps.pages.models',       'HomeCounter',       'order'),
            'HomeSlide':         ('apps.pages.models',       'HomeSlide',         'order'),
            'LegalPage':         ('apps.pages.models',       'LegalPage',         'order'),
            'Plugin':            ('apps.marketplace.models', 'Plugin',            'order'),
        }

        if model_name not in MODEL_MAP:
            return JsonResponse({'ok': False, 'error': 'Unknown model'}, status=400)

        module_path, class_name, field = MODEL_MAP[model_name]
        import importlib
        module = importlib.import_module(module_path)
        Model = getattr(module, class_name)

        for idx, pk in enumerate(order):
            Model.objects.filter(pk=pk).update(**{field: idx})

        return JsonResponse({'ok': True})
    except Exception as e:
        return JsonResponse({'ok': False, 'error': str(e)}, status=400)


@cms_required
def products_sort(request):
    """AJAX — save drag-drop sort order for products."""
    from apps.ecommerce.models import Product
    from django.http import JsonResponse
    if request.method == 'POST':
        try:
            order = json.loads(request.body).get('order', [])
            for idx, pid in enumerate(order):
                Product.objects.filter(pk=pid).update(sort_order=idx)
            return JsonResponse({'ok': True})
        except Exception as e:
            return JsonResponse({'ok': False, 'error': str(e)}, status=400)
    return JsonResponse({'ok': False}, status=405)


@cms_required
def product_delete(request, pk):
    from apps.ecommerce.models import Product
    product = get_object_or_404(Product, pk=pk)
    if request.method == 'POST':
        name = product.name
        product.delete()
        messages.success(request, f'Product "{name}" deleted.')
        if product.product_type == 'subscription':
            return redirect('cms_subscriptions')
        return redirect('cms_products')
    return render(request, 'cms/ecommerce/product_confirm_delete.html', {'product': product})


@cms_required
def product_image_delete(request, pk):
    from apps.ecommerce.models import Product
    product = get_object_or_404(Product, pk=pk)
    if request.method == 'POST' and product.image:
        product.image.delete(save=True)   # deletes file from disk + clears the field
        messages.success(request, 'Image deleted.')
    if product.product_type == 'subscription':
        return redirect('cms_subscription_edit', pk=pk)
    return redirect('cms_product_edit', pk=pk)


# ─── Ecommerce — Orders ───────────────────────────────────────────────────────

@cms_required
@module_required('ecommerce')
def orders_list(request):
    from apps.ecommerce.models import Order, OrderItem, ProductCategory
    from django.db.models import Count, Sum
    from decimal import Decimal

    status_filter = request.GET.get('status', '')
    cat_filter    = request.GET.get('cat', '').strip()
    search        = request.GET.get('q', '').strip()

    all_orders = Order.objects.all()

    # Counts per status for chips (before cat/search filters)
    status_counts = {
        row['status']: row['cnt']
        for row in all_orders.values('status').annotate(cnt=Count('id'))
    }
    total_count = sum(status_counts.values())

    # ── Revenue boxes — use amount_paid on the order (accurate regardless of status) ──
    # We aggregate per product_type from items, weighted by the order's payment ratio
    from django.db.models import F, FloatField, ExpressionWrapper
    revenue_orders = all_orders.exclude(status__in=[Order.STATUS_CANCELLED, Order.STATUS_REFUNDED]).filter(amount_paid__gt=0)
    # Build a dict: order_id -> amount_paid
    paid_map = {o['id']: o['amount_paid'] for o in revenue_orders.values('id', 'amount_paid')}
    # For each item on a paid order, attribute its share
    type_revenue = {}
    type_count   = {}
    if paid_map:
        from apps.ecommerce.models import OrderItem
        for item in OrderItem.objects.filter(order_id__in=paid_map.keys()).values('product_type', 'order_id', 'total_price'):
            pt = item['product_type'] or 'other'
            type_revenue[pt] = type_revenue.get(pt, Decimal('0')) + (item['total_price'] or Decimal('0'))
            type_count.setdefault(pt, set()).add(item['order_id'])
    type_count = {k: len(v) for k, v in type_count.items()}
    def _rev(t):
        return type_revenue.get(t) or Decimal('0')
    def _cnt(t):
        return type_count.get(t, 0)

    rev_boxes = [
        {'label': 'Web',             'type': 'web',          'icon': 'fa-laptop-code',    'color': '#0369a1', 'bg': '#f0f9ff', 'border': '#bae6fd'},
        {'label': 'Applications',    'type': 'hosting',      'icon': 'fa-display',        'color': '#b45309', 'bg': '#fffbeb', 'border': '#fde68a'},
        {'label': 'Products',        'type': 'product',      'icon': 'fa-box',            'color': '#15803d', 'bg': '#f0fdf4', 'border': '#bbf7d0'},
        {'label': 'Services',        'type': 'service',      'icon': 'fa-briefcase',      'color': '#0891b2', 'bg': '#ecfeff', 'border': '#a5f3fc'},
        {'label': 'Subscriptions',   'type': 'subscription', 'icon': 'fa-rotate',         'color': '#7c3aed', 'bg': '#faf5ff', 'border': '#e9d5ff'},
    ]
    for b in rev_boxes:
        b['revenue'] = _rev(b['type'])
        b['count']   = _cnt(b['type'])

    # Domains are sold as products, so their revenue is folded into the Products
    # box rather than shown separately. Without this the domain money would
    # vanish from the totals entirely.
    for b in rev_boxes:
        if b['type'] == 'product':
            b['revenue'] += _rev('domain')
            b['count']   += _cnt('domain')

    total_revenue = sum(b['revenue'] for b in rev_boxes)

    # Refunds sit outside the product-type split: this is money going back out,
    # so it is counted from order status rather than from line items and is
    # deliberately left out of total_revenue.
    refunded = all_orders.filter(status=Order.STATUS_REFUNDED)
    rev_boxes.append({
        'label':   'Refunds',
        'type':    'refunded',
        'icon':    'fa-rotate-left',
        'color':   '#dc2626',
        'bg':      '#fff5f5',
        'border':  '#fecaca',
        'revenue': refunded.aggregate(s=Sum('total'))['s'] or Decimal('0'),
        'count':   refunded.count(),
    })

    orders = all_orders.prefetch_related('items').order_by('-created_at')
    if status_filter:
        orders = orders.filter(status=status_filter)
    if cat_filter:
        orders = orders.filter(items__product__category__slug=cat_filter).distinct()
    if search:
        orders = orders.filter(
            Q(order_number__icontains=search) |
            Q(customer_name__icontains=search) |
            Q(customer_email__icontains=search)
        )

    # Build list of (val, label, count) for chips
    statuses_with_counts = [
        (val, label, status_counts.get(val, 0))
        for val, label in Order.STATUS_CHOICES
        if status_counts.get(val, 0) > 0
    ]

    categories = ProductCategory.objects.annotate(
        order_count=Count('products__orderitem__order', distinct=True)
    ).order_by('sort_order', 'name')

    from django.core.paginator import Paginator
    paginator = Paginator(orders, 20)
    orders_page = paginator.get_page(request.GET.get('page'))

    return render(request, 'cms/ecommerce/orders_list.html', {
        'orders':               orders_page,
        'status_filter':        status_filter,
        'cat_filter':           cat_filter,
        'search':               search,
        'statuses_with_counts': statuses_with_counts,
        'total_count':          total_count,
        'categories':           categories,
        'rev_boxes':            rev_boxes,
        'total_revenue':        total_revenue,
    })


@cms_required
def order_detail_cms(request, pk):
    from apps.ecommerce.models import Order, EmailLog
    order = get_object_or_404(Order, pk=pk)

    if request.method == 'POST':
        action = request.POST.get('action')
        if action == 'update_status':
            new_status = request.POST.get('status')
            if new_status in dict(Order.STATUS_CHOICES):
                order.status = new_status
                if new_status in [Order.STATUS_PAID, Order.STATUS_COMPLETED]:
                    if not order.paid_at:
                        order.paid_at = timezone.now()
                    # Sync amount_paid to total if not already set
                    if order.amount_paid == 0 and order.total > 0:
                        order.amount_paid = order.total
                elif new_status == Order.STATUS_DEPOSIT_PAID:
                    if order.amount_paid == 0 and order.total > 0:
                        order.amount_paid = order.total / 2
                order.save()
                messages.success(request, f'Order status updated to {order.get_status_display()}.')
        elif action == 'save_notes':
            order.internal_notes = request.POST.get('internal_notes', '')
            order.save(update_fields=['internal_notes'])
            messages.success(request, 'Notes saved.')
        elif action == 'save_customer':
            # Corrects details a customer mistyped at checkout. Name and email
            # are required because the invoice and every email depend on them.
            name  = request.POST.get('customer_name', '').strip()
            email = request.POST.get('customer_email', '').strip()
            if not name or not email:
                messages.error(request, 'Customer name and email are both required.')
            else:
                order.customer_name     = name
                order.customer_email    = email
                order.customer_phone    = request.POST.get('customer_phone', '').strip()
                order.customer_company  = request.POST.get('customer_company', '').strip()
                order.customer_address  = request.POST.get('customer_address', '').strip()
                order.customer_postcode = request.POST.get('customer_postcode', '').strip()
                order.customer_country  = request.POST.get('customer_country', '').strip()
                order.save(update_fields=[
                    'customer_name', 'customer_email', 'customer_phone',
                    'customer_company', 'customer_address', 'customer_postcode',
                    'customer_country',
                ])
                messages.success(request, 'Customer details updated.')
        elif action == 'resend_confirmation':
            try:
                from apps.ecommerce.email_utils import send_order_confirmation
                send_order_confirmation(order)
                messages.success(request, f'Confirmation email resent to {order.customer_email}.')
            except Exception as exc:
                messages.error(request, f'Resend failed: {exc}')
        return redirect('cms_order_detail', pk=pk)

    email_logs = EmailLog.objects.filter(
        to_email=order.customer_email,
        email_type=EmailLog.TYPE_ORDER_CONFIRM,
    ).order_by('-sent_at')[:5]

    return render(request, 'cms/ecommerce/order_detail.html', {
        'order': order,
        'status_choices': Order.STATUS_CHOICES,
        'email_logs': email_logs,
    })


# ── Order Refund ───────────────────────────────────────────────────────────────

@cms_required
def order_refund_cms(request, pk):
    """Cancel or refund an order depending on whether payment was received."""
    if request.method != 'POST':
        return redirect('cms_order_detail', pk=pk)
    from apps.ecommerce.models import Order
    from apps.ecommerce.email_utils import send_order_cancellation
    order = get_object_or_404(Order, pk=pk)
    if order.status in [Order.STATUS_REFUNDED, Order.STATUS_CANCELLED]:
        messages.warning(request, 'Order is already cancelled or refunded.')
        return redirect('cms_order_detail', pk=pk)

    # Determine whether money was actually received
    paid_statuses = [Order.STATUS_PAID, Order.STATUS_DEPOSIT_PAID, Order.STATUS_PROCESSING, Order.STATUS_COMPLETED]
    was_paid = order.status in paid_statuses or (order.amount_paid and order.amount_paid > 0)

    if was_paid:
        # Actually refund the money.
        #
        # This used to set the status, zero amount_paid and email the customer
        # to say they had been refunded, without ever calling Stripe. The
        # money stayed in the account and the customer had written
        # confirmation that it had not. Nothing is marked here unless Stripe
        # confirms the refund, and the email only goes after that.
        refund_id = ''
        intent = (order.stripe_payment_intent_id or '').strip()

        if intent:
            try:
                import stripe
                from apps.ecommerce.stripe_utils import _get_stripe_secret_key
                stripe.api_key = _get_stripe_secret_key(getattr(order, 'tenant', None))
                refund = stripe.Refund.create(
                    payment_intent=intent,
                    # Refund what was actually taken, not the order total. A
                    # deposit-only order must not be refunded in full.
                    amount=int(round(float(order.amount_paid) * 100)),
                )
                refund_id = refund.id
            except Exception as exc:
                log.error('order_refund_cms: Stripe refused the refund for %s: %s',
                          order.order_number, exc, exc_info=True)
                messages.error(
                    request,
                    f'Stripe would not refund {order.display_number}: {exc}. '
                    f'Nothing has been changed and the customer has not been emailed.'
                )
                return redirect('cms_order_detail', pk=pk)
        else:
            # No payment intent means the money did not come through Stripe,
            # a bank transfer or a card machine. Say so rather than pretending.
            messages.warning(
                request,
                f'{order.display_number} has no Stripe payment on record, so nothing '
                f'could be refunded automatically. Marked as refunded, but you must '
                f'return the money yourself.'
            )

        order.status      = Order.STATUS_REFUNDED
        order.amount_paid = 0
        order.save(update_fields=['status', 'amount_paid'])
        try:
            send_order_cancellation(order)
        except Exception as exc:
            messages.warning(request, f'Order refunded but cancellation email failed: {exc}')
        else:
            if refund_id:
                messages.success(
                    request,
                    f'Order {order.display_number} refunded through Stripe ({refund_id}) '
                    f'and customer notified.'
                )
            else:
                messages.success(request, f'Order {order.display_number} marked as Refunded and customer notified.')
    else:
        order.status = Order.STATUS_CANCELLED
        order.save(update_fields=['status'])
        messages.success(request, f'Order {order.display_number} marked as Cancelled.')

    return redirect('cms_orders')


# ── Deal Agreement ─────────────────────────────────────────────────────────────

@cms_required
def order_send_deal(request, pk):
    """Generate a deal agreement link and email it to the client."""
    from apps.ecommerce.models import Order, DealAgreement
    order = get_object_or_404(Order, pk=pk)

    if request.method == 'POST':
        # Detect terms type or let admin override
        terms_type = request.POST.get('terms_type') or DealAgreement.detect_terms_type(order)

        agreement, created = DealAgreement.objects.get_or_create(
            order=order,
            defaults={
                'terms_type':    terms_type,
                'sent_to_email': order.customer_email,
                'sent_to_name':  order.customer_name,
                'created_by':    request.user,
            }
        )
        if not created:
            # Resend — update terms type and name in case they changed
            agreement.terms_type    = terms_type
            agreement.sent_to_email = order.customer_email
            agreement.sent_to_name  = order.customer_name
            agreement.save(update_fields=['terms_type', 'sent_to_email', 'sent_to_name'])

        # Build the deal URL
        deal_url = request.build_absolute_uri(f'/deal/{agreement.token}/')

        # Send email
        try:
            from apps.ecommerce.email_utils import send_deal_email
            send_deal_email(order, deal_url)
            agreement.sent_at = timezone.now()
            agreement.save(update_fields=['sent_at'])
            messages.success(request, f'Deal link sent to {order.customer_email}.')
        except Exception as e:
            messages.error(request, f'Agreement created but email failed: {e}')
            messages.info(request, f'Manual link: {deal_url}')

        return redirect('cms_order_detail', pk=pk)

    # GET — show confirmation form
    existing = order.agreements.first()
    return render(request, 'cms/ecommerce/send_deal.html', {
        'order':          order,
        'existing':       existing,
        'terms_choices':  DealAgreement.TERMS_CHOICES,
        'detected_type':  DealAgreement.detect_terms_type(order),
    })


# ── Expenses ───────────────────────────────────────────────────────────────────

@cms_required
@module_required('ecommerce')
def expenses_list(request):
    from apps.ecommerce.models import Expense
    from decimal import Decimal
    from django.utils import timezone

    cat_filter = request.GET.get('cat', '')
    q          = request.GET.get('q', '').strip()
    expenses   = Expense.objects.all().order_by('-date')
    if cat_filter:
        expenses = expenses.filter(category=cat_filter)
    if q:
        expenses = expenses.filter(Q(supplier__icontains=q) | Q(description__icontains=q))

    total = expenses.aggregate(t=Sum('amount'))['t'] or Decimal('0')

    # Summary stats always from full dataset (no filters) for the boxes
    all_exp = Expense.objects.all()
    def _agg(bp):
        qs = all_exp.filter(billing_period=bp)
        return {
            'count':  qs.count(),
            'amount': qs.aggregate(t=Sum('amount'))['t'] or Decimal('0'),
        }

    stats_once    = _agg('once')
    stats_hourly  = _agg('hourly')
    stats_monthly = _agg('monthly')
    stats_annual  = _agg('annual')
    total_all    = all_exp.aggregate(t=Sum('amount'))['t'] or Decimal('0')
    total_count  = all_exp.count()

    cat_counts = {
        row['category']: row['cnt']
        for row in all_exp.values('category').annotate(cnt=Count('id'))
    }
    categories_with_counts = [
        (val, label, cat_counts.get(val, 0))
        for val, label in Expense.CATEGORY_CHOICES
        if cat_counts.get(val, 0) > 0
    ]

    from django.core.paginator import Paginator
    paginator = Paginator(expenses, 20)
    expenses_page = paginator.get_page(request.GET.get('page'))

    return render(request, 'cms/ecommerce/expenses_list.html', {
        'expenses':               expenses_page,
        'total':                  total,
        'cat_filter':             cat_filter,
        'q':                      q,
        'today':                  timezone.now().date(),
        'category_choices':       Expense.CATEGORY_CHOICES,
        'categories_with_counts': categories_with_counts,
        # boxes
        'total_all':        total_all,
        'total_count':      total_count,
        'stats_once':       stats_once,
        'stats_hourly':     stats_hourly,
        'stats_monthly':    stats_monthly,
        'stats_annual':     stats_annual,
    })


@cms_required
def expense_edit(request, pk=None):
    from apps.ecommerce.models import Expense
    expense = get_object_or_404(Expense, pk=pk) if pk else None

    if request.method == 'POST':
        category       = request.POST.get('category', '').strip()
        billing_period = request.POST.get('billing_period', '').strip()
        supplier       = request.POST.get('supplier', '').strip()
        description    = request.POST.get('description', '').strip()
        amount         = request.POST.get('amount', '0').strip()
        date           = request.POST.get('date', '').strip()
        end_date       = request.POST.get('end_date', '').strip() or None

        missing = []
        if not category:
            missing.append('category')
        if not supplier:
            missing.append('supplier')
        if not billing_period:
            missing.append('billing period')
        if not description:
            missing.append('circulation')
        if not amount:
            missing.append('amount')
        if not date:
            missing.append('start date')
        if missing:
            messages.error(request, 'Please fill in the required fields: ' + ', '.join(missing) + '.')
            return render(request, 'cms/ecommerce/expense_form.html', {
                'expense': expense, 'category_choices': Expense.CATEGORY_CHOICES,
                'billing_choices': Expense.BILLING_CHOICES,
                'form_data': request.POST,
            })

        try:
            from decimal import Decimal as D
            if expense:
                expense.category       = category
                expense.billing_period = billing_period
                expense.supplier       = supplier
                expense.description    = description
                expense.amount         = D(amount)
                expense.date           = date
                expense.end_date       = end_date
                expense.save()
                messages.success(request, 'Expense updated.')
            else:
                Expense.objects.create(
                    category=category, billing_period=billing_period,
                    supplier=supplier, description=description,
                    amount=D(amount), date=date, end_date=end_date,
                )
                messages.success(request, 'Expense added.')
            return redirect('cms_expenses')
        except Exception as e:
            messages.error(request, f'Error saving expense: {e}')

    return render(request, 'cms/ecommerce/expense_form.html', {
        'expense':          expense,
        'category_choices': Expense.CATEGORY_CHOICES,
        'billing_choices':  Expense.BILLING_CHOICES,
    })


@cms_required
def expense_delete(request, pk):
    from apps.ecommerce.models import Expense
    expense = get_object_or_404(Expense, pk=pk)
    if request.method == 'POST':
        expense.delete()
        messages.success(request, 'Expense deleted.')
        return redirect('cms_expenses')
    return render(request, 'cms/ecommerce/expense_confirm_delete.html', {'expense': expense})


# ─── Email Log ────────────────────────────────────────────────────────────────

@super_admin_required
def email_log_list(request):
    # Send log (safe even if table doesn't exist yet)
    show_archived = request.GET.get('archived') == '1'
    archived_count = 0
    type_filter   = request.GET.get('type', '')
    status_filter = request.GET.get('status', '')
    search        = request.GET.get('q', '').strip()
    page_obj = None
    total = 0
    type_choices = []
    type_counts = {}
    sent_count = 0
    fail_count = 0
    try:
        from apps.ecommerce.models import EmailLog
        from django.db.models import Q
        type_choices = EmailLog.TYPE_CHOICES
        all_qs = EmailLog.objects.filter(is_archived=show_archived)
        archived_count = EmailLog.objects.filter(is_archived=True).count()
        # Counts per type — read directly from DB so no constant mismatch
        from django.db.models import Count
        _raw_counts = {
            row['email_type']: row['n']
            for row in all_qs.values('email_type').annotate(n=Count('pk'))
        }
        # Build lookup: value → label from TYPE_CHOICES, fallback to raw value
        _type_label = dict(type_choices)
        # Union of known choices + any DB types not in choices
        _all_types = list(dict.fromkeys(
            [t for t, _ in type_choices] + list(_raw_counts.keys())
        ))
        type_choices_with_counts = [
            (t, _type_label.get(t, t.replace('_', ' ').title()), _raw_counts.get(t, 0))
            for t in _all_types
            if _raw_counts.get(t, 0) > 0 or t in _type_label
        ]
        sent_count = all_qs.filter(ok=True).count()
        fail_count = all_qs.filter(ok=False).count()
        # Filtered queryset for the table
        qs = all_qs
        if type_filter:
            qs = qs.filter(email_type=type_filter)
        if status_filter == 'ok':
            qs = qs.filter(ok=True)
        elif status_filter == 'fail':
            qs = qs.filter(ok=False)
        if search:
            qs = qs.filter(Q(subject__icontains=search) | Q(to_email__icontains=search))
        total = qs.count()
        from django.core.paginator import Paginator
        paginator = Paginator(qs.order_by('-sent_at'), 50)
        page_obj = paginator.get_page(request.GET.get('page', 1))
    except Exception:
        pass  # table not yet migrated

    return render(request, 'cms/emails/list.html', {
        'page_obj':        page_obj,
        'type_filter':     type_filter,
        'status_filter':   status_filter,
        'search':          search,
        'type_choices':              type_choices,
        'type_choices_with_counts':  type_choices_with_counts,
        'sent_count':      sent_count,
        'fail_count':      fail_count,
        'total':           total,
        'show_archived':   show_archived,
        'archived_count':  archived_count,
    })


@super_admin_required
def email_log_detail(request, pk):
    from apps.ecommerce.models import EmailLog
    log = get_object_or_404(EmailLog, pk=pk)

    # Older ticket replies were logged without their body. The text is still on
    # the ticket, so find it there rather than showing an empty frame. The
    # subject carries the number, for example "Re: ... [#FT-882748]".
    # What this email was about, so the page links to the record rather than
    # offering raw HTML nobody reads. Subjects carry the reference already.
    related_url, related_label = '', ''
    _subj = log.subject or ''
    try:
        import re as _re
        _tn = _re.search(r'#?([A-Z]{2,4}-\d{4,})', _subj)
        if _tn:
            ref = _tn.group(1)
            from apps.tickets.models import Ticket
            _t = Ticket.objects.filter(ticket_number=ref).first()
            if _t:
                related_url = reverse('cms_ticket_detail', args=[_t.pk])
                related_label = f'Open Ticket #{ref}'
            else:
                from apps.ecommerce.models import Order
                _o = Order.objects.filter(order_number=ref).first()
                if _o:
                    related_url = reverse('cms_order_detail', args=[_o.pk])
                    related_label = f'Open Order #{ref}'
        if not related_url:
            _inv = _re.search(r'Invoice\s+([\w-]+)', _subj)
            if _inv:
                from apps.tenants.models import TenantInvoice
                _i = TenantInvoice.objects.filter(invoice_number=_inv.group(1)).first()
                if _i:
                    related_url = reverse('cms_tenant_invoice_detail', args=[_i.pk])
                    related_label = f'Open Invoice {_i.invoice_number}'
        if not related_url and 'Quotation' in _subj:
            from apps.sales.models import ProjectAssessment
            _a = (ProjectAssessment.objects.filter(email__iexact=log.to_email)
                  .order_by('-created_at').first())
            if _a:
                related_url = reverse('cms_assessment_detail', args=[_a.pk])
                related_label = 'Open Assessment'
    except Exception:
        related_url, related_label = '', ''

    ticket, rebuilt_html = None, ''
    if not log.html_body:
        import re as _re
        match = _re.search(r'#([A-Za-z]+-\d+)', log.subject or '')
        if match:
            try:
                from apps.tickets.models import Ticket
                from django.template.loader import render_to_string
                ticket = Ticket.objects.filter(ticket_number=match.group(1)).first()
                reply = (ticket.replies.filter(is_internal=False, sent_by_email=True)
                         .order_by('-created_at').first()) if ticket else None
                if reply:
                    # Rebuild it through the same template the client received,
                    # so the record looks like the email rather than a text dump.
                    rebuilt_html = render_to_string(
                        'public/emails/ticket_reply_email.html',
                        {
                            'subject':       ticket.subject,
                            'ticket_number': ticket.ticket_number,
                            'customer_name': (ticket.sender_name or '').split()[0]
                                             if ticket.sender_name else '',
                            'message':       reply.message,
                            'sig_name':      f'{_company()} Helpdesk',
                            'sig_dept':      'Client Services',
                        },
                    )
            except Exception:
                pass  # a lookup must never break the page

    return render(request, 'cms/emails/detail.html', {
        'log': log,
        'ticket': ticket,
        'rebuilt_html': rebuilt_html,
        'related_url': related_url,
        'related_label': related_label,
    })


@super_admin_required
@require_POST
def email_log_archive(request, pk):
    """Move a record out of the main list without destroying it."""
    from apps.ecommerce.models import EmailLog
    log = get_object_or_404(EmailLog, pk=pk)
    log.is_archived = True
    log.save(update_fields=['is_archived'])
    messages.success(request, f'Email record #{pk} archived.')
    return redirect(request.POST.get('next') or 'cms_email_log')


@super_admin_required
@require_POST
def email_log_restore(request, pk):
    """Bring an archived record back to the main list."""
    from apps.ecommerce.models import EmailLog
    log = get_object_or_404(EmailLog, pk=pk)
    log.is_archived = False
    log.save(update_fields=['is_archived'])
    messages.success(request, f'Email record #{pk} restored.')
    return redirect(request.POST.get('next') or 'cms_email_log')


@super_admin_required
def email_log_delete(request, pk):
    from apps.ecommerce.models import EmailLog
    log = get_object_or_404(EmailLog, pk=pk)
    if request.method == 'POST':
        log.delete()
        # The list runs its own countdown and has already said so in red. A
        # green success banner after it reads as a second, cheerier event.
        if 'silent' not in request.POST:
            messages.success(request, f'Email record #{pk} deleted.')
        return redirect(f"{reverse('cms_email_log')}?archived=1")
    return render(request, 'cms/emails/confirm_delete.html', {'log': log})


@super_admin_required
def email_resend(request, pk):
    from apps.ecommerce.models import EmailLog
    from django.core.mail import EmailMessage
    log = get_object_or_404(EmailLog, pk=pk)
    try:
        msg = EmailMessage(
            subject=log.subject,
            body=log.html_body,
            from_email=log.from_email or None,
            to=[log.to_email],
        )
        msg.content_subtype = 'html'
        msg.send()
        log.ok = True
        log.error = ''
        log.save(update_fields=['ok', 'error'])
        messages.success(request, f'Email resent to {log.to_email}.')
    except Exception as exc:
        messages.error(request, f'Resend failed: {exc}')
    return redirect('cms_email_log')


# Email template registry — key → (label, filename, preview_slug, trigger_description)
_EMAIL_TEMPLATES = {
    'order-confirmation': ('Order Confirmation',    'order_confirmation.html',       'order-confirmation', 'Sent to customer when they complete a payment'),
    'order-admin':        ('Admin Notification',    'order_admin_notification.html', 'order-admin',        'Sent to admin inbox when a new order arrives'),
    'quote':              ('Project Quote',         'quote_email.html',              'quote',              'Sent when you click Send Quote on an assessment'),
    'deal':               ('Deal Agreement',        'deal_email.html',               'deal',               'Sent when you generate a Deal Agreement link'),
    'ticket-reply':       ('Support Reply',         'ticket_reply_email.html',       'ticket-reply',       'Sent to the client when you reply to a ticket with Send email ticked'),
    'incident-down':      ('Site Down Alert',       'incident_down.html',            'incident-down',      'Sent to every system administrator the moment monitoring cannot reach a site'),
    'incident-recovered': ('Site Recovered',        'incident_recovered.html',       'incident-recovered', 'Sent when a site responds again and the incident closes'),
    'renewal-warning':    ('Renewal Notice',        'renewal_warning.html',          'renewal-warning',    'Sent to the client 14, 7 and 3 days before their renewal date, and again on the day'),
}

# What has to be switched on for each template to ever be sent.
#
# The list above is written for the fleet master, which sells hosting, takes
# orders and watches every site. A tenant with no shop cannot produce an order
# confirmation and a tenant that is not the master never sends a renewal
# notice, so those rows were eight dead links on most panels.
#
# 'master' means the fleet master only. A key absent from this map is shown
# everywhere.
_EMAIL_TEMPLATE_NEEDS = {
    'order-confirmation': 'ecommerce',
    'order-admin':        'ecommerce',
    'quote':              'crm',
    'deal':               'crm',
    'ticket-reply':       'tickets',
    'incident-down':      'master',
    'incident-recovered': 'master',
    'renewal-warning':    'master',
}


def _email_templates_for(tenant):
    """The templates this install can actually send, in the order defined."""
    from django.conf import settings as _s

    # The fleet master is the install that reports to nobody.
    is_master = not getattr(_s, 'FT_CONTROL_URL', '')

    out = []
    for key, (label, _filename, preview_slug, trigger_desc) in _EMAIL_TEMPLATES.items():
        need = _EMAIL_TEMPLATE_NEEDS.get(key)
        if need == 'master':
            if not is_master:
                continue
        elif need:
            if not (tenant and tenant.has_module(need)):
                continue
        out.append({
            'key': key, 'label': label,
            'preview_slug': preview_slug, 'trigger_desc': trigger_desc,
        })
    return out


@super_admin_required
def email_templates_list(request):
    from django.conf import settings as _s
    templates_dir = _s.BASE_DIR / 'templates' / 'public' / 'emails'
    items = []
    for key, (label, filename, preview_slug, trigger_desc) in _EMAIL_TEMPLATES.items():
        path = templates_dir / filename
        items.append({
            'key':          key,
            'label':        label,
            'filename':     filename,
            'preview_slug': preview_slug,
            'trigger_desc': trigger_desc,
            'exists':       path.exists(),
        })
    return render(request, 'cms/emails/templates.html', {'items': items})


@super_admin_required
def email_template_edit(request, key):
    from django.conf import settings as _s
    if key not in _EMAIL_TEMPLATES:
        from django.http import Http404
        raise Http404
    label, filename, preview_slug, trigger_desc = _EMAIL_TEMPLATES[key]
    path = _s.BASE_DIR / 'templates' / 'public' / 'emails' / filename
    error = None
    if request.method == 'POST':
        content = request.POST.get('content', '')
        try:
            with open(path, 'w', encoding='utf-8') as f:
                f.write(content)
            messages.success(request, f'{label} template saved.')
            return redirect('cms_email_template_edit', key=key)
        except Exception as exc:
            error = str(exc)
    try:
        content = path.read_text(encoding='utf-8')
    except Exception:
        content = ''
    return render(request, 'cms/emails/template_edit.html', {
        'key':          key,
        'label':        label,
        'filename':     filename,
        'preview_slug': preview_slug,
        'trigger_desc': trigger_desc,
        'content':      content,
        'error':        error,
    })


# ─── Footer Tagline ───────────────────────────────────────────────────────────

@cms_required
def footer_tagline_edit(request):
    tenant = _resolve_tenant(request)
    obj    = SiteSettings.load(tenant)
    if request.method == 'POST':
        obj.footer_tagline = request.POST.get('footer_tagline', '').strip()
        # Column headings and the copyright line live on the same screen, so
        # the whole footer is edited in one place rather than three.
        for f in ('footer_col1_title', 'footer_col2_title',
                  'footer_col3_title', 'footer_copyright'):
            if f in request.POST:
                setattr(obj, f, request.POST[f].strip())
        align = request.POST.get('footer_tagline_align', '').strip()
        if align in dict(SiteSettings._meta.get_field('footer_tagline_align').choices):
            obj.footer_tagline_align = align
        obj.save()
        return redirect(reverse('cms_homepage') + '#footer-sections')
    from django.conf import settings as _settings
    ck_config = json.dumps(_settings.CKEDITOR_5_CONFIGS.get('extended', {}))
    return render(request, 'cms/homepage/footer_tagline.html', {'obj': obj, 'ck_config': ck_config})


# ─── Footer Links ─────────────────────────────────────────────────────────────

@cms_required
def footer_links_list(request):
    tenant     = _resolve_tenant(request)
    col_filter = request.GET.get('col', '')
    qs         = FooterLink.objects.filter(tenant=tenant).order_by('column', 'order')

    # Column chips: (key, label, count)
    col_counts = {}
    for fl in qs:
        col_counts[fl.column] = col_counts.get(fl.column, 0) + 1
    columns = [
        (key, label, col_counts.get(key, 0))
        for key, label in FooterLink.COLUMN_CHOICES
    ]
    total = qs.count()

    if col_filter:
        links = qs.filter(column=col_filter)
    else:
        links = qs

    return render(request, 'cms/footer/list.html', {
        'links':      links,
        'columns':    columns,
        'col_filter': col_filter,
        'total':      total,
    })


@cms_required
def footer_link_edit(request, pk=None):
    tenant = _resolve_tenant(request)
    obj    = get_object_or_404(FooterLink, pk=pk, tenant=tenant) if pk else None
    if request.method == 'POST':
        column    = request.POST.get('column', '')
        label     = request.POST.get('label', '').strip()
        url       = request.POST.get('url', '').strip()
        target    = request.POST.get('target', '_self')
        is_active = request.POST.get('is_active') == 'on'
        if not column or not label or not url:
            messages.error(request, 'Column, label, and URL are required.')
        else:
            if obj is None:
                max_order = FooterLink.objects.filter(tenant=tenant, column=column).count()
                obj = FooterLink(tenant=tenant, order=max_order)
            obj.column    = column
            obj.label     = label
            obj.url       = url
            obj.target    = target if target in ('_self', '_blank') else '_self'
            obj.is_active = is_active
            obj.save()
            messages.success(request, 'Footer link saved.')
            return redirect('cms_footer_links')
    return render(request, 'cms/footer/edit.html', {
        'obj':            obj,
        'column_choices': FooterLink.COLUMN_CHOICES,
    })


@cms_required
def footer_link_toggle(request, pk):
    tenant = _resolve_tenant(request)
    fl     = get_object_or_404(FooterLink, pk=pk, tenant=tenant)
    FooterLink.objects.filter(pk=pk).update(is_active=not fl.is_active)
    return redirect('cms_footer_links')


@cms_required
def footer_link_delete(request, pk):
    tenant = _resolve_tenant(request)
    fl     = get_object_or_404(FooterLink, pk=pk, tenant=tenant)
    if request.method == 'POST':
        fl.delete()
        messages.success(request, 'Footer link deleted.')
    return redirect('cms_footer_links')


@cms_required
def footer_links_sort(request):
    import json as _json
    if request.method == 'POST':
        try:
            order = _json.loads(request.body).get('order', [])
            for i, pk in enumerate(order):
                FooterLink.objects.filter(pk=pk).update(order=i)
        except Exception:
            pass
    from django.http import JsonResponse
    return JsonResponse({'ok': True})


# ─── Backup ───────────────────────────────────────────────────────────────────

def _backup_dir():
    from django.conf import settings
    from pathlib import Path
    d = Path(getattr(settings, 'BACKUP_DIR', settings.BASE_DIR.parent / 'backups'))
    d.mkdir(parents=True, exist_ok=True)
    return d


def _backup_db_path():
    from django.conf import settings
    from pathlib import Path
    name = settings.DATABASES['default']['NAME']
    p = Path(name)
    return p if p.is_absolute() else settings.BASE_DIR / name


def _backup_build_name():
    """Return (display_label, zip_filename) for a new backup."""
    from django.conf import settings
    from datetime import datetime
    version = getattr(settings, 'CMS_VERSION', '1.0.0')
    # The domain comes from the tenant row, not from SiteSettings.
    #
    # This used to read SiteSettings.site_url, a field that does not exist on
    # the model, so getattr returned '' every time and every backup on every
    # install was called "site". With fifty installs that is fifty files with
    # the same name and no way to tell them apart in a restore.
    try:
        from apps.tenants.models import Tenant
        tenant = Tenant.objects.filter(is_master=True).first()
        domain = (getattr(tenant, 'primary_domain', '') or '').strip()
        domain = domain.replace('https://', '').replace('http://', '').rstrip('/')
        if not domain:
            domain = (getattr(settings, 'SITE_URL', '') or '')
            domain = domain.replace('https://', '').replace('http://', '').rstrip('/')
        domain = domain or 'site'
    except Exception:
        domain = 'site'
    now = datetime.now()
    display  = f"{domain} v{version} {now.strftime('%d/%m/%Y %H:%M')}"
    filename = f"{domain} v{version} {now.strftime('%d-%m-%Y %H-%M')}.zip"
    return display, filename


def _backup_create_zip(zip_path):
    """
    A complete, restorable backup: database, code, media, config, instructions.

    The previous version looked for the database as a file on disk, which is
    only true on SQLite. On PostgreSQL that path never exists, so the write was
    skipped and the archive shipped with no data in it at all, while the page
    promised otherwise. It now dumps through pg_dump and raises if that fails,
    because a backup that quietly omits the database is worse than none.
    """
    import zipfile, json, subprocess, os, datetime as _dt
    from django.conf import settings
    from pathlib import Path
    import django

    base       = Path(settings.BASE_DIR)
    media_root = Path(settings.MEDIA_ROOT)
    db         = settings.DATABASES['default']
    engine     = db.get('ENGINE', '')
    backup_root = _backup_dir().resolve()

    # ── database ────────────────────────────────────────────────────────────
    dump_bytes, db_note = b'', ''
    if 'postgresql' in engine:
        env = os.environ.copy()
        if db.get('PASSWORD'):
            env['PGPASSWORD'] = db['PASSWORD']
        # -w so it never prompts. A hidden password prompt inside a captured
        # subprocess hangs the request forever instead of failing.
        cmd = ['pg_dump', '-w', '--no-owner', '--no-privileges', db['NAME']]
        if db.get('USER'):
            cmd += ['-U', db['USER']]
        if db.get('HOST'):
            cmd += ['-h', db['HOST']]
        if db.get('PORT'):
            cmd += ['-p', str(db['PORT'])]
        try:
            proc = subprocess.run(cmd, capture_output=True, env=env, timeout=900)
        except subprocess.TimeoutExpired:
            raise RuntimeError(
                'pg_dump did not finish within 15 minutes and was stopped. '
                'The usual cause is authentication waiting on input.'
            )
        if proc.returncode != 0 or not proc.stdout:
            raise RuntimeError(
                'pg_dump failed, so the backup would contain no database. '
                + (proc.stderr.decode('utf-8', 'replace')[:300] or 'no output')
            )
        dump_bytes = proc.stdout
        db_note = f'PostgreSQL dump of {db["NAME"]}, {len(dump_bytes) // 1024} KB'
    else:
        sqlite_file = Path(db['NAME'])
        if not sqlite_file.is_absolute():
            sqlite_file = base / db['NAME']
        if not sqlite_file.exists():
            raise RuntimeError(f'Database file not found at {sqlite_file}.')
        dump_bytes = sqlite_file.read_bytes()
        db_note = f'SQLite file, {len(dump_bytes) // 1024} KB'

    # ── what to leave out of the code copy ──────────────────────────────────
    SKIP_DIRS = {'venv', '__pycache__', 'node_modules', 'media', 'staticfiles',
                 '.git', 'backups'}

    def _code_files():
        # os.walk with pruning, not rglob. rglob enumerates everything first,
        # including tens of thousands of files inside venv, which made a
        # backup look like it had hung.
        for root, dirnames, filenames in os.walk(base):
            dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
            root_path = Path(root)
            try:
                if root_path == backup_root or backup_root in root_path.parents:
                    dirnames[:] = []
                    continue
            except Exception:
                pass
            for name in filenames:
                if name.endswith(('.pyc', '.pyo')):
                    continue
                f = root_path / name
                yield f, f.relative_to(base)

    manifest = {
        'cms_version':    getattr(settings, 'CMS_VERSION', '1.0.0'),
        'created':        _dt.datetime.now().isoformat(timespec='seconds'),
        'django_version': '.'.join(str(x) for x in django.VERSION[:3]),
        'python_version': '.'.join(str(x) for x in __import__('sys').version_info[:3]),
        'db_engine':      engine.rsplit('.', 1)[-1],
        'db_name':        db.get('NAME', ''),
        'db_file':        'database.sql' if 'postgresql' in engine else 'db.sqlite3',
        'includes_media': media_root.exists(),
        'includes_code':  True,
        'includes_env':   (base / '.env').exists(),
    }

    restore_txt = (
        "RESTORE INSTRUCTIONS\n"
        "====================\n\n"
        "This archive restores the whole site: database, code, media and\n"
        "environment. Everything below assumes the paths this server uses.\n\n"
        "Option A, from the panel\n"
        "  Backup > Restore next to this file. Restores the database and media.\n"
        "  Code is not replaced this way, on purpose: overwriting running code\n"
        "  from a web request is how a half-written deploy takes a site down.\n\n"
        "Option B, a full rebuild over SSH\n"
        "  1. systemctl stop <service>\n"
        "  2. unzip this file into /root/restore\n"
        "  3. Code:\n"
        "       cp -a /root/restore/code/. /home/<user>/public_html/backend/\n"
        "  4. Environment (holds every credential, keep it 600):\n"
        "       cp /root/restore/env/.env /home/<user>/public_html/backend/.env\n"
        "       chmod 600 /home/<user>/public_html/backend/.env\n"
        "  5. Python packages:\n"
        "       cd /home/<user>/public_html/backend\n"
        "       python3 -m venv venv\n"
        "       venv/bin/pip install -r config/requirements.frozen.txt\n"
        "  6. Database:\n"
        "       sudo -u postgres dropdb --if-exists " + str(db.get('NAME', '')) + "\n"
        "       sudo -u postgres createdb " + str(db.get('NAME', '')) + "\n"
        "       sudo -u postgres psql " + str(db.get('NAME', '')) + " < /root/restore/database.sql\n"
        "  7. Media:\n"
        "       cp -a /root/restore/media/. /home/<user>/public_html/backend/media/\n"
        "  8. systemctl start <service>\n"
        "  9. Check before trusting it:\n"
        "       venv/bin/python manage.py migrate --plan --settings=config.settings.production\n\n"
        "Note. FIELD_ENCRYPTION_KEY inside .env is what makes the encrypted\n"
        "columns readable. Regenerate it and the old values are lost for good.\n"
    )

    with zipfile.ZipFile(str(zip_path), 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as zf:
        zf.writestr('backup_manifest.json', json.dumps(manifest, indent=2))
        zf.writestr('RESTORE.txt', restore_txt)
        zf.writestr(manifest['db_file'], dump_bytes)

        for f, rel in _code_files():
            try:
                zf.write(str(f), 'code/' + str(rel))
            except (OSError, PermissionError):
                continue

        env_file = base / '.env'
        if env_file.exists():
            try:
                zf.writestr('env/.env', env_file.read_text())
            except Exception:
                pass

        # Frozen requirements, so the environment can be rebuilt exactly
        try:
            pip = base / 'venv' / 'bin' / 'pip'
            if pip.exists():
                frozen = subprocess.run([str(pip), 'freeze'], capture_output=True, timeout=120)
                if frozen.returncode == 0:
                    zf.writestr('config/requirements.frozen.txt', frozen.stdout.decode())
        except Exception:
            pass

        if media_root.exists():
            for f in media_root.rglob('*'):
                if f.is_file():
                    try:
                        zf.write(str(f), 'media/' + str(f.relative_to(media_root)))
                    except (OSError, PermissionError):
                        continue

    # Verify rather than assume. An archive missing the database is the exact
    # failure this replaces.
    with zipfile.ZipFile(str(zip_path), 'r') as zf:
        names = zf.namelist()
        for required in (manifest['db_file'], 'RESTORE.txt', 'backup_manifest.json'):
            if required not in names:
                raise RuntimeError(f'{required} missing from the archive.')
        if zf.getinfo(manifest['db_file']).file_size == 0:
            raise RuntimeError('The database entry in the archive is empty.')
        bad = zf.testzip()
        if bad:
            raise RuntimeError(f'The archive is corrupt at {bad}.')

    return zip_path


@super_admin_required
def backup_list(request):
    import datetime as _dt
    bd = _backup_dir()
    backups = []
    for f in sorted(bd.glob('*.zip'), key=lambda x: x.stat().st_mtime, reverse=True):
        if f.name.startswith('_trash_'):
            continue
        s = f.stat()
        size_mb = round(s.st_size / 1_048_576, 2)
        backups.append({
            'filename': f.name,
            'display':  f.stem,
            'size_mb':  size_mb,
            'modified': _dt.datetime.fromtimestamp(s.st_mtime),
        })
    pending_delete = request.GET.get('deleted', '')
    if pending_delete and ('/' in pending_delete or '..' in pending_delete):
        pending_delete = ''
    # Only offer an undo if there is actually something to undo. The banner used
    # to come from the URL alone, so refreshing the page restarted the countdown
    # for a file that had already been purged, over and over.
    if pending_delete:
        trash = bd / f'_trash_{pending_delete}'
        if not trash.exists():
            pending_delete = ''
        else:
            import time as _time
            if _time.time() - trash.stat().st_mtime > 60:
                # Older than the countdown it belongs to: stale, not pending.
                pending_delete = ''
    from pathlib import Path as _P
    pending_display = _P(pending_delete).stem if pending_delete else ''
    from apps.accounts.backup_status import nightly_status
    return render(request, 'cms/backup/list.html', {
        'backups': backups,
        'pending_delete': pending_delete,
        'pending_display': pending_display,
        'nightly': nightly_status(),
    })


@super_admin_required
def backup_create(request):
    if request.method != 'POST':
        return redirect('cms_backup')
    try:
        _, filename = _backup_build_name()
        zip_path = _backup_dir() / filename
        _backup_create_zip(zip_path)
        messages.success(request, f'Backup created: {filename}')
    except Exception as exc:
        messages.error(request, f'Backup failed: {exc}')
    return redirect('cms_backup')


@super_admin_required
def backup_download(request, filename):
    from django.http import FileResponse, Http404
    if '/' in filename or '..' in filename:
        raise Http404
    filepath = _backup_dir() / filename
    if not filepath.exists():
        raise Http404
    response = FileResponse(open(str(filepath), 'rb'), content_type='application/zip')
    response['Content-Disposition'] = f'attachment; filename="{filename}"'
    return response


@super_admin_required
def backup_delete(request, filename):
    from django.http import Http404
    from django.urls import reverse
    if '/' in filename or '..' in filename:
        raise Http404
    filepath = _backup_dir() / filename
    if not filepath.exists():
        raise Http404
    if request.method == 'POST':
        trash_path = _backup_dir() / f'_trash_{filename}'
        filepath.rename(trash_path)
        return redirect(f"{reverse('cms_backup')}?deleted={filename}")
    return render(request, 'cms/backup/confirm_delete.html', {
        'filename': filename,
        'display':  filepath.stem,
    })


@super_admin_required
def backup_undo(request, filename):
    from django.http import Http404
    if '/' in filename or '..' in filename:
        raise Http404
    if request.method == 'POST':
        trash_path = _backup_dir() / f'_trash_{filename}'
        restore_path = _backup_dir() / filename
        if trash_path.exists():
            trash_path.rename(restore_path)
    return redirect('cms_backup')


@super_admin_required
def backup_purge(request, filename):
    from django.http import JsonResponse, Http404
    if '/' in filename or '..' in filename:
        raise Http404
    if request.method == 'POST':
        trash_path = _backup_dir() / f'_trash_{filename}'
        removed = False
        size = ''
        if trash_path.exists():
            try:
                size = f'{trash_path.stat().st_size / (1024 * 1024):.1f} MB'
            except OSError:
                size = 'unknown'
            trash_path.unlink()
            removed = True
        # Only alert when a file actually went. The undo countdown calls this
        # endpoint whether or not anything is left to remove.
        if removed:
            try:
                from apps.ftlink.alerts import send_admin_alert
                send_admin_alert(
                    headline='Backup deleted',
                    intro=('A database backup has been permanently removed from the server. '
                           'It cannot be recovered.'),
                    rows=[
                        ('File', filename),
                        ('Size', size),
                        ('Deleted by', request.user.get_full_name() or request.user.username),
                        ('When', timezone.localtime().strftime('%d %b %Y, %H:%M')),
                    ],
                    panel_path='backups/',
                    short=f'ALERT: backup {filename} deleted by '
                          f'{request.user.get_full_name() or request.user.username}',
                    kicker='Irreversible Action',
                    accent='#b91c1c',
                )
            except Exception:
                log.exception('Backup purge alert failed for %s', filename)
        return JsonResponse({'ok': True})
    return JsonResponse({'ok': False}, status=405)


@super_admin_required
def backup_restore(request, filename):
    import zipfile, json, shutil
    from django.http import Http404
    from django.conf import settings
    from pathlib import Path
    if '/' in filename or '..' in filename:
        raise Http404
    filepath = _backup_dir() / filename
    if not filepath.exists():
        raise Http404

    manifest = {}
    try:
        with zipfile.ZipFile(str(filepath), 'r') as zf:
            if 'backup_manifest.json' in zf.namelist():
                manifest = json.loads(zf.read('backup_manifest.json').decode())
    except Exception:
        pass

    if request.method == 'POST':
        try:
            db_path    = _backup_db_path()
            media_root = Path(settings.MEDIA_ROOT)

            # Optional safety snapshot before overwriting
            if request.POST.get('safety_backup'):
                _, sname = _backup_build_name()
                _backup_create_zip(_backup_dir() / f'SAFETY_{sname}')

            with zipfile.ZipFile(str(filepath), 'r') as zf:
                names = zf.namelist()

                # Restore the database.
                if 'database.sql' in names:
                    # PostgreSQL. Restoring means dropping and recreating the
                    # database the request itself is running against, which
                    # cannot be done from inside that request. Say so plainly
                    # rather than restoring media and calling it done.
                    raise RuntimeError(
                        'This archive holds a PostgreSQL dump, which cannot be restored from '
                        'the panel because the database is in use by this request. Follow '
                        'RESTORE.txt inside the ZIP over SSH. Media can be restored here by '
                        'using an older media-only archive.'
                    )
                if 'db.sqlite3' in names:
                    from django.db import connections
                    for conn in connections.all():
                        conn.close()
                    zf.extract('db.sqlite3', str(db_path.parent))

                # Restore media
                media_files = [n for n in names if n.startswith('media/') and not n.endswith('/')]
                if media_files:
                    if media_root.exists():
                        shutil.rmtree(str(media_root))
                    media_root.mkdir(parents=True, exist_ok=True)
                    for name in media_files:
                        zf.extract(name, str(media_root.parent))

            # Restart in the background so the response goes out first. The
            # unit name comes from settings: restarting a hardcoded service
            # would stop a different site sharing this server.
            from django.conf import settings as django_settings
            service = (getattr(django_settings, 'SERVICE_NAME', '') or '').strip()
            if service:
                import subprocess
                subprocess.Popen(['systemctl', 'restart', service])
                messages.success(request, 'Restore complete. The site is restarting, so log in again in a few seconds.')
            else:
                messages.warning(request, 'Restore complete. SERVICE_NAME is not set in .env, so restart the site yourself before using it.')
            return redirect('cms_login')

        except Exception as exc:
            messages.error(request, f'Restore failed: {exc}')
            return redirect('cms_backup')

    return render(request, 'cms/backup/confirm_restore.html', {
        'filename': filename,
        'display':  filepath.stem,
        'manifest': manifest,
    })


# ─── Applications — extras a tenant can buy from YOUR COMPANY ─────────────────

# Price shown against each module on the Applications page. Anything not listed
# falls back to a quote. YOUR COMPANY can still waive the fee per tenant.
MODULE_PRICING = {
    'analytics':   {'price': 'from £39 / month', 'blurb': 'Traffic, sources, devices and conversions for your site, inside the panel.', 'featured': True},
    'crm':         {'price': 'from £49 / month', 'blurb': 'Sales pipeline, deals, forecasting and client records.', 'featured': True},
    'media':       {'price': 'from £29 / month', 'blurb': 'Host and stream video and audio with encrypted delivery.'},
    'ecommerce':   {'price': 'from £59 / month', 'blurb': 'Products, orders, checkout and payment handling.'},
    'chat':        {'price': 'from £19 / month', 'blurb': 'AI chat widget trained on your own site content.'},
    'auction':     {'price': 'from £25 / month', 'blurb': 'List and sell domains with offers and enquiries.'},
    'marketplace': {'price': 'from £25 / month', 'blurb': 'Publish a directory of services on your public site.'},
    'events':      {'price': 'from £25 / month', 'blurb': 'Events, schedules and bookings.'},
    'gallery':     {'price': 'from £15 / month', 'blurb': 'Image galleries and albums.'},
    'menu':        {'price': 'from £15 / month', 'blurb': 'Food and drink menus with categories and pricing.'},
    'startup':     {'price': 'quoted per project', 'blurb': 'Startup solutions workflow and assessment intake.'},
    'portfolio':   {'price': 'from £12 / month', 'blurb': 'Showcase projects and case studies.'},
    'insights':    {'price': 'from £12 / month', 'blurb': 'Blog and insights with built in SEO tooling.'},
    'services':    {'price': 'from £12 / month', 'blurb': 'Service pages with individual landing pages.'},
    'faq':         {'price': 'from £9 / month',  'blurb': 'Support centre and frequently asked questions.'},
    'homepage':    {'price': 'included',         'blurb': 'Homepage editor.'},
}


APPLICATION_ADDONS = [
    {
        'key': 'extra_domain',
        'title': 'Extra Domain',
        'blurb': 'Point an additional domain at your site, with SSL included.',
        'icon': 'fa-solid fa-globe',
        'price': 'from £25 / year',
    },
    {
        'key': 'hosting_upgrade',
        'title': 'Hosting Upgrade',
        'blurb': 'More storage, more bandwidth and priority resources.',
        'icon': 'fa-solid fa-server',
        'price': 'from £15 / month',
    },
    {
        'key': 'business_email',
        'title': 'Business Email',
        'blurb': 'Professional mailboxes on your own domain.',
        'icon': 'fa-solid fa-envelope',
        'price': 'from £3 / mailbox / month',
    },
    {
        'key': 'ssl_certificate',
        'title': 'Premium SSL',
        'blurb': 'Extended validation certificate with warranty cover.',
        'icon': 'fa-solid fa-lock',
        'price': 'from £60 / year',
    },
    {
        'key': 'seo_package',
        'title': 'SEO & SEM Package',
        'blurb': 'Keyword research, on-page optimisation and monthly reporting.',
        'icon': 'fa-solid fa-chart-line',
        'price': 'from £299 / month',
    },
    {
        'key': 'custom_development',
        'title': 'Custom Development',
        # No name here. This list is a module level constant, evaluated once
        # at import, so a per tenant value baked into it would be both a
        # database query during app loading and the wrong name for every
        # tenant but the first.
        'blurb': 'Bespoke features built for your site by our team.',
        'icon': 'fa-solid fa-code',
        'price': 'quoted per project',
    },
]


def _application_plan_options(tenant):
    """Return the plans above the tenant's current plan."""
    order = [Tenant.PLAN_STARTER, Tenant.PLAN_STANDARD, Tenant.PLAN_SIGNATURE, Tenant.PLAN_STORE]
    labels = dict(Tenant.PLAN_CHOICES)
    current = getattr(tenant, 'plan', Tenant.PLAN_STARTER)
    if current not in order:
        return []
    idx = order.index(current)
    module_labels = dict(MODULE_CHOICES)
    out = []
    for p in order[idx + 1:]:
        keys = sorted(PLAN_MODULES.get(p, set()))
        out.append({
            'key': p,
            'label': labels.get(p, p),
            'module_count': len(keys),
            'module_names': ', '.join(module_labels.get(k, k) for k in keys),
        })
    return out


@cms_required
def tenant_plugins(request):
    """
    What a tenant can buy or ask for, in three sections.

    Server and panel items are requested and then invoiced. Catalogue items
    are bought online and made live within 24 hours. The split comes from each
    plugin's fulfilment setting rather than being hard coded here.
    """
    from apps.marketplace.models import Plugin, PluginRequest

    tenant = getattr(request, 'tenant', None)

    try:
        plugins = [p for p in Plugin.objects.filter(is_active=True).order_by('order', 'id')
                   if p.visible_to(tenant)]
    except Exception:
        plugins = []

    def of(section):
        return [p for p in plugins if p.section == section]

    catalogue = (of(Plugin.SECTION_PRODUCT)
                 + of(Plugin.SECTION_SERVICE)
                 + of(Plugin.SECTION_SUBSCRIPTION))

    try:
        my_requests = PluginRequest.objects.filter(tenant=tenant).order_by('-created_at')[:10] \
            if tenant is not None else []
    except Exception:
        my_requests = []

    return render(request, 'cms/plugins.html', {
        'server_items':  of(Plugin.SECTION_SERVER),
        'panel_items':   of(Plugin.SECTION_PANEL),
        'products':      of(Plugin.SECTION_PRODUCT),
        'services':      of(Plugin.SECTION_SERVICE),
        'subscriptions': of(Plugin.SECTION_SUBSCRIPTION),
        'catalogue':     catalogue,
        'my_requests':   my_requests,
    })


@cms_required
def tenant_plugin_request(request, pk):
    """A tenant asks for something we have to quote. It reaches us as a request."""
    from apps.marketplace.models import Plugin, PluginRequest

    if request.method != 'POST':
        return redirect('cms_tenant_plugins')

    tenant = getattr(request, 'tenant', None)
    plugin = get_object_or_404(Plugin, pk=pk, is_active=True)

    if not plugin.visible_to(tenant):
        messages.error(request, 'That item is not available on your plan.')
        return redirect('cms_tenant_plugins')

    existing = PluginRequest.objects.filter(
        tenant=tenant, plugin=plugin,
        status__in=[PluginRequest.STATUS_REQUESTED, PluginRequest.STATUS_ACCEPTED],
    ).first()
    if existing:
        messages.info(request, f'You have already asked about {plugin.title}. We are on it.')
        return redirect('cms_tenant_plugins')

    req = PluginRequest.objects.create(
        tenant=tenant,
        plugin=plugin,
        plugin_title=plugin.title,
        section=plugin.section,
        requested_by=request.user.get_full_name() or request.user.username,
        message=request.POST.get('message', '').strip(),
    )

    # Tell YOUR COMPANY. Reuses the alert recipients so it reaches whoever is on duty.
    try:
        from apps.ftlink.alerts import _recipients
        from django.core.mail import send_mail
        from apps.ecommerce.email_utils import _get_from_email
        site = getattr(tenant, 'primary_domain', '') or getattr(tenant, 'name', 'a tenant')
        for person in _recipients():
            if person.get('email'):
                send_mail(
                    f'Plugin request from {site}: {plugin.title}',
                    (
                        f'{req.requested_by} at {site} has requested {plugin.title}.\n\n'
                        f'Section: {plugin.get_section_display()}\n'
                        f'Message: {req.message or "none"}\n\n'
                        f'Accept and invoice it from the panel.'
                    ),
                    _get_from_email(), [person['email']], fail_silently=True,
                )
    except Exception:
        pass

    messages.success(request, f'Thank you. We have your request for {plugin.title} and will come back with a quote.')
    return redirect('cms_tenant_plugins')


@cms_required
def applications(request):
    """Store page — modules, plan upgrades and services a tenant can request."""
    tenant = getattr(request, 'tenant', None)
    module_labels = dict(MODULE_CHOICES)

    if tenant is not None and getattr(tenant, 'pk', 0):
        enabled = set(tenant.enabled_module_keys)
    else:
        enabled = set()

    available_modules = []
    for k, _ in MODULE_CHOICES:
        if k in enabled:
            continue
        meta = MODULE_PRICING.get(k, {})
        available_modules.append({
            'key': k,
            'label': module_labels.get(k, k),
            'price': meta.get('price', 'quoted on request'),
            'blurb': meta.get('blurb', 'Not currently enabled on your site.'),
            'featured': meta.get('featured', False),
        })
    # Featured modules first, so Analytics and CRM lead the section
    available_modules.sort(key=lambda m: (not m['featured'], m['label']))

    services = MarketplaceService.objects.filter(is_active=True).order_by('order')[:12]

    return render(request, 'cms/applications.html', {
        'current_plan': tenant.get_plan_display() if tenant and getattr(tenant, 'pk', 0) else '',
        'plan_options': _application_plan_options(tenant) if tenant else [],
        'available_modules': available_modules,
        'addons': APPLICATION_ADDONS,
        'services': services,
        'module_labels': module_labels,
    })


@cms_required
def application_request(request):
    """Create an enquiry ticket for YOUR COMPANY from the Applications page."""
    if request.method != 'POST':
        return redirect('cms_applications')

    item_type = request.POST.get('item_type', 'item')
    item_key = request.POST.get('item_key', '')
    item_label = request.POST.get('item_label', '') or item_key
    note = request.POST.get('note', '').strip()

    tenant = getattr(request, 'tenant', None)
    tenant_name = getattr(tenant, 'name', 'Unknown site')
    tenant_domain = getattr(tenant, 'primary_domain', '')

    type_labels = {
        'module': 'Module',
        'plan': 'Plan upgrade',
        'addon': 'Add-on',
        'service': 'Service',
    }
    type_label = type_labels.get(item_type, 'Item')

    body = (
        f'{type_label} request from the {tenant_name} panel.\n\n'
        f'Requested: {item_label}\n'
        f'Site: {tenant_domain}\n'
        f'Current plan: {getattr(tenant, "plan", "")}\n'
        f'Requested by: {request.user.get_full_name() or request.user.username} '
        f'({request.user.email})\n'
    )
    if note:
        body += f'\nNote from client:\n{note}\n'

    master = Tenant.objects.filter(is_master=True).first()

    ticket = Ticket.objects.create(
        tenant=master,
        source=Ticket.SOURCE_MARKETPLACE,
        status=Ticket.STATUS_NEW,
        priority=Ticket.PRIORITY_MEDIUM,
        user=request.user if request.user.is_authenticated else None,
        sender_name=request.user.get_full_name() or request.user.username,
        sender_email=request.user.email or '',
        sender_company=tenant_name,
        subject=f'{type_label} request: {item_label} ({tenant_name})',
        category=type_label,
        message=body,
    )

    try:
        from django.core.mail import send_mail
        from django.conf import settings as django_settings
        notify_to = getattr(django_settings, 'APPLICATIONS_NOTIFY_EMAIL', None) \
            or _outgoing()
        if notify_to:
            send_mail(
                subject=f'[{ticket.ticket_number}] {type_label} request: {item_label}',
                message=body,
                from_email=_outgoing(),
                recipient_list=[notify_to],
                fail_silently=True,
            )
    except Exception:
        pass

    messages.success(
        request,
        f'Request sent. {_company()} will be in touch about "{item_label}". '
        f'Your reference is {ticket.ticket_number}.'
    )
    return redirect('cms_applications')


# ─── Fleet — every tenant install and what it reports ────────────────────────

def _short_ago(when):
    """Compact age: 0m, 45m, 3h, 2d, 5w. Empty string if never seen."""
    if not when:
        return ''
    from django.utils import timezone
    seconds = (timezone.now() - when).total_seconds()
    if seconds < 0:
        seconds = 0
    minutes = int(seconds // 60)
    if minutes < 60:
        return f'{minutes}m'
    hours = minutes // 60
    if hours < 24:
        return f'{hours}h'
    days = hours // 24
    if days < 7:
        return f'{days}d'
    return f'{days // 7}w'


@super_admin_required
def fleet(request):
    """Overview of every tenant install: FT OS version, OS, database, last seen."""
    _fleet_master_only(request)
    from django.conf import settings as django_settings

    tenants = Tenant.objects.all().order_by('name')
    latest = getattr(django_settings, 'CMS_VERSION', '')

    # The master never checks in over HTTP, so read its own details directly.
    try:
        from apps.ftlink.collect import apply_to_tenant
        master = tenants.filter(is_master=True).first()
        if master is not None:
            apply_to_tenant(master)
    except Exception:
        pass

    rows = []
    online = behind = never = 0
    for t in tenants:
        # Entitled version comes from the grant on the Tenants page, falling
        # back to the latest release when a tenant has never been stamped.
        entitled = t.cms_version or latest
        is_behind = bool(t.reported_version) and _is_newer(entitled, t.reported_version)
        never_seen = t.last_checkin_at is None
        if t.is_master:
            pass
        elif never_seen:
            never += 1
        elif t.is_online:
            online += 1
        if is_behind and not t.is_master:
            behind += 1
        rows.append({
            'obj': t,
            'behind': is_behind,
            'never_seen': never_seen,
            'is_reference': t.is_master,
            'seen_short': _short_ago(t.last_checkin_at),
            'entitled': entitled,
        })

    # Master first: it is the reference every other install is compared against
    rows.sort(key=lambda r: (not r['is_reference'], r['obj'].name.lower()))

    # Counts above cover the whole fleet; the table itself is paged like Tenants.
    total = len(rows) - 1 if any(r['is_reference'] for r in rows) else len(rows)

    from django.core.paginator import Paginator
    reference = [r for r in rows if r['is_reference']]
    rest      = [r for r in rows if not r['is_reference']]
    paginator = Paginator(rest, 20)
    page      = paginator.get_page(request.GET.get('page', 1))

    # Keep the reference row pinned to the top of every page
    page_rows = reference + list(page.object_list)

    return render(request, 'cms/tenants/fleet.html', {
        'rows': page_rows,
        'page_obj': page,
        'latest_version': latest,
        'total_count': total,
        'online_count': online,
        'behind_count': behind,
        'never_count': never,
        'reporting_count': len(rows) - never,
        'plan_choices': Tenant.PLAN_CHOICES,
        'checkin_url': request.build_absolute_uri('/api/ftlink/checkin/'),
    })


@super_admin_required
def fleet_issue_key(request, pk):
    """Generate or rotate the shared secret a tenant uses to check in."""
    obj = get_object_or_404(Tenant, pk=pk)
    if request.method == 'POST':
        import secrets
        obj.api_key = secrets.token_hex(32)
        obj.save(update_fields=['api_key'])
        messages.success(request, f'New key issued for {obj.name}.')
    # Straight to the connection details. A key on its own is not enough to
    # wire a site up, and handing over only the key is why installs end up
    # with no PANEL_HOST and no check-in schedule.
    return redirect('cms_fleet_connection', pk=obj.pk)


@super_admin_required
def fleet_connection(request, pk):
    """
    Everything that tenant's server needs, in one block ready to paste.

    Deliberately one screen: the key, the .env lines that go with it, and the
    cron entry that makes the link actually run. A key without the schedule is
    a connection that never happens.
    """
    obj = get_object_or_404(Tenant, pk=pk)

    from django.conf import settings as django_settings
    master = Tenant.objects.filter(is_master=True).first()
    panel_host = (getattr(master, 'cms_subdomain', '') or '').strip() or (getattr(django_settings, 'PANEL_HOST', '') or '')
    panel_host = panel_host.replace('https://', '').replace('http://', '').strip('/')

    same_server = request.GET.get('remote') != '1'
    if same_server:
        # Keep it on the machine rather than out through Cloudflare and back.
        control_url = 'http://127.0.0.1:8001/api/ftlink/checkin/'
        control_host = panel_host
    else:
        control_url = f'https://{panel_host}/api/ftlink/checkin/'
        control_host = ''

    app_dir = f'/home/{obj.slug}/public_html/backend'
    env_lines = [
        f'PANEL_HOST={(obj.cms_subdomain or "").strip()}',
        f'FT_CONTROL_URL={control_url}',
    ]
    if control_host:
        env_lines.append(f'FT_CONTROL_HOST={control_host}')
    env_lines += [
        f'FT_TENANT_SLUG={obj.slug}',
        f'FT_TENANT_KEY={obj.api_key or "(no key issued yet)"}',
    ]

    cron_line = (
        f'*/15 * * * * cd {app_dir} && venv/bin/python manage.py ft_checkin '
        f'--settings=config.settings.production >> /var/log/ft-checkin-{obj.slug}.log 2>&1'
    )

    return render(request, 'cms/tenants/connection.html', {
        'obj': obj,
        'env_block': '\n'.join(env_lines),
        'cron_line': cron_line,
        'app_dir': app_dir,
        'same_server': same_server,
        'panel_host': panel_host,
    })


def _company():
    """This install's name, from Settings. Never a literal."""
    from apps.pages.branding import company_name
    return company_name()


def _outgoing():
    """The sending address, from Settings. Never .env."""
    from apps.pages.email_backend import outgoing_address
    return outgoing_address()


def _support_address():
    """The support mailbox address, from Settings. Never .env."""
    from apps.pages.email_backend import support_credentials
    return support_credentials()[0]


# ─── Brands ───────────────────────────────────────────────────────────────────
#
# Sub-brands of a group, each served at /<slug>/ on the parent domain. A Brand
# is a section inside this one install, not a Tenant: see apps/brands/models.py
# for why that distinction matters.

@cms_required
@module_required('brands')
def brands_list(request):
    q = request.GET.get('q', '').strip()
    items = Brand.objects.all()
    if q:
        items = items.filter(
            Q(name__icontains=q) | Q(slug__icontains=q) |
            Q(tagline__icontains=q) | Q(external_domain__icontains=q)
        )
    return render(request, 'cms/brands/list.html', {
        'items': items,
        'q': q,
        'total': Brand.objects.count(),
    })


@admin_required
def brand_edit(request, pk=None):
    obj = get_object_or_404(Brand, pk=pk) if pk else Brand()
    if request.method == 'POST':
        obj.name             = request.POST.get('name', '').strip()
        obj.slug             = request.POST.get('slug', '').strip()
        obj.tagline          = request.POST.get('tagline', '').strip()
        obj.intro            = request.POST.get('intro', '').strip()
        obj.body             = request.POST.get('body', '').strip()
        obj.accent_colour    = request.POST.get('accent_colour', '').strip()
        obj.external_domain  = request.POST.get('external_domain', '').strip()
        obj.meta_description = request.POST.get('meta_description', '').strip()
        obj.meta_keywords    = request.POST.get('meta_keywords', '').strip()
        obj.order            = int(request.POST.get('order') or 99)
        obj.is_active        = 'is_active' in request.POST
        obj.show_on_home     = 'show_on_home' in request.POST

        errors = []
        if not obj.name:
            errors.append('Name is required.')

        # The slug becomes a top-level path, so a clash silently replaces a
        # real page. Validate before saving rather than discovering it when
        # the contact form disappears.
        from django.utils.text import slugify
        if not obj.slug:
            obj.slug = slugify(obj.name)
        obj.slug = obj.slug.strip('/').lower()
        if obj.slug in BRAND_RESERVED_SLUGS:
            errors.append(f'"{obj.slug}" is a path this site already uses. Pick another.')
        clash = Brand.objects.filter(slug=obj.slug).exclude(pk=obj.pk).exists()
        if clash:
            errors.append(f'Another brand already uses "{obj.slug}".')

        if errors:
            for e in errors:
                messages.error(request, e)
            return render(request, 'cms/brands/form.html', {'obj': obj})

        if 'clear_logo' in request.POST:
            obj.logo = None
        elif request.FILES.get('logo'):
            obj.logo = request.FILES['logo']
        if 'clear_hero' in request.POST:
            obj.hero = None
        elif request.FILES.get('hero'):
            obj.hero = request.FILES['hero']

        if obj.tenant_id is None:
            obj.tenant = _resolve_tenant(request)
        obj.save()
        messages.success(request, f'{obj.name} saved. Live at /{obj.slug}/')
        return redirect('cms_brands')

    return render(request, 'cms/brands/form.html', {'obj': obj})


@admin_required
@require_POST
def brand_delete(request, pk):
    obj = get_object_or_404(Brand, pk=pk)
    name = obj.name
    obj.delete()
    messages.success(request, f'{name} deleted.')
    return redirect('cms_brands')


@admin_required
@require_POST
def brand_toggle_visibility(request, pk):
    obj = get_object_or_404(Brand, pk=pk)
    obj.is_active = not obj.is_active
    obj.save(update_fields=['is_active'])
    messages.success(request, f'{obj.name} is now {"visible" if obj.is_active else "hidden"}.')
    return redirect('cms_brands')


# ─── Homepage slides ──────────────────────────────────────────────────────────
#
# The panels of the hero carousel. A theme with a static hero ignores these
# entirely, and a site with no rows keeps whatever its hero already renders,
# so nothing here can take a working homepage away.

@cms_required
def slides_list(request):
    from apps.pages.models import HomeSlide
    items = HomeSlide.objects.all()
    return render(request, 'cms/slides/list.html', {
        'items': items,
        'total': items.count(),
    })


@admin_required
def slide_edit(request, pk=None):
    from apps.pages.models import HomeSlide
    obj = get_object_or_404(HomeSlide, pk=pk) if pk else HomeSlide()
    if request.method == 'POST':
        obj.label       = request.POST.get('label', '').strip()
        obj.headline    = request.POST.get('headline', '').strip()
        obj.headline_em = request.POST.get('headline_em', '').strip()
        obj.body        = request.POST.get('body', '').strip()
        obj.btn1_text   = request.POST.get('btn1_text', '').strip()
        obj.btn1_url    = request.POST.get('btn1_url', '').strip()
        obj.btn2_text   = request.POST.get('btn2_text', '').strip()
        obj.btn2_url    = request.POST.get('btn2_url', '').strip()
        obj.order       = int(request.POST.get('order') or 1)
        obj.is_active   = 'is_active' in request.POST

        # A slide with no headline and no image is a blank panel the visitor
        # has to sit through, so refuse it rather than render nothing.
        if not obj.headline and not obj.label and not request.FILES.get('image') and not obj.image:
            messages.error(request, 'Give the slide a headline or an image.')
            return render(request, 'cms/slides/form.html', {'obj': obj})

        if 'clear_image' in request.POST:
            obj.image = None
        elif request.FILES.get('image'):
            obj.image = request.FILES['image']

        if obj.tenant_id is None:
            obj.tenant = _resolve_tenant(request)
        obj.save()
        messages.success(request, 'Slide saved.')
        return redirect('cms_slides')

    return render(request, 'cms/slides/form.html', {'obj': obj})


@admin_required
@require_POST
def slide_delete(request, pk):
    from apps.pages.models import HomeSlide
    obj = get_object_or_404(HomeSlide, pk=pk)
    obj.delete()
    messages.success(request, 'Slide deleted.')
    return redirect('cms_slides')


@admin_required
@require_POST
def slide_toggle_visibility(request, pk):
    from apps.pages.models import HomeSlide
    obj = get_object_or_404(HomeSlide, pk=pk)
    obj.is_active = not obj.is_active
    obj.save(update_fields=['is_active'])
    messages.success(request, f'Slide is now {"visible" if obj.is_active else "hidden"}.')
    return redirect('cms_slides')


# ─── Homepage counters ────────────────────────────────────────────────────────

@cms_required
def counters_list(request):
    from apps.pages.models import HomeCounter
    items = HomeCounter.objects.all()
    return render(request, 'cms/counters/list.html', {
        'items': items,
        'total': items.count(),
    })


@admin_required
def counter_edit(request, pk=None):
    from apps.pages.models import HomeCounter
    obj = get_object_or_404(HomeCounter, pk=pk) if pk else HomeCounter()
    if request.method == 'POST':
        obj.label     = request.POST.get('label', '').strip()
        obj.suffix    = request.POST.get('suffix', '').strip()
        obj.is_active = 'is_active' in request.POST

        # Order is set by dragging on the list, so a new figure joins the end
        # rather than landing on 1 and pushing everything else down.
        if not obj.pk:
            last = HomeCounter.objects.order_by('-order').first()
            obj.order = (last.order + 1) if last else 1

        # The band counts up to a number, so anything that is not one would
        # animate to zero and sit there looking broken.
        raw = (request.POST.get('number') or '').strip()
        try:
            obj.number = int(raw)
        except ValueError:
            messages.error(request, 'The number has to be a whole number. Put the + or % in Suffix.')
            return render(request, 'cms/counters/form.html', {'obj': obj})

        if not obj.label:
            messages.error(request, 'Give the figure a label, such as Years in Business.')
            return render(request, 'cms/counters/form.html', {'obj': obj})

        if obj.tenant_id is None:
            obj.tenant = _resolve_tenant(request)
        obj.save()
        messages.success(request, 'Counter saved.')
        return redirect('cms_counters')

    return render(request, 'cms/counters/form.html', {'obj': obj})


@admin_required
@require_POST
def counter_delete(request, pk):
    from apps.pages.models import HomeCounter
    obj = get_object_or_404(HomeCounter, pk=pk)
    obj.delete()
    messages.success(request, 'Counter deleted.')
    return redirect('cms_counters')


@admin_required
@require_POST
def counter_toggle_visibility(request, pk):
    from apps.pages.models import HomeCounter
    obj = get_object_or_404(HomeCounter, pk=pk)
    obj.is_active = not obj.is_active
    obj.save(update_fields=['is_active'])
    messages.success(request, f'Counter is now {"visible" if obj.is_active else "hidden"}.')
    return redirect('cms_counters')
