import time

from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import render
from django.urls import reverse
from django.views import View

from core.clients import transport
from core.events.manticore import (
    count_behavioral_events,
    get_recent_event_rows,
    get_trace_event_rows,
    get_trace_ids,
)
from core.events.projection import shadow_compare_session
from core.memory.search_backend import backend_status, get_memory_search_backend
from core.models import (
    AdapterHealthEvent,
    AIRequest,
    AIResult,
    AIResultSignal,
    Chat,
    ChatSession,
    CommandSecurityPolicy,
    ConversationEvent,
    Group,
    MemoryItem,
    Message,
    MessageEvent,
    PatternArtifactExport,
    PatternMitigationAutoSettings,
    PatternMitigationCorrection,
    PatternMitigationGame,
    PatternMitigationMessage,
    PatternMitigationPlan,
    PatternMitigationRule,
    Person,
    Persona,
    PersonIdentifier,
    QueuedMessage,
    UserAccessibilitySettings,
    UserXmppOmemoState,
    UserXmppOmemoTrustedKey,
    UserXmppSecuritySettings,
    WorkspaceConversation,
    WorkspaceMetricSnapshot,
)
from core.security.capabilities import (
    CAPABILITY_SCOPES,
)
from core.security.capabilities import GLOBAL_SCOPE_KEY as COMMAND_GLOBAL_SCOPE_KEY
from core.security.capabilities import GROUP_LABELS as CAPABILITY_GROUP_LABELS
from core.security.capabilities import (
    scope_record,
)
from core.transports.capabilities import capability_snapshot
from core.views.manage.permissions import SuperUserRequiredMixin


class SystemSettings(SuperUserRequiredMixin, View):
    template_name = "pages/system-settings.html"

    def _counts(self, user):
        behavioral_event_rows = 0
        try:
            behavioral_event_rows = int(count_behavioral_events(user_id=int(user.id)) or 0)
        except Exception:
            behavioral_event_rows = 0
        return {
            "chat_sessions": ChatSession.objects.filter(user=user).count(),
            "messages": Message.objects.filter(user=user).count(),
            "queued_messages": QueuedMessage.objects.filter(user=user).count(),
            "message_events": MessageEvent.objects.filter(user=user).count(),
            "conversation_event_shadow_rows": ConversationEvent.objects.filter(
                user=user
            ).count(),
            "behavioral_event_rows": behavioral_event_rows,
            "adapter_health_events": AdapterHealthEvent.objects.filter(
                user=user
            ).count(),
            "workspace_conversations": WorkspaceConversation.objects.filter(
                user=user
            ).count(),
            "workspace_snapshots": WorkspaceMetricSnapshot.objects.filter(
                conversation__user=user
            ).count(),
            "ai_requests": AIRequest.objects.filter(user=user).count(),
            "ai_results": AIResult.objects.filter(user=user).count(),
            "ai_result_signals": AIResultSignal.objects.filter(user=user).count(),
            "memory_items": MemoryItem.objects.filter(user=user).count(),
            "mitigation_plans": PatternMitigationPlan.objects.filter(user=user).count(),
            "mitigation_rules": PatternMitigationRule.objects.filter(user=user).count(),
            "mitigation_games": PatternMitigationGame.objects.filter(user=user).count(),
            "mitigation_corrections": PatternMitigationCorrection.objects.filter(
                user=user
            ).count(),
            "mitigation_messages": PatternMitigationMessage.objects.filter(
                user=user
            ).count(),
            "mitigation_auto_settings": PatternMitigationAutoSettings.objects.filter(
                user=user
            ).count(),
            "mitigation_exports": PatternArtifactExport.objects.filter(
                user=user
            ).count(),
            "osint_people": Person.objects.filter(user=user).count(),
            "osint_identifiers": PersonIdentifier.objects.filter(user=user).count(),
            "osint_groups": Group.objects.filter(user=user).count(),
            "osint_personas": Persona.objects.filter(user=user).count(),
        }

    def _purge_non_osint(self, user):
        deleted = 0
        deleted += PatternArtifactExport.objects.filter(user=user).delete()[0]
        deleted += PatternMitigationMessage.objects.filter(user=user).delete()[0]
        deleted += PatternMitigationCorrection.objects.filter(user=user).delete()[0]
        deleted += PatternMitigationGame.objects.filter(user=user).delete()[0]
        deleted += PatternMitigationRule.objects.filter(user=user).delete()[0]
        deleted += PatternMitigationAutoSettings.objects.filter(user=user).delete()[0]
        deleted += PatternMitigationPlan.objects.filter(user=user).delete()[0]
        deleted += AIResultSignal.objects.filter(user=user).delete()[0]
        deleted += AIResult.objects.filter(user=user).delete()[0]
        deleted += AIRequest.objects.filter(user=user).delete()[0]
        deleted += MemoryItem.objects.filter(user=user).delete()[0]
        deleted += WorkspaceMetricSnapshot.objects.filter(
            conversation__user=user
        ).delete()[0]
        deleted += MessageEvent.objects.filter(user=user).delete()[0]
        deleted += ConversationEvent.objects.filter(user=user).delete()[0]
        deleted += AdapterHealthEvent.objects.filter(user=user).delete()[0]
        deleted += Message.objects.filter(user=user).delete()[0]
        deleted += QueuedMessage.objects.filter(user=user).delete()[0]
        deleted += WorkspaceConversation.objects.filter(user=user).delete()[0]
        deleted += ChatSession.objects.filter(user=user).delete()[0]
        # Chat rows are legacy Signal cache rows and are not user-scoped.
        deleted += Chat.objects.all().delete()[0]
        return deleted

    def _purge_osint_people(self, user):
        return Person.objects.filter(user=user).delete()[0]

    def _purge_osint_identifiers(self, user):
        return PersonIdentifier.objects.filter(user=user).delete()[0]

    def _purge_osint_groups(self, user):
        return Group.objects.filter(user=user).delete()[0]

    def _purge_osint_personas(self, user):
        return Persona.objects.filter(user=user).delete()[0]

    def _handle_action(self, request):
        action = str(request.POST.get("action") or "").strip().lower()
        if action == "purge_non_osint":
            return (
                "success",
                f"Purged {self._purge_non_osint(request.user)} non-OSINT row(s).",
            )
        if action == "purge_osint_people":
            return (
                "warning",
                f"Purged {self._purge_osint_people(request.user)} OSINT people row(s).",
            )
        if action == "purge_osint_identifiers":
            return (
                "warning",
                f"Purged {self._purge_osint_identifiers(request.user)} OSINT identifier row(s).",
            )
        if action == "purge_osint_groups":
            return (
                "warning",
                f"Purged {self._purge_osint_groups(request.user)} OSINT group row(s).",
            )
        if action == "purge_osint_personas":
            return (
                "warning",
                f"Purged {self._purge_osint_personas(request.user)} OSINT persona row(s).",
            )
        return ("danger", "Unknown action.")

    def _diagnostics_options(self, user):
        session_rows = list(
            ChatSession.objects.filter(user=user)
            .select_related("identifier", "identifier__person")
            .order_by("-last_interaction", "-id")[:120]
        )
        session_options = []
        for row in session_rows:
            identifier = getattr(row, "identifier", None)
            person = getattr(identifier, "person", None) if identifier else None
            session_options.append(
                {
                    "id": str(row.id),
                    "label": " | ".join(
                        [
                            str(getattr(person, "name", "") or "-"),
                            str(row.id),
                            str(getattr(identifier, "service", "") or "-"),
                            str(getattr(identifier, "identifier", "") or "-"),
                        ]
                    ),
                }
            )

        trace_options = []
        seen_trace_ids = set()
        for trace_id in (
            ConversationEvent.objects.filter(user=user)
            .exclude(trace_id="")
            .order_by("-ts")
            .values_list("trace_id", flat=True)[:400]
        ):
            value = str(trace_id or "").strip()
            if not value or value in seen_trace_ids:
                continue
            seen_trace_ids.add(value)
            trace_options.append(value)
            if len(trace_options) >= 120:
                break
        if len(trace_options) < 120:
            try:
                for trace_id in get_trace_ids(
                    user_id=int(user.id),
                    limit=max(1, 120 - len(trace_options)),
                ):
                    value = str(trace_id or "").strip()
                    if not value or value in seen_trace_ids:
                        continue
                    seen_trace_ids.add(value)
                    trace_options.append(value)
                    if len(trace_options) >= 120:
                        break
            except Exception:
                pass

        service_candidates = {"signal", "whatsapp", "xmpp", "instagram", "web"}
        service_candidates.update(
            str(item or "").strip().lower()
            for item in ConversationEvent.objects.filter(user=user)
            .exclude(origin_transport="")
            .values_list("origin_transport", flat=True)
            .distinct()[:50]
        )
        try:
            service_candidates.update(
                str(item.get("origin_transport") or "").strip().lower()
                for item in get_recent_event_rows(
                    minutes=60 * 24 * 30,
                    user_id=str(user.id),
                    limit=500,
                )
                if str(item.get("origin_transport") or "").strip()
            )
        except Exception:
            pass
        service_options = sorted(value for value in service_candidates if value)

        event_type_candidates = {
            "message_created",
            "reaction_added",
            "reaction_removed",
            "read_receipt",
            "message_updated",
            "message_deleted",
        }
        event_type_candidates.update(
            str(item or "").strip().lower()
            for item in ConversationEvent.objects.filter(user=user)
            .exclude(event_type="")
            .values_list("event_type", flat=True)
            .distinct()[:80]
        )
        try:
            event_type_candidates.update(
                str(item.get("event_type") or "").strip().lower()
                for item in get_recent_event_rows(
                    minutes=60 * 24 * 30,
                    user_id=str(user.id),
                    limit=500,
                )
                if str(item.get("event_type") or "").strip()
            )
        except Exception:
            pass
        event_type_options = sorted(value for value in event_type_candidates if value)

        return {
            "sessions": session_options,
            "trace_ids": trace_options,
            "services": service_options,
            "event_types": event_type_options,
        }

    def _render_page(self, request, notice_level="", notice_message=""):
        return render(
            request,
            self.template_name,
            {
                "counts": self._counts(request.user),
                "notice_level": notice_level,
                "notice_message": notice_message,
                "diagnostics_options": self._diagnostics_options(request.user),
            },
        )

    def get(self, request):
        return self._render_page(request)

    def post(self, request):
        notice_level, notice_message = self._handle_action(request)
        return self._render_page(
            request,
            notice_level=notice_level,
            notice_message=notice_message,
        )


class ServiceCapabilitySnapshotAPI(SuperUserRequiredMixin, View):
    def get(self, request):
        service = str(request.GET.get("service") or "").strip().lower()
        return JsonResponse(
            {
                "ok": True,
                "data": capability_snapshot(service),
            }
        )


class AdapterHealthSummaryAPI(SuperUserRequiredMixin, View):
    def get(self, request):
        latest_by_service = {}
        rows = AdapterHealthEvent.objects.order_by("service", "-ts")[:200]
        for row in rows:
            key = str(row.service or "").strip().lower()
            if key in latest_by_service:
                continue
            latest_by_service[key] = {
                "status": str(row.status or ""),
                "reason": str(row.reason or ""),
                "ts": int(row.ts or 0),
                "created_at": row.created_at.isoformat(),
            }
        return JsonResponse({"ok": True, "services": latest_by_service})


class TraceDiagnosticsAPI(SuperUserRequiredMixin, View):
    def get(self, request):
        trace_id = str(request.GET.get("trace_id") or "").strip()
        if not trace_id:
            return JsonResponse(
                {"ok": False, "error": "trace_id_required"},
                status=400,
            )
        rows = list(
            ConversationEvent.objects.filter(
                user=request.user,
                trace_id=trace_id,
            )
            .select_related("session")
            .order_by("ts", "created_at")[:500]
        )
        data_source = "django"
        if not rows:
            try:
                rows = get_trace_event_rows(
                    user_id=int(request.user.id),
                    trace_id=trace_id,
                    limit=500,
                )
            except Exception:
                rows = []
            if rows:
                data_source = "manticore"
        related_session_ids = []
        seen_sessions = set()
        for row in rows:
            session_id = (
                str(row.session_id or "").strip()
                if not isinstance(row, dict)
                else str(row.get("session_id") or "").strip()
            )
            if not session_id or session_id in seen_sessions:
                continue
            seen_sessions.add(session_id)
            related_session_ids.append(session_id)

        return JsonResponse(
            {
                "ok": True,
                "trace_id": trace_id,
                "data_source": data_source,
                "count": len(rows),
                "related_session_ids": related_session_ids,
                "projection_shadow_urls": [
                    f"{reverse('system_projection_shadow')}?session_id={session_id}"
                    for session_id in related_session_ids
                ],
                "events": [
                    {
                        "id": (
                            str(row.id)
                            if not isinstance(row, dict)
                            else str(row.get("id") or "")
                        ),
                        "ts": (
                            int(row.ts or 0)
                            if not isinstance(row, dict)
                            else int(row.get("ts") or 0)
                        ),
                        "event_type": (
                            str(row.event_type or "")
                            if not isinstance(row, dict)
                            else str(row.get("event_type") or "")
                        ),
                        "direction": (
                            str(row.direction or "")
                            if not isinstance(row, dict)
                            else str(row.get("direction") or "")
                        ),
                        "session_id": (
                            str(row.session_id or "")
                            if not isinstance(row, dict)
                            else str(row.get("session_id") or "")
                        ),
                        "projection_shadow_url": (
                            f"{reverse('system_projection_shadow')}?session_id="
                            f"{(str(row.session_id or '').strip() if not isinstance(row, dict) else str(row.get('session_id') or '').strip())}"
                            if (
                                str(row.session_id or "").strip()
                                if not isinstance(row, dict)
                                else str(row.get("session_id") or "").strip()
                            )
                            else ""
                        ),
                        "origin_transport": (
                            str(row.origin_transport or "")
                            if not isinstance(row, dict)
                            else str(row.get("origin_transport") or "")
                        ),
                        "origin_message_id": (
                            str(row.origin_message_id or "")
                            if not isinstance(row, dict)
                            else str(row.get("origin_message_id") or "")
                        ),
                        "payload": (
                            dict(row.payload or {})
                            if not isinstance(row, dict)
                            else dict(row.get("payload") or {})
                        ),
                    }
                    for row in rows
                ],
            }
        )


class EventProjectionShadowAPI(SuperUserRequiredMixin, View):
    def get(self, request):
        session_id = str(request.GET.get("session_id") or "").strip()
        if not session_id:
            return JsonResponse(
                {"ok": False, "error": "session_id_required"},
                status=400,
            )
        detail_limit = int(request.GET.get("detail_limit") or 25)
        session = ChatSession.objects.filter(
            id=session_id,
            user=request.user,
        ).first()
        if session is None:
            return JsonResponse(
                {"ok": False, "error": "session_not_found"},
                status=404,
            )
        compared = shadow_compare_session(session, detail_limit=max(0, detail_limit))
        return JsonResponse(
            {
                "ok": True,
                "result": compared,
                "cause_summary": dict(compared.get("cause_counts") or {}),
                "cause_samples": dict(compared.get("cause_samples") or {}),
            }
        )


class EventLedgerSmokeAPI(SuperUserRequiredMixin, View):
    def _recent_rows(self, *, minutes: int, service: str, user_id: str, limit: int):
        cutoff_ts = int(time.time() * 1000) - (minutes * 60 * 1000)
        queryset = ConversationEvent.objects.filter(ts__gte=cutoff_ts).order_by("-ts")
        if service:
            queryset = queryset.filter(origin_transport=service)
        if user_id:
            queryset = queryset.filter(user_id=user_id)

        rows = list(
            queryset.values(
                "id",
                "user_id",
                "session_id",
                "ts",
                "event_type",
                "direction",
                "origin_transport",
                "trace_id",
            )[:limit]
        )
        if rows:
            return rows, "django"
        try:
            manticore_rows = get_recent_event_rows(
                minutes=minutes,
                service=service,
                user_id=user_id,
                limit=limit,
            )
        except Exception:
            manticore_rows = []
        return manticore_rows, "manticore" if manticore_rows else "django"

    def get(self, request):
        minutes = max(1, int(request.GET.get("minutes") or 120))
        service = str(request.GET.get("service") or "").strip().lower()
        user_id = str(request.GET.get("user_id") or "").strip() or str(request.user.id)
        limit = max(1, min(500, int(request.GET.get("limit") or 200)))
        require_types_raw = str(request.GET.get("require_types") or "").strip()
        required_types = [
            item.strip().lower()
            for item in require_types_raw.split(",")
            if item.strip()
        ]

        rows, data_source = self._recent_rows(
            minutes=minutes,
            service=service,
            user_id=user_id,
            limit=limit,
        )
        event_type_counts = {}
        for row in rows:
            key = str(row.get("event_type") or "")
            event_type_counts[key] = int(event_type_counts.get(key) or 0) + 1
        missing_required_types = [
            event_type
            for event_type in required_types
            if int(event_type_counts.get(event_type) or 0) <= 0
        ]
        return JsonResponse(
            {
                "ok": True,
                "minutes": minutes,
                "service": service,
                "user_id": user_id,
                "data_source": data_source,
                "count": len(rows),
                "event_type_counts": event_type_counts,
                "required_types": required_types,
                "missing_required_types": missing_required_types,
                "sample": rows[:25],
            }
        )


class MemorySearchStatusAPI(SuperUserRequiredMixin, View):
    def get(self, request):
        return JsonResponse({"ok": True, "status": backend_status()})


class MemorySearchQueryAPI(SuperUserRequiredMixin, View):
    def get(self, request):
        query = str(request.GET.get("q") or "").strip()
        user_id = int(request.GET.get("user_id") or request.user.id)
        conversation_id = str(request.GET.get("conversation_id") or "").strip()
        limit = max(1, min(50, int(request.GET.get("limit") or 20)))
        statuses = tuple(
            item.strip().lower()
            for item in str(request.GET.get("statuses") or "active").split(",")
            if item.strip()
        )
        if not query:
            return JsonResponse({"ok": False, "error": "query_required"}, status=400)

        backend = get_memory_search_backend()
        hits = backend.search(
            user_id=user_id,
            query=query,
            conversation_id=conversation_id,
            limit=limit,
            include_statuses=statuses,
        )
        return JsonResponse(
            {
                "ok": True,
                "backend": getattr(backend, "name", "unknown"),
                "query": query,
                "count": len(hits),
                "hits": [
                    {
                        "memory_id": item.memory_id,
                        "score": item.score,
                        "summary": item.summary,
                        "payload": item.payload,
                    }
                    for item in hits
                ],
            }
        )


def _parse_xmpp_jid(jid_str: str) -> dict:
    """Split a full JID (localpart@domain/resource) into components."""
    raw = str(jid_str or "").strip()
    bare, _, resource = raw.partition("/")
    localpart, _, domain = bare.partition("@")
    return {
        "full": raw,
        "bare": bare,
        "localpart": localpart,
        "domain": domain,
        "resource": resource,
    }


def _parse_omemo_client_key(client_key: str) -> dict:
    raw = str(client_key or "").strip()
    parts = {}
    for row in raw.split(","):
        key, _, value = str(row or "").partition(":")
        k = key.strip().lower()
        v = value.strip()
        if k and v:
            parts[k] = v
    sid = str(parts.get("sid") or "").strip()
    rid = str(parts.get("rid") or "").strip()
    return {
        "raw": raw,
        "sid": sid,
        "rid": rid,
        "has_ids": bool(sid or rid),
    }


def _latest_client_omemo_fingerprint(omemo_row) -> str:
    if omemo_row is None:
        return ""
    details = getattr(omemo_row, "details", {}) or {}
    if not isinstance(details, dict):
        return ""
    return str(details.get("latest_client_fingerprint") or "").strip()


def _to_bool(value, default=False):
    if value is None:
        return bool(default)
    text = str(value).strip().lower()
    if text in {"1", "true", "yes", "on", "y"}:
        return True
    if text in {"0", "false", "no", "off", "n"}:
        return False
    return bool(default)


class SecurityPage(LoginRequiredMixin, View):
    """Security settings page for OMEMO and command-scope policy controls."""

    template_name = "pages/security.html"
    page_mode = "encryption"
    GLOBAL_SCOPE_KEY = COMMAND_GLOBAL_SCOPE_KEY
    # Allowed Services list used by both Global Scope Override and local scopes.
    # Keep this in sync with the UI text on the Security page.
    POLICY_SERVICES = ["xmpp", "whatsapp", "signal", "instagram", "web"]
    # Override mode names as shown in the interface:
    # - per_scope: local scope controls remain editable
    # - on/off: global override forces each local scope value
    OVERRIDE_OPTIONS = ("per_scope", "on", "off")
    GLOBAL_OVERRIDE_FIELDS = (
        "scope_enabled",
        "require_omemo",
        "require_trusted_fingerprint",
    )
    POLICY_GROUP_LABELS = CAPABILITY_GROUP_LABELS

    def _show_encryption(self) -> bool:
        return str(getattr(self, "page_mode", "encryption")).strip().lower() in {
            "encryption",
            "all",
        }

    def _show_permission(self) -> bool:
        return str(getattr(self, "page_mode", "encryption")).strip().lower() in {
            "permission",
            "all",
        }

    def _security_settings(self, request):
        row, _ = UserXmppSecuritySettings.objects.get_or_create(user=request.user)
        return row

    def _omemo_discovered_keys(self, request, xmpp_state, omemo_row):
        discovered = []

        sender = _parse_xmpp_jid(getattr(omemo_row, "last_sender_jid", "") or "")
        client_jid = str(sender.get("bare") or "").strip()
        client_fingerprint = _latest_client_omemo_fingerprint(omemo_row)
        client_key = str(getattr(omemo_row, "latest_client_key", "") or "").strip()
        if client_jid and client_fingerprint:
            discovered.append(
                {
                    "jid": client_jid,
                    "key_type": "fingerprint",
                    "key_id": client_fingerprint,
                    "source": "client_observed",
                    "label": "Observed client fingerprint",
                }
            )
        elif client_jid and client_key:
            discovered.append(
                {
                    "jid": client_jid,
                    "key_type": "client_key",
                    "key_id": client_key,
                    "source": "client_observed",
                    "label": "Observed client key",
                }
            )

        # De-duplicate while preserving order.
        deduped = []
        seen = set()
        for row in discovered:
            key = (
                str(row.get("jid") or "").strip().lower(),
                str(row.get("key_type") or "").strip().lower(),
                str(row.get("key_id") or "").strip(),
            )
            if key in seen:
                continue
            seen.add(key)
            deduped.append(row)
        discovered = deduped

        trusted_map = {}
        rows = UserXmppOmemoTrustedKey.objects.filter(user=request.user)
        for item in rows:
            trusted_map[
                (
                    str(item.jid or "").strip().lower(),
                    str(item.key_type or "").strip().lower(),
                    str(item.key_id or "").strip(),
                )
            ] = item

        payload = []
        for row in discovered:
            map_key = (
                str(row.get("jid") or "").strip().lower(),
                str(row.get("key_type") or "").strip().lower(),
                str(row.get("key_id") or "").strip(),
            )
            existing = trusted_map.get(map_key)
            payload.append(
                {
                    **row,
                    "trusted": bool(getattr(existing, "trusted", False)),
                    "updated_at": getattr(existing, "updated_at", None),
                }
            )
        return payload

    def _parse_override_value(self, value):
        option = str(value or "").strip().lower()
        if option == "inherit":
            # Backward-compat for existing persisted values.
            option = "per_scope"
        if option in self.OVERRIDE_OPTIONS:
            return option
        return "per_scope"

    def _global_override_payload(self, request):
        row, _ = CommandSecurityPolicy.objects.get_or_create(
            user=request.user,
            scope_key=self.GLOBAL_SCOPE_KEY,
            defaults={
                "enabled": True,
                "allowed_services": [],
                "allowed_channels": {},
                "settings": {},
            },
        )
        settings_payload = dict(row.settings or {})
        values = {
            "scope_enabled": self._parse_override_value(
                settings_payload.get("scope_enabled")
            ),
            "require_omemo": self._parse_override_value(
                settings_payload.get("require_omemo")
            ),
            "require_trusted_fingerprint": self._parse_override_value(
                settings_payload.get("require_trusted_fingerprint")
            ),
        }
        allowed_services = [
            str(value or "").strip().lower()
            for value in (row.allowed_services or [])
            if str(value or "").strip()
        ]
        channel_rules = self._channel_rules_from_map(dict(row.allowed_channels or {}))
        if not channel_rules:
            channel_rules = [{"service": "xmpp", "pattern": ""}]
        return {
            "row": row,
            "values": values,
            "allowed_services": allowed_services,
            "channel_rules": channel_rules,
        }

    def _apply_global_override(self, current_value: bool, option: str) -> bool:
        normalized = self._parse_override_value(option)
        if normalized == "on":
            return True
        if normalized == "off":
            return False
        return bool(current_value)

    def _channel_rules_from_map(self, source_map):
        rows = []
        raw = dict(source_map or {})
        for service_key, patterns in raw.items():
            service_name = str(service_key or "").strip().lower()
            if not service_name:
                continue
            if isinstance(patterns, list):
                for pattern in patterns:
                    pattern_text = str(pattern or "").strip()
                    if pattern_text:
                        rows.append(
                            {
                                "service": service_name,
                                "pattern": pattern_text,
                            }
                        )
        return rows

    def _channels_map_from_post(self, request):
        channel_services = request.POST.getlist("allowed_channel_service")
        channel_patterns = request.POST.getlist("allowed_channel_pattern")
        allowed_channels: dict[str, list[str]] = {}
        for idx, raw_pattern in enumerate(channel_patterns):
            pattern = str(raw_pattern or "").strip()
            if not pattern:
                continue
            service_name = (
                str(channel_services[idx] if idx < len(channel_services) else "")
                .strip()
                .lower()
            )
            if not service_name:
                service_name = "*"
            allowed_channels.setdefault(service_name, [])
            if pattern not in allowed_channels[service_name]:
                allowed_channels[service_name].append(pattern)
        return allowed_channels

    def _scope_rows(self, request):
        global_overrides = self._global_override_payload(request)["values"]
        security_settings = self._security_settings(request)
        rows = {
            str(item.scope_key or "").strip().lower(): item
            for item in CommandSecurityPolicy.objects.filter(user=request.user).exclude(
                scope_key=self.GLOBAL_SCOPE_KEY
            )
        }
        payload = []
        for scope in CAPABILITY_SCOPES:
            if not bool(scope.configurable):
                continue
            key = str(scope.key or "").strip().lower()
            item = rows.get(key)
            raw_allowed_services = [
                str(value or "").strip().lower()
                for value in (getattr(item, "allowed_services", []) or [])
                if str(value or "").strip()
            ]
            channel_rules = self._channel_rules_from_map(
                dict(getattr(item, "allowed_channels", {}) or {})
            )
            if not channel_rules:
                channel_rules = [{"service": "xmpp", "pattern": ""}]
            enabled_locked = global_overrides["scope_enabled"] != "per_scope"
            require_omemo_locked = global_overrides[
                "require_omemo"
            ] != "per_scope" or bool(security_settings.require_omemo)
            require_trusted_locked = (
                global_overrides["require_trusted_fingerprint"] != "per_scope"
            )
            payload.append(
                {
                    "scope_key": key,
                    "label": scope.label,
                    "description": scope.description,
                    "enabled": self._apply_global_override(
                        bool(getattr(item, "enabled", True)),
                        global_overrides["scope_enabled"],
                    ),
                    "require_omemo": self._apply_global_override(
                        bool(getattr(item, "require_omemo", False)),
                        global_overrides["require_omemo"],
                    ),
                    "require_trusted_fingerprint": self._apply_global_override(
                        bool(getattr(item, "require_trusted_omemo_fingerprint", False)),
                        global_overrides["require_trusted_fingerprint"],
                    ),
                    "enabled_locked": enabled_locked,
                    "require_omemo_locked": require_omemo_locked,
                    "require_trusted_fingerprint_locked": require_trusted_locked,
                    "lock_help": "Set this field to 'Per Scope' in Global Scope Override to edit it here.",
                    "require_omemo_lock_help": (
                        "Disable 'Require OMEMO encryption' in Encryption settings to edit this field."
                        if bool(security_settings.require_omemo)
                        else "Set this field to 'Per Scope' in Global Scope Override to edit it here."
                    ),
                    "allowed_services": raw_allowed_services,
                    "channel_rules": channel_rules,
                }
            )
        return payload

    def _scope_group_key(self, scope_key: str) -> str:
        row = scope_record(scope_key)
        return row.group if row is not None else "other"

    def _grouped_scope_rows(self, request):
        rows = self._scope_rows(request)
        grouped: dict[str, list[dict]] = {
            key: [] for key in self.POLICY_GROUP_LABELS.keys()
        }
        for row in rows:
            group_key = self._scope_group_key(row.get("scope_key"))
            grouped.setdefault(group_key, [])
            grouped[group_key].append(row)
        payload = []
        for group_key in ("gateway", "tasks", "command", "agentic", "other"):
            items = grouped.get(group_key) or []
            if not items:
                continue
            payload.append(
                {
                    "key": group_key,
                    "label": self.POLICY_GROUP_LABELS.get(group_key, group_key.title()),
                    "rows": items,
                }
            )
        return payload

    def post(self, request):
        row = self._security_settings(request)
        if str(request.POST.get("encryption_settings_submit") or "").strip() == "1":
            row.require_omemo = _to_bool(request.POST.get("require_omemo"), False)
            row.encrypt_component_messages_with_omemo = _to_bool(
                request.POST.get("encrypt_component_messages_with_omemo"),
                True,
            )
            row.encrypt_contact_messages_with_omemo = _to_bool(
                request.POST.get("encrypt_contact_messages_with_omemo"),
                False,
            )
            row.save(
                update_fields=[
                    "require_omemo",
                    "encrypt_component_messages_with_omemo",
                    "encrypt_contact_messages_with_omemo",
                    "updated_at",
                ]
            )
        if "omemo_trust_update" in request.POST:
            key_type = (
                str(request.POST.get("key_type") or "fingerprint").strip().lower()
            )
            if key_type not in {"fingerprint", "client_key"}:
                key_type = "fingerprint"
            jid = str(request.POST.get("jid") or "").strip()
            key_id = str(request.POST.get("key_id") or "").strip()
            source = str(request.POST.get("source") or "").strip().lower()
            trusted = _to_bool(request.POST.get("trusted"), False)
            if jid and key_id:
                trust_row, _ = UserXmppOmemoTrustedKey.objects.get_or_create(
                    user=request.user,
                    jid=jid,
                    key_type=key_type,
                    key_id=key_id,
                    defaults={"source": source},
                )
                trust_row.trusted = trusted
                if source:
                    trust_row.source = source
                trust_row.save(update_fields=["trusted", "source", "updated_at"])
        redirect_to = HttpResponseRedirect(request.path)
        scope_key = str(request.POST.get("scope_key") or "").strip().lower()
        if not self._show_permission():
            return redirect_to
        if scope_key == self.GLOBAL_SCOPE_KEY:
            global_row = self._global_override_payload(request)["row"]
            security_settings = self._security_settings(request)
            settings_payload = dict(global_row.settings or {})
            for field in self.GLOBAL_OVERRIDE_FIELDS:
                if field == "require_omemo" and bool(security_settings.require_omemo):
                    continue
                settings_payload[field] = self._parse_override_value(
                    request.POST.get(f"global_{field}")
                )
            global_row.allowed_services = [
                str(item or "").strip().lower()
                for item in request.POST.getlist("allowed_services")
                if str(item or "").strip()
            ]
            global_row.allowed_channels = self._channels_map_from_post(request)
            global_row.settings = settings_payload
            global_row.save(
                update_fields=[
                    "settings",
                    "allowed_services",
                    "allowed_channels",
                    "updated_at",
                ]
            )
            return redirect_to

        if scope_key:
            if str(request.POST.get("scope_change_mode") or "").strip() != "1":
                return redirect_to
            global_overrides = self._global_override_payload(request)["values"]
            security_settings = self._security_settings(request)
            allowed_services = [
                str(item or "").strip().lower()
                for item in request.POST.getlist("allowed_services")
                if str(item or "").strip()
            ]
            allowed_channels = self._channels_map_from_post(request)
            policy, _ = CommandSecurityPolicy.objects.get_or_create(
                user=request.user,
                scope_key=scope_key,
            )
            policy.allowed_services = allowed_services
            policy.allowed_channels = allowed_channels
            if global_overrides["scope_enabled"] == "per_scope":
                policy.enabled = _to_bool(request.POST.get("policy_enabled"), True)
            if global_overrides["require_omemo"] == "per_scope" and not bool(
                security_settings.require_omemo
            ):
                policy.require_omemo = _to_bool(
                    request.POST.get("policy_require_omemo"), False
                )
            if global_overrides["require_trusted_fingerprint"] == "per_scope":
                policy.require_trusted_omemo_fingerprint = _to_bool(
                    request.POST.get("policy_require_trusted_fingerprint"),
                    False,
                )
            policy.save(
                update_fields=[
                    "enabled",
                    "require_omemo",
                    "require_trusted_omemo_fingerprint",
                    "allowed_services",
                    "allowed_channels",
                    "updated_at",
                ]
            )
        return redirect_to

    def get(self, request):
        show_encryption = self._show_encryption()
        show_permission = self._show_permission()
        xmpp_state = transport.get_runtime_state("xmpp") if show_encryption else {}
        omemo_row = None
        if show_encryption:
            try:
                omemo_row = UserXmppOmemoState.objects.get(user=request.user)
            except UserXmppOmemoState.DoesNotExist:
                omemo_row = None
        discovered_omemo_keys = self._omemo_discovered_keys(
            request, xmpp_state, omemo_row
        )
        security_settings = self._security_settings(request)
        sender_jid = _parse_xmpp_jid(getattr(omemo_row, "last_sender_jid", "") or "")
        omemo_client_fingerprint = _latest_client_omemo_fingerprint(omemo_row)
        omemo_client_key_info = _parse_omemo_client_key(
            getattr(omemo_row, "latest_client_key", "") if omemo_row else ""
        )
        omemo_plan = []
        if show_encryption:
            omemo_plan = [
                {
                    "label": "Component OMEMO active",
                    "done": bool(xmpp_state.get("omemo_enabled")),
                    "hint": "The gateway's OMEMO plugin must be loaded and initialised.",
                },
                {
                    "label": "OMEMO observed from your client",
                    "done": omemo_row is not None and omemo_row.status == "detected",
                    "hint": "Send any message with OMEMO enabled in your XMPP client.",
                },
                {
                    "label": "Client key on file",
                    "done": bool(getattr(omemo_row, "latest_client_key", "")),
                    "hint": "A device key (sid/rid) must be recorded from your client.",
                },
                {
                    "label": "Encryption required",
                    "done": security_settings.require_omemo,
                    "hint": "Enable 'Require OMEMO encryption' in Security Policy above to enforce this policy.",
                },
            ]
        return render(
            request,
            self.template_name,
            {
                "xmpp_state": xmpp_state,
                "omemo_row": omemo_row,
                "discovered_omemo_keys": discovered_omemo_keys,
                "security_settings": security_settings,
                "global_override": self._global_override_payload(request),
                "policy_services": self.POLICY_SERVICES,
                "policy_rows": self._scope_rows(request),
                "policy_groups": self._grouped_scope_rows(request),
                "sender_jid": sender_jid,
                "omemo_client_fingerprint": omemo_client_fingerprint,
                "omemo_client_key_info": omemo_client_key_info,
                "omemo_plan": omemo_plan,
                "show_encryption": show_encryption,
                "show_permission": show_permission,
            },
        )


class AccessibilitySettings(LoginRequiredMixin, View):
    template_name = "pages/accessibility-settings.html"

    def _row(self, request):
        row, _ = UserAccessibilitySettings.objects.get_or_create(user=request.user)
        return row

    def get(self, request):
        return render(
            request,
            self.template_name,
            {
                "accessibility_settings": self._row(request),
            },
        )

    def post(self, request):
        row = self._row(request)
        row.disable_animations = _to_bool(request.POST.get("disable_animations"), False)
        row.save(update_fields=["disable_animations", "updated_at"])
        return HttpResponseRedirect(reverse("accessibility_settings"))


class _SettingsCategoryPage(LoginRequiredMixin, View):
    template_name = "pages/settings-category.html"
    category_key = "general"
    category_title = "General"
    category_description = ""
    tabs = ()

    def _tab_rows(self):
        current_path = str(getattr(self.request, "path", "") or "")
        rows = []
        for label, href in self.tabs:
            rows.append(
                {
                    "label": label,
                    "href": href,
                    "active": current_path == href,
                }
            )
        return rows

    def get(self, request):
        return render(
            request,
            self.template_name,
            {
                "category_key": self.category_key,
                "category_title": self.category_title,
                "category_description": self.category_description,
                "category_tabs": self._tab_rows(),
            },
        )


class AISettingsPage(LoginRequiredMixin, View):
    def get(self, request):
        return HttpResponseRedirect(reverse("ai_models"))


class ModulesSettingsPage(_SettingsCategoryPage):
    def get(self, request):
        return HttpResponseRedirect(reverse("command_routing"))
