"""HTMX endpoints and POST handlers for importing LibreNMS devices."""

import json
import logging
from urllib.parse import parse_qs, urlparse

from django.contrib import messages
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction

from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect, render
from django.utils.html import escape
from django.views import View

from netbox_librenms_plugin.import_utils import (
    _determine_device_name,
    bulk_import_devices,
    bulk_import_vms,
    fetch_device_with_cache,
    get_import_device_cache_key,
    get_librenms_device_by_id,
    get_virtual_chassis_data,
    update_vc_member_suggested_names,
    validate_device_for_import,
)
from netbox_librenms_plugin.import_validation_helpers import (
    apply_cluster_to_validation,
    apply_rack_to_validation,
    apply_role_to_validation,
    extract_device_selections,
    fetch_model_by_id,
)
from netbox_librenms_plugin.tables.device_status import DeviceImportTable
from netbox_librenms_plugin.utils import resolve_naming_preferences, save_user_pref, set_librenms_device_id
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin, NetBoxObjectPermissionMixin

logger = logging.getLogger(__name__)

# Actions that require the force checkbox when a device-type mismatch is detected.
_FORCE_REQUIRED_ACTIONS = frozenset({"link", "update", "update_serial", "update_type"})

# Actions that operate on Device-only fields and cannot be applied to VMs.
_DEVICE_ONLY_ACTIONS = frozenset({"link", "update", "update_serial", "update_type", "sync_serial", "sync_device_type"})


_TRUTHY_VALUES = {"1", "true", "on", "yes"}
_FALSY_VALUES = {"0", "false", "off", "no", ""}


def _parse_boolish(value) -> bool | None:
    """Parse common form/query boolean values. Return None when value is unset/unknown."""
    if value is None:
        return None
    if isinstance(value, bool):
        return value

    normalized = str(value).strip().lower()
    if normalized in _TRUTHY_VALUES:
        return True
    if normalized in _FALSY_VALUES:
        return False
    return None


def _resolve_vc_detection_enabled(request) -> bool:
    """
    Resolve VC detection preference from request payloads.

    Resolution order:
    1. Explicit POST enable_vc_detection
    2. Explicit GET enable_vc_detection
    3. return_url query param fallback (POST, then GET)
    4. Default False
    """
    for source in (request.POST, request.GET):
        parsed = _parse_boolish(source.get("enable_vc_detection"))
        if parsed is not None:
            return parsed

    for source in (request.POST, request.GET):
        return_url = source.get("return_url")
        if not return_url:
            continue
        query = parse_qs(urlparse(return_url).query)

        parsed = _parse_boolish((query.get("enable_vc_detection") or [None])[-1])
        if parsed is not None:
            return parsed

        # Backward compatibility for legacy URLs that used skip_vc_detection.
        skip_vc = _parse_boolish((query.get("skip_vc_detection") or [None])[-1])
        if skip_vc is not None:
            return not skip_vc

    return False


def _save_device(device) -> HttpResponse | None:
    """Call full_clean() then save(). Return an HttpResponse on failure, None on success."""
    from django.db import IntegrityError

    try:
        device.full_clean()
    except ValidationError as exc:
        error_msg = exc.message_dict if hasattr(exc, "message_dict") else str(exc)
        return HttpResponse(f"Validation error: {escape(str(error_msg))}", status=400)
    try:
        device.save()
    except IntegrityError as exc:
        return HttpResponse(f"Integrity error: {escape(str(exc))}", status=409)
    return None


def _get_hostname_for_action(request, validation: dict, libre_device: dict) -> str:
    """
    Return the resolved hostname to use when updating a device during a conflict action.

    Prefer the cached ``resolved_name`` from validation (already computed with the
    user's naming prefs at validation time). Fall back to computing it fresh from
    the current request's naming preferences.
    """
    resolved = validation.get("resolved_name")
    if resolved:
        return resolved
    use_sysname, strip_domain = resolve_naming_preferences(request)
    return _determine_device_name(libre_device, use_sysname=use_sysname, strip_domain=strip_domain)


class DeviceImportHelperMixin:
    """Mixin providing common validation and rendering helpers for device import views."""

    def _should_enable_vc_detection(self, device_id: int, request) -> bool:
        """
        Determine if VC detection should be enabled for this request.

        VC detection is always enabled for role/rack changes and detail views,
        regardless of the initial user preference. This implements smart caching:

        1. If user originally requested VC detection: Uses cached data from initial load
        2. If VC data is already cached: Reuses cached data (no API call)
        3. Otherwise: Fetches VC data from LibreNMS API and caches it

        This approach ensures:
        - Role/rack changes always have VC context available (required for import)
        - No redundant API calls when VC data is already cached
        - Consistent VC detection behavior across dropdowns and detail modals
        - Since role assignment is required before import, VC data is always
          available by the time bulk import/confirm operations run

        Args:
            device_id: LibreNMS device ID
            request: Django request object

        Returns:
            bool: Always returns True to enable VC detection with smart caching
        """
        # Check if user originally requested VC detection
        vc_requested = _resolve_vc_detection_enabled(request)

        if vc_requested:
            # User explicitly enabled it - use it (will use cache if available)
            return True

        # Check if VC data is already cached (no API call will be made)
        from netbox_librenms_plugin.import_utils import _vc_cache_key

        cache_key = _vc_cache_key(self.librenms_api, device_id)
        vc_cached = cache.get(cache_key) is not None

        if vc_cached:
            # Data already in cache - enable detection (no API call)
            return True

        # Not requested and not cached - make API call to get VC data
        # This handles the case where user didn't initially request it
        # but is now changing role/rack (so we fetch it now)
        return True

    def get_validated_device_with_selections(self, device_id: int, request) -> tuple[dict | None, dict | None, dict]:
        """
        Get LibreNMS device, validate it, and apply user selections.

        Consolidates the common pattern across all device import update views.

        Args:
            device_id: LibreNMS device ID
            request: Django request object

        Returns:
            Tuple of (libre_device, validation, selections)
            Returns (None, None, selections) if device not found
        """
        selections = extract_device_selections(request, device_id)
        cluster_id = selections["cluster_id"]
        is_vm = bool(cluster_id)

        # Try to use cached device data from table load (eliminates redundant API calls)
        libre_device = fetch_device_with_cache(device_id, self.librenms_api)

        if not libre_device:
            return None, None, selections

        # Determine if we should enable VC detection for this request
        # This checks: user preference, cache status, and VM vs Device
        enable_vc = not is_vm and self._should_enable_vc_detection(device_id, request)

        # Extract naming preferences: POST data (hx-include) → user pref → plugin settings.
        use_sysname, strip_domain = resolve_naming_preferences(request)

        validation = validate_device_for_import(
            libre_device,
            import_as_vm=is_vm,
            api=self.librenms_api if enable_vc else None,
            include_vc_detection=enable_vc,
            use_sysname=use_sysname,
            strip_domain=strip_domain,
            server_key=self.librenms_api.server_key,
        )
        # Recompute is_vm from validate_device_for_import's own detection
        # (it may have found an existing VM via hostname/IP lookup)
        is_vm = bool(validation.get("import_as_vm"))

        # Apply user selections (cluster, role, rack) to validation
        _apply_user_selections_to_validation(validation, selections, is_vm)

        return libre_device, validation, selections

    def render_device_row(self, request, libre_device: dict, validation: dict, selections: dict):
        """
        Render device import table row with updated validation.

        Args:
            request: Django request object
            libre_device: LibreNMS device data
            validation: Updated validation dict
            selections: User selections dict with cluster_id, role_id, rack_id

        Returns:
            HttpResponse with rendered device row
        """
        libre_device["_validation"] = validation
        table = DeviceImportTable([libre_device])

        context = {
            "record": libre_device,
            "table": table,
            "cluster_id": selections["cluster_id"],
            "role_id": selections["role_id"],
            "rack_id": selections["rack_id"],
        }

        return render(
            request,
            "netbox_librenms_plugin/htmx/device_import_row.html",
            context,
        )


def _apply_user_selections_to_validation(
    validation: dict,
    selections: dict,
    is_vm: bool,
) -> None:
    """
    Apply user-selected cluster, role, and rack to validation dict.

    This helper consolidates the logic shared across DeviceValidationDetailsView,
    DeviceRoleUpdateView, DeviceClusterUpdateView, and DeviceRackUpdateView.

    Args:
        validation: Validation dict from validate_device_for_import()
        selections: Dict with keys: cluster_id, role_id, rack_id
        is_vm: True if importing as VM, False for device

    Modifies validation dict in-place by applying cluster/role/rack selections.
    """
    from dcim.models import DeviceRole, Rack
    from virtualization.models import Cluster

    cluster_id = selections.get("cluster_id")
    role_id = selections.get("role_id")
    rack_id = selections.get("rack_id")

    if is_vm:
        # Handle cluster selection (VM only)
        if cluster_id:
            cluster = fetch_model_by_id(Cluster, cluster_id)
            if cluster:
                apply_cluster_to_validation(validation, cluster)

        # Handle role selection for VM
        if role_id:
            role = fetch_model_by_id(DeviceRole, role_id)
            if role:
                apply_role_to_validation(validation, role, is_vm=True)
    else:
        # Handle role selection for device
        if role_id:
            role = fetch_model_by_id(DeviceRole, role_id)
            if role:
                apply_role_to_validation(validation, role, is_vm=False)

        # Handle rack selection (device only, optional)
        if rack_id:
            rack = fetch_model_by_id(Rack, rack_id)
            if rack:
                apply_rack_to_validation(validation, rack)


class BulkImportConfirmView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View):
    """HTMX view to confirm bulk imports before execution."""

    def post(self, request):
        """Render a confirmation modal for selected devices before bulk import."""
        # Check write permission before showing import confirmation
        if error := self.require_write_permission():
            return error

        post_server_key = (request.POST.get("server_key") or "").strip()
        if post_server_key:
            from netbox_librenms_plugin.librenms_api import LibreNMSAPI

            self._librenms_api = LibreNMSAPI(server_key=post_server_key)

        device_ids = request.POST.getlist("select")
        if not device_ids:
            return HttpResponse(
                '<div class="alert alert-warning mb-0">Select at least one device.</div>',
                status=400,
            )

        use_sysname, strip_domain = resolve_naming_preferences(request)
        vc_detection_enabled = _resolve_vc_detection_enabled(request)

        devices = []
        errors = []
        seen_ids = set()
        cache_expired_count = 0

        for raw_device_id in device_ids:
            try:
                device_id = int(raw_device_id)
            except (TypeError, ValueError):
                errors.append(f"Invalid device identifier: {raw_device_id}")
                continue

            if device_id in seen_ids:
                continue
            seen_ids.add(device_id)

            # Try to use cached device data from table load or role changes
            libre_device = fetch_device_with_cache(device_id, self.librenms_api)
            from_cache = libre_device is not None

            if not from_cache:
                cache_expired_count += 1

            if not libre_device:
                errors.append(f"Device ID {device_id} not found in LibreNMS")
                continue

            selections = extract_device_selections(request, device_id)
            cluster_id = selections["cluster_id"]
            role_id = selections["role_id"]
            rack_id = selections["rack_id"]
            is_vm = bool(cluster_id)

            validation = validate_device_for_import(
                libre_device,
                import_as_vm=is_vm,
                api=self.librenms_api,
                use_sysname=use_sysname,
                strip_domain=strip_domain,
                server_key=self.librenms_api.server_key,
                # Keep confirm modal aligned with import-time behavior: always
                # detect VC membership so stack members are visible before import.
                include_vc_detection=True,
            )
            # Recompute is_vm from validation result — the function may have
            # detected an existing VM via hostname/IP lookup
            is_vm = bool(validation.get("import_as_vm"))

            # Mark validation with VC detection flag for proper URL generation in table
            # Bulk confirm should respect the initial filter's VC detection preference
            validation["_vc_detection_enabled"] = vc_detection_enabled

            device_name = validation.get("resolved_name") or f"device-{device_id}"

            if validation.get("virtual_chassis", {}).get("is_stack") and device_name:
                validation["virtual_chassis"] = update_vc_member_suggested_names(
                    validation["virtual_chassis"], device_name
                )

            from dcim.models import DeviceRole, Rack
            from virtualization.models import Cluster

            role = fetch_model_by_id(DeviceRole, role_id) if role_id else None
            cluster = fetch_model_by_id(Cluster, cluster_id) if cluster_id else None
            rack = fetch_model_by_id(Rack, rack_id) if rack_id else None

            if is_vm:
                if cluster:
                    apply_cluster_to_validation(validation, cluster)

                if role:
                    apply_role_to_validation(validation, role, is_vm=True)
            else:
                if role:
                    apply_role_to_validation(validation, role, is_vm=False)

                if rack:
                    apply_rack_to_validation(validation, rack)

            devices.append(
                {
                    "device_id": device_id,
                    "device_name": device_name,
                    "validation": validation,
                    "role": role,
                    "cluster": cluster,
                    "rack": rack,
                    "is_vm": is_vm,
                }
            )

        if not devices:
            # Check if this is due to cache expiration
            if cache_expired_count > 0 and cache_expired_count == len(seen_ids):
                return HttpResponse(
                    '<div class="alert alert-warning mb-0">'
                    '<i class="mdi mdi-clock-alert"></i> '
                    "<strong>Filter results have expired.</strong><br>"
                    "The device data is no longer available in cache (5-minute timeout). "
                    'Please <a href="javascript:window.location.reload();" class="alert-link">refresh the page</a> '
                    "or re-run your filter to reload device data."
                    "</div>",
                    status=400,
                )
            elif cache_expired_count > 0:
                # Partial expiration - some devices lost their selections
                return HttpResponse(
                    '<div class="alert alert-warning mb-0">'
                    '<i class="mdi mdi-clock-alert"></i> '
                    f"<strong>Some device data has expired.</strong><br>"
                    f"{cache_expired_count} of {len(seen_ids)} selected devices had expired cache data and may be missing role/rack selections. "
                    'Please <a href="javascript:window.location.reload();" class="alert-link">refresh the page</a> '
                    "or re-run your filter to reload device data."
                    "</div>",
                    status=400,
                )
            else:
                # Generic error - validation failed for all devices
                return HttpResponse(
                    '<div class="alert alert-danger mb-0">'
                    "No valid devices selected. "
                    f"{len(errors)} error(s) occurred: {' '.join(errors) if errors else 'Please check device validation status.'}"
                    "</div>",
                    status=400,
                )

        context = {
            "devices": devices,
            "device_count": len(devices),
            "errors": errors,
            "use_sysname": use_sysname,
            "strip_domain": strip_domain,
            "server_key": self.librenms_api.server_key,
            "vc_detection_enabled": vc_detection_enabled,
        }

        return render(
            request,
            "netbox_librenms_plugin/htmx/bulk_import_confirm.html",
            context,
        )


class BulkImportDevicesView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View):
    """Handle bulk import requests coming from the LibreNMS import table."""

    def should_use_background_job_for_import(self, request):
        """
        Determine if import operation should run as background job.

        Import jobs provide active cancellation and keep the browser responsive
        during bulk imports.

        Note: Non-superusers automatically fall back to synchronous mode because
        the /api/core/background-tasks/ endpoint requires superuser access.

        Args:
            request: Django request object containing POST data

        Returns:
            bool: True if background job should be used, False for synchronous
        """
        # Non-superusers cannot poll background-tasks API (requires IsSuperuser)
        if not request.user.is_superuser:
            return False
        return request.POST.get("use_background_job") == "on"

    def post(self, request):  # noqa: PLR0912 - branching keeps responses explicit
        """Import selected devices from LibreNMS into NetBox."""
        # Check write permission before any import operation
        if error := self.require_write_permission():
            return error

        post_server_key = (request.POST.get("server_key") or "").strip()
        if post_server_key:
            from netbox_librenms_plugin.librenms_api import LibreNMSAPI

            self._librenms_api = LibreNMSAPI(server_key=post_server_key)

        device_ids = request.POST.getlist("select")
        if not device_ids:
            messages.error(request, "No devices selected for import")
            return HttpResponse("No devices selected", status=400)

        try:
            parsed_ids = [int(device_id) for device_id in device_ids]
        except (TypeError, ValueError):
            messages.error(request, "Invalid device identifier supplied")
            return HttpResponse("Invalid device identifier", status=400)

        use_sysname, strip_domain = resolve_naming_preferences(request)
        vc_detection_enabled = _resolve_vc_detection_enabled(request)
        sync_options = {
            "sync_interfaces": request.POST.get("sync_interfaces") == "on",
            "sync_cables": request.POST.get("sync_cables") == "on",
            "sync_ips": request.POST.get("sync_ips") == "on",
            "vc_detection_enabled": vc_detection_enabled,
            "use_sysname": use_sysname,
            "strip_domain": strip_domain,
        }

        manual_mappings_per_device: dict[int, dict[str, int]] = {}
        vm_imports: dict[int, dict[str, int]] = {}  # Track which devices to import as VMs

        for device_id in parsed_ids:
            mappings = {}
            cluster_value = request.POST.get(f"cluster_{device_id}")

            # If cluster is selected, this is a VM import
            if cluster_value:
                try:
                    vm_imports[device_id] = {"cluster_id": int(cluster_value)}
                    # VMs can also have roles
                    role_value = request.POST.get(f"role_{device_id}")
                    if role_value:
                        vm_imports[device_id]["device_role_id"] = int(role_value)
                except (TypeError, ValueError):
                    logger.warning(
                        "Ignoring invalid cluster/role id for VM import of device %s",
                        device_id,
                    )
                continue  # Skip device-specific mappings for VMs

            # Device import mappings
            role_value = request.POST.get(f"role_{device_id}")
            if role_value:
                try:
                    mappings["device_role_id"] = int(role_value)
                except (TypeError, ValueError):
                    logger.warning(
                        "Ignoring invalid role id '%s' for device %s",
                        role_value,
                        device_id,
                    )

            rack_value = request.POST.get(f"rack_{device_id}")
            if rack_value:
                try:
                    mappings["rack_id"] = int(rack_value)
                except (TypeError, ValueError):
                    logger.warning(
                        "Ignoring invalid rack id '%s' for device %s",
                        rack_value,
                        device_id,
                    )

            if mappings:
                manual_mappings_per_device[device_id] = mappings

        # Separate device IDs into device imports vs VM imports
        device_ids_to_import = [d for d in parsed_ids if d not in vm_imports]
        vm_ids_to_import = list(vm_imports.keys())

        # Build cache of already-fetched device data to avoid redundant API calls
        libre_devices_cache = {}
        for device_id in parsed_ids:
            cached_device = fetch_device_with_cache(device_id, self.librenms_api)
            if cached_device:
                libre_devices_cache[device_id] = cached_device

        # Check if we should use background job for import
        total_import_count = len(parsed_ids)

        # Decide whether to use background job
        if self.should_use_background_job_for_import(request):
            # Check if RQ workers are available
            from utilities.rqworker import get_workers_for_queue

            if get_workers_for_queue("default") > 0:
                from netbox_librenms_plugin.jobs import ImportDevicesJob

                # Enqueue background job
                job = ImportDevicesJob.enqueue(
                    user=request.user,
                    device_ids=device_ids_to_import,
                    vm_imports=vm_imports,
                    server_key=self.librenms_api.server_key,
                    sync_options=sync_options,
                    manual_mappings_per_device=manual_mappings_per_device,
                    libre_devices_cache=libre_devices_cache,
                )

                logger.info(
                    f"Enqueued ImportDevicesJob {job.pk} (UUID: {job.job_id}) for user {request.user} - {total_import_count} devices/VMs"
                )

                # Show notification and redirect - matching NetBox's native pattern
                from django.utils.safestring import mark_safe

                messages.info(
                    request,
                    mark_safe(
                        f"Import job started for {total_import_count} device{'s' if total_import_count != 1 else ''}. "
                        f'You can monitor progress in the <a href="/core/jobs/{job.pk}/">Jobs interface</a>.'
                    ),
                )

                if request.headers.get("HX-Request"):
                    # For HTMX requests, redirect to clean import page (no filters)
                    # This matches the "Clear" button behavior
                    return HttpResponse(
                        "",
                        headers={"HX-Redirect": "/plugins/librenms_plugin/librenms-import/"},
                    )
                else:
                    return redirect("plugins:netbox_librenms_plugin:librenms_import")
            else:
                # No workers available - warn user and proceed synchronously
                logger.warning("No RQ workers available for import job, falling back to synchronous import")
                messages.warning(
                    request,
                    f"Background job requested but no workers available. Importing {total_import_count} devices synchronously...",
                )

        # Synchronous import execution
        # Build cache of already-fetched device data to avoid redundant API calls
        libre_devices_cache_sync = {}
        for device_id in parsed_ids:
            cached_device = fetch_device_with_cache(device_id, self.librenms_api)
            if cached_device:
                libre_devices_cache_sync[device_id] = cached_device

        # Import devices and VMs separately
        device_result = {
            "success": [],
            "failed": [],
            "skipped": [],
            "virtual_chassis_created": 0,
        }
        vm_result = {"success": [], "failed": [], "skipped": []}

        try:
            # Import devices if any
            if device_ids_to_import:
                device_result = bulk_import_devices(
                    device_ids=device_ids_to_import,
                    server_key=self.librenms_api.server_key,
                    sync_options=sync_options,
                    manual_mappings_per_device=manual_mappings_per_device,  # type: ignore
                    libre_devices_cache=libre_devices_cache_sync,
                    user=request.user,  # Pass user for permission checks
                )

            # Import VMs if any
            if vm_ids_to_import:
                vm_result = bulk_import_vms(
                    vm_imports,
                    self.librenms_api,
                    sync_options,
                    libre_devices_cache_sync,
                    user=request.user,  # Pass user for permission checks
                )

        except PermissionDenied as exc:
            # Handle permission errors with a user-friendly message
            logger.warning(f"Permission denied during import: {exc}")
            messages.error(request, str(exc))
            if request.headers.get("HX-Request"):
                return HttpResponse(
                    "",
                    headers={"HX-Redirect": "/plugins/librenms_plugin/librenms-import/"},
                )
            return redirect("plugins:netbox_librenms_plugin:librenms_import")

        except Exception as exc:  # pragma: no cover - defensive guard
            logger.exception("Error during bulk import")
            if request.headers.get("HX-Request"):
                return HttpResponse(str(exc), status=500)
            messages.error(request, f"Bulk import failed: {exc}")
            return redirect("plugins:netbox_librenms_plugin:librenms_import")

        # Combine results
        success_count = len(device_result.get("success", [])) + len(vm_result.get("success", []))
        failed_count = len(device_result.get("failed", [])) + len(vm_result.get("failed", []))
        skipped_count = len(device_result.get("skipped", [])) + len(vm_result.get("skipped", []))

        if success_count:
            messages.success(
                request,
                f"Successfully imported {success_count} LibreNMS device{'s' if success_count != 1 else ''}",
            )
        if failed_count:
            messages.error(
                request,
                f"Failed to import {failed_count} device{'s' if failed_count != 1 else ''}",
            )
        if skipped_count:
            messages.warning(
                request,
                f"Skipped {skipped_count} existing device{'s' if skipped_count != 1 else ''}",
            )

        if request.headers.get("HX-Request"):
            # Return updated rows for all imported devices using HTMX OOB swaps
            # This updates only the affected rows instead of refreshing the entire table
            updated_rows_html = []

            # Collect all successfully imported device IDs (devices + VMs)
            imported_device_ids = [item["device_id"] for item in device_result.get("success", [])] + [
                item["device_id"] for item in vm_result.get("success", [])
            ]

            # Re-validate and render each imported device with fresh status
            for device_id in imported_device_ids:
                # Fetch device from cache or API
                libre_device = fetch_device_with_cache(
                    device_id,
                    self.librenms_api,
                    libre_devices_cache=libre_devices_cache_sync,
                )

                if libre_device:
                    # Determine if this was imported as VM or device
                    is_vm = device_id in [item["device_id"] for item in vm_result.get("success", [])]

                    # Re-validate with fresh status (will now show as imported)
                    # Pass naming preferences so name comparison uses the same
                    # resolved name the device was imported with.
                    validation = validate_device_for_import(
                        libre_device,
                        import_as_vm=is_vm,
                        api=None,  # No VC detection needed for already-imported devices
                        include_vc_detection=False,
                        server_key=self.librenms_api.server_key,
                        use_sysname=sync_options.get("use_sysname", True),
                        strip_domain=sync_options.get("strip_domain", False),
                    )
                    validation["import_as_vm"] = is_vm

                    # Update cache with fresh validation
                    libre_device["_validation"] = validation
                    cache_key = get_import_device_cache_key(device_id, self.librenms_api.server_key)
                    cache.set(cache_key, libre_device, self.librenms_api.cache_timeout)

                    # Render updated row
                    table = DeviceImportTable([libre_device])
                    context = {
                        "record": libre_device,
                        "table": table,
                        "cluster_id": None,
                        "role_id": None,
                        "rack_id": None,
                    }

                    row_html = render(
                        request,
                        "netbox_librenms_plugin/htmx/device_import_row.html",
                        context,
                    ).content.decode("utf-8")
                    updated_rows_html.append(row_html)

            # Return concatenated row HTML with closeModal trigger
            return HttpResponse(
                "\n".join(updated_rows_html),
                headers={"HX-Trigger": '{"closeModal": null}'},
            )

        return redirect("plugins:netbox_librenms_plugin:librenms_import")


class DeviceVCDetailsView(LibreNMSPermissionMixin, LibreNMSAPIMixin, View):
    """HTMX view to show virtual chassis details."""

    def get(self, request, device_id):
        """Render virtual chassis details for a LibreNMS device."""
        libre_device = get_librenms_device_by_id(self.librenms_api, device_id)
        if not libre_device:
            return HttpResponse(
                '<div class="alert alert-danger">Device not found in LibreNMS</div>',
                status=404,
            )

        vc_data = get_virtual_chassis_data(self.librenms_api, device_id)

        context = {
            "libre_device": libre_device,
            "vc_data": vc_data,
        }

        return render(
            request,
            "netbox_librenms_plugin/htmx/device_vc_details.html",
            context,
        )


class DeviceValidationDetailsView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View):
    """HTMX view to show detailed validation information."""

    def get(self, request, device_id):
        """Render detailed validation information for a LibreNMS device."""
        libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)

        if not libre_device:
            return HttpResponse(
                '<div class="alert alert-danger">Device not found in LibreNMS</div>',
                status=404,
            )

        use_sysname, strip_domain = resolve_naming_preferences(request)

        context = {
            "libre_device": libre_device,
            "validation": validation,
            "use_sysname": use_sysname,
            "strip_domain": strip_domain,
            "server_key": self.librenms_api.server_key,
        }

        # Add sync comparison data for existing devices
        existing = validation.get("existing_device")
        if existing:
            context["sync_info"] = self._build_sync_info(libre_device, existing)
            context["existing_id_servers"] = self._build_id_server_info(existing)
            context["existing_device_model_name"] = existing._meta.model_name

        return render(
            request,
            "netbox_librenms_plugin/htmx/device_validation_details.html",
            context,
        )

    @staticmethod
    def _build_sync_info(libre_device, existing_device):
        """Build sync comparison data between LibreNMS device and existing NetBox device."""
        librenms_serial = libre_device.get("serial") or "-"
        librenms_os = libre_device.get("os") or "-"
        librenms_hardware = libre_device.get("hardware") or "-"

        # Serial comparison (VMs may not have serial in all NetBox versions)
        netbox_serial = getattr(existing_device, "serial", None) or ""
        serial_synced = netbox_serial == librenms_serial or librenms_serial == "-"

        # Platform comparison
        platform_info = {
            "netbox_platform": getattr(existing_device, "platform", None),
            "librenms_os": librenms_os,
            "platform_exists": False,
            "matching_platform": None,
        }
        if librenms_os and librenms_os != "-":
            from netbox_librenms_plugin.utils import find_matching_platform

            match_result = find_matching_platform(librenms_os)
            if match_result["found"]:
                platform_info["platform_exists"] = True
                platform_info["matching_platform"] = match_result["platform"]

        netbox_platform = platform_info["netbox_platform"]
        matching_platform = platform_info["matching_platform"]
        platform_synced = librenms_os == "-" or bool(
            netbox_platform and matching_platform and netbox_platform.pk == matching_platform.pk
        )

        # Device type comparison (VMs don't have device_type)
        device_type_synced = True
        librenms_device_type = None
        from virtualization.models import VirtualMachine

        if not isinstance(existing_device, VirtualMachine):
            netbox_device_type = getattr(existing_device, "device_type", None)
            if librenms_hardware and librenms_hardware != "-":
                from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type

                hw_match = match_librenms_hardware_to_device_type(librenms_hardware)
                if hw_match is None:
                    device_type_synced = False
                elif hw_match.get("matched"):
                    librenms_device_type = hw_match["device_type"]
                    if netbox_device_type is None or netbox_device_type.pk != librenms_device_type.pk:
                        device_type_synced = False
                else:
                    device_type_synced = False

        all_synced = serial_synced and platform_synced and device_type_synced

        return {
            "librenms_serial": librenms_serial,
            "serial_synced": serial_synced,
            "platform_info": platform_info,
            "platform_synced": platform_synced,
            "librenms_hardware": librenms_hardware,
            "librenms_device_type": librenms_device_type,
            "device_type_synced": device_type_synced,
            "all_synced": all_synced,
        }

    @staticmethod
    def _build_id_server_info(existing_device):
        """
        Return per-server ID mappings for the existing device's librenms_id custom field.

        Returns a list of dicts with server_key, display_name, and device_id — one entry
        per server the device is linked to. Returns None when the format is legacy (bare int)
        or when the field is absent/invalid.
        """
        from django.conf import settings

        cf_value = existing_device.custom_field_data.get("librenms_id")
        if not isinstance(cf_value, dict):
            return None

        plugins_config = settings.PLUGINS_CONFIG.get("netbox_librenms_plugin") or {}
        servers_config = plugins_config.get("servers") or {}
        if not isinstance(servers_config, dict):
            servers_config = {}
        result = []
        for sk, did in cf_value.items():
            if isinstance(did, bool) or not isinstance(did, (int, str)):
                continue
            if isinstance(did, str):
                if not did.isdigit():
                    continue
                did = int(did)
            srv_cfg = servers_config.get(sk)
            # Legacy single-server config: "default" key with no matching servers entry —
            # fall back to root-level display_name in plugins_config.
            if srv_cfg is None and sk == "default" and not servers_config:
                display_name = plugins_config.get("display_name") or sk
            else:
                if not isinstance(srv_cfg, dict):
                    srv_cfg = {}
                display_name = srv_cfg.get("display_name") or sk
            result.append({"server_key": sk, "display_name": display_name, "device_id": did})
        return result or None


class DeviceRoleUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View):
    """HTMX view to update a table row when a role is selected."""

    def post(self, request, device_id):
        """Update the table row after a device role selection change."""
        libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)

        if not libre_device:
            return HttpResponse("Device not found", status=404)

        return self.render_device_row(request, libre_device, validation, selections)


class DeviceClusterUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View):
    """HTMX view to update a table row when a cluster is selected/deselected."""

    def post(self, request, device_id):
        """Update the table row after a cluster selection change."""
        libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)

        if not libre_device:
            return HttpResponse("Device not found", status=404)

        return self.render_device_row(request, libre_device, validation, selections)


class DeviceRackUpdateView(LibreNMSPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View):
    """HTMX view to update a table row when a rack is selected."""

    def post(self, request, device_id):
        """Update the table row after a rack selection change."""
        libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)

        if not libre_device:
            return HttpResponse("Device not found", status=404)

        return self.render_device_row(request, libre_device, validation, selections)


class DeviceConflictActionView(
    LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View
):
    """HTMX view to resolve device conflicts (link, update, update serial)."""

    def post(self, request, device_id):
        """Resolve a device conflict by linking, updating, or syncing serial."""
        if error := self.require_write_permission():
            return error

        from dcim.models import Device
        from netbox_librenms_plugin.librenms_api import LibreNMSAPI

        action = request.POST.get("action")
        existing_device_id = request.POST.get("existing_device_id")
        existing_device_type = request.POST.get("existing_device_type", "device")

        # If the form submitted a specific server_key, honour it so the handler uses
        # the same server context as the import page when the user clicked the button.
        post_server_key = (request.POST.get("server_key") or "").strip()
        if post_server_key:
            self._librenms_api = LibreNMSAPI(server_key=post_server_key)

        if not action or not existing_device_id:
            return HttpResponse("Missing action or existing_device_id", status=400)

        # VirtualMachine supports migrate_librenms_id, sync_name, and sync_platform.
        # Device-only actions (serial, device_type, legacy link/update) are rejected.
        if existing_device_type == "virtualmachine":
            if action in _DEVICE_ONLY_ACTIONS:
                return HttpResponse(
                    f"Action '{escape(action)}' is not supported for virtual machines",
                    status=400,
                )
            from virtualization.models import VirtualMachine as NetBoxVM

            existing_model: type = NetBoxVM
        else:
            existing_model = Device

        try:
            existing_device = existing_model.objects.get(pk=int(existing_device_id))
        except (existing_model.DoesNotExist, ValueError):
            return HttpResponse("Existing device not found", status=404)

        # Object-level change permission for the specific model being mutated.
        self.required_object_permissions = {"POST": [("change", existing_model)]}
        if error := self.require_object_permissions("POST"):
            return error

        libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)
        if not libre_device:
            return HttpResponse("LibreNMS device not found", status=404)

        # Verify the POSTed existing_device_id matches the validated conflict target.
        # Require a confirmed conflict target: if validation has no existing_device, the
        # LibreNMS device was not validated against this NetBox device, so mutations are unsafe.
        validated_existing = validation.get("existing_device") if validation else None
        if validated_existing is None:
            return HttpResponse("Missing validated conflict target", status=400)
        if validated_existing.pk != existing_device.pk or type(validated_existing) is not type(existing_device):
            return HttpResponse("Device ID mismatch: existing_device_id does not match validated device", status=400)

        # Require force flag when device type mismatches, but only for actions that use it
        force = request.POST.get("force") == "on"
        if validation.get("device_type_mismatch") and action in _FORCE_REQUIRED_ACTIONS and not force:
            return HttpResponse(
                "Device type mismatch detected. Check the force checkbox to proceed.",
                status=400,
            )

        # When force is used with device_type_mismatch, update device type to LibreNMS value
        librenms_device_type = None
        if validation.get("device_type_mismatch") and force:
            librenms_device_type = validation.get("device_type", {}).get("device_type")

        librenms_id = libre_device.get("device_id")
        if isinstance(librenms_id, bool):
            return HttpResponse("Invalid or missing LibreNMS device_id in payload", status=400)
        try:
            librenms_id = int(librenms_id)
        except (TypeError, ValueError):
            return HttpResponse("Invalid or missing LibreNMS device_id in payload", status=400)
        if librenms_id <= 0:
            return HttpResponse("Invalid or missing LibreNMS device_id in payload", status=400)

        # Wrap the LibreNMS-ID collision check and subsequent write in a single
        # transaction so the read-then-write is atomic for link/update/update_serial.
        # NOTE: A fully race-free guarantee would require a DB-unique constraint on
        # (server_key, librenms_id) — e.g., a dedicated DeviceLibreNMSIDMapping model.
        # That is deferred to a future schema migration.  Until then, we acquire a
        # row-level lock on the target device before re-checking for conflicts, which
        # serializes concurrent operations on the SAME device and greatly reduces the
        # window for assigning the same ID to two DIFFERENT devices.
        if action in {"link", "update", "update_serial"}:
            from netbox_librenms_plugin.utils import find_by_librenms_id

            with transaction.atomic():
                server_key = self.librenms_api.server_key
                # Lock the target device row so concurrent requests for the same
                # device are serialized.  The conflict check below is still a
                # best-effort guard for different devices; a DB unique constraint
                # would be needed for full protection.
                try:
                    existing_device = Device.objects.select_for_update().get(pk=existing_device.pk)
                except Device.DoesNotExist:
                    return HttpResponse(
                        "Device no longer exists; it may have been deleted concurrently.",
                        status=409,
                    )
                id_conflict = find_by_librenms_id(Device, int(librenms_id), server_key)
                if id_conflict and id_conflict.pk != existing_device.pk:
                    return HttpResponse(
                        f"LibreNMS ID conflict: ID {escape(str(librenms_id))} is already assigned to device "
                        f"'{escape(id_conflict.name)}' (ID: {id_conflict.pk})",
                        status=409,
                    )

                # Reject legacy bare-int/string librenms_id: set_librenms_device_id
                # silently skips writes for legacy formats, leaving the device partially
                # updated. User must run "Convert mapping" migration first.
                stored_id = existing_device.custom_field_data.get("librenms_id")
                _is_legacy = isinstance(stored_id, int) and not isinstance(stored_id, bool)
                if not _is_legacy and isinstance(stored_id, str):
                    try:
                        int(stored_id)
                        _is_legacy = True
                    except (ValueError, TypeError):
                        pass
                if _is_legacy:
                    return HttpResponse(
                        "Device has a legacy bare-integer librenms_id; use 'Convert mapping' "
                        "to migrate to the multi-server format before linking.",
                        status=409,
                    )

                if action == "link":
                    # Link to LibreNMS and update name from LibreNMS data
                    hostname = _get_hostname_for_action(request, validation, libre_device)
                    set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key)
                    existing_device.name = hostname
                    if librenms_device_type:
                        existing_device.device_type = librenms_device_type
                    if err := _save_device(existing_device):
                        return err
                    logger.info(f"Linked device '{existing_device.name}' to LibreNMS ID {librenms_id}")

                elif action == "update":
                    # Update hostname, serial, and link to LibreNMS
                    hostname = _get_hostname_for_action(request, validation, libre_device)
                    incoming_serial = libre_device.get("serial") or ""
                    if incoming_serial and incoming_serial != "-":
                        # Lock any conflicting device under the same transaction to reduce
                        # the serial-assignment race window (best-effort; a DB unique
                        # constraint on serial would give full protection).
                        conflict_device = (
                            Device.objects.select_for_update()
                            .filter(serial=incoming_serial)
                            .exclude(pk=existing_device.pk)
                            .first()
                        )
                        if conflict_device:
                            return HttpResponse(
                                f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device "
                                f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})",
                                status=409,
                            )
                        existing_device.serial = incoming_serial
                    existing_device.name = hostname
                    if librenms_device_type:
                        existing_device.device_type = librenms_device_type
                    set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key)
                    if err := _save_device(existing_device):
                        return err
                    logger.info(
                        f"Updated device '{existing_device.name}': serial={incoming_serial}, "
                        f"linked to LibreNMS ID {librenms_id}"
                    )

                elif action == "update_serial":
                    # Update only the serial and link to LibreNMS
                    incoming_serial = libre_device.get("serial") or ""
                    if incoming_serial and incoming_serial != "-":
                        # Lock any conflicting device under the same transaction to reduce
                        # the serial-assignment race window (best-effort; a DB unique
                        # constraint on serial would give full protection).
                        conflict_device = (
                            Device.objects.select_for_update()
                            .filter(serial=incoming_serial)
                            .exclude(pk=existing_device.pk)
                            .first()
                        )
                        if conflict_device:
                            return HttpResponse(
                                f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device "
                                f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})",
                                status=409,
                            )
                        existing_device.serial = incoming_serial
                    if librenms_device_type:
                        existing_device.device_type = librenms_device_type
                    set_librenms_device_id(existing_device, librenms_id, self.librenms_api.server_key)
                    if err := _save_device(existing_device):
                        return err
                    logger.info(
                        f"Updated serial on device '{existing_device.name}' to {incoming_serial}, "
                        f"linked to LibreNMS ID {librenms_id}"
                    )

        elif action == "sync_name":
            # Sync device name from LibreNMS (e.g., IP → sysName)
            hostname = _get_hostname_for_action(request, validation, libre_device)
            existing_device.name = hostname
            if err := _save_device(existing_device):
                return err
            logger.info(f"Synced name on device '{existing_device.name}' from LibreNMS")

        elif action == "update_type":
            # Update device type from LibreNMS (requires force for mismatch)
            if librenms_device_type:
                existing_device.device_type = librenms_device_type
                if err := _save_device(existing_device):
                    return err
                logger.info(f"Updated device type on '{existing_device.name}' to {librenms_device_type}")
            else:
                return HttpResponse("No LibreNMS device type available to update", status=400)

        elif action == "sync_serial":
            # Sync serial number from LibreNMS.
            # Wrap conflict-check-and-write in a transaction with a row lock so
            # concurrent requests cannot both pass the serial uniqueness guard.
            incoming_serial = libre_device.get("serial") or ""
            if incoming_serial and incoming_serial != "-":
                with transaction.atomic():
                    try:
                        locked_device = Device.objects.select_for_update().get(pk=existing_device.pk)
                    except Device.DoesNotExist:
                        return HttpResponse(
                            "Device no longer exists; it may have been deleted concurrently.",
                            status=409,
                        )
                    # Re-check for serial ownership conflict under lock.
                    # Note: We intentionally do NOT enforce a DB-level uniqueness constraint on
                    # Device.serial. During device moves/replacements, multiple devices may
                    # temporarily share a serial (old record gets updated later). A unique
                    # constraint would block those valid workflows. Instead, we rely on this
                    # in-transaction row-lock check to guard concurrent sync of the SAME serial,
                    # and flag conflicts via a 409 response for the user to resolve manually.
                    conflict_device = Device.objects.filter(serial=incoming_serial).exclude(pk=locked_device.pk).first()
                    if conflict_device:
                        logger.warning(
                            f"Serial sync blocked: '{incoming_serial}' already assigned to "
                            f"'{conflict_device.name}' (pk={conflict_device.pk})"
                        )
                        return HttpResponse(
                            f"Serial conflict: '{escape(incoming_serial)}' is already assigned to device "
                            f"'{escape(conflict_device.name)}' (ID: {conflict_device.pk})",
                            status=409,
                        )
                    locked_device.serial = incoming_serial
                    if err := _save_device(locked_device):
                        return err
                    logger.info(f"Synced serial on '{locked_device.name}' to {incoming_serial}")
            else:
                return HttpResponse("No valid serial from LibreNMS", status=400)

        elif action == "sync_platform":
            # Sync platform from LibreNMS OS
            from netbox_librenms_plugin.utils import find_matching_platform

            librenms_os = libre_device.get("os") or ""
            if librenms_os and librenms_os != "-":
                match_result = find_matching_platform(librenms_os)
                if match_result["found"]:
                    existing_device.platform = match_result["platform"]
                    if err := _save_device(existing_device):
                        return err
                    logger.info(f"Synced platform on '{existing_device.name}' to {match_result['platform']}")
                else:
                    return HttpResponse(f"Platform '{escape(librenms_os)}' not found in NetBox", status=400)
            else:
                return HttpResponse("No OS info from LibreNMS", status=400)

        elif action == "sync_device_type":
            # Sync device type from LibreNMS hardware (non-mismatch case)
            from netbox_librenms_plugin.utils import match_librenms_hardware_to_device_type

            hardware = libre_device.get("hardware") or ""
            hw_match = match_librenms_hardware_to_device_type(hardware)
            if hw_match and hw_match.get("matched"):
                existing_device.device_type = hw_match["device_type"]
                if err := _save_device(existing_device):
                    return err
                logger.info(f"Synced device type on '{existing_device.name}' to {hw_match['device_type']}")
            else:
                return HttpResponse(f"No matching device type for '{escape(hardware)}'", status=400)

        elif action == "migrate_librenms_id":
            # Migrate legacy bare-integer librenms_id to the JSON dict format.
            # Only safe when the integer matches the LibreNMS device ID for this server,
            # confirmed by serial match (or explicit force).
            from netbox_librenms_plugin.utils import migrate_legacy_librenms_id

            # Direct access needed to detect legacy integer format for migration prompt:
            # LibreNMSAPI.get_librenms_id() returns an int in both formats; only the raw
            # type check on custom_field_data reveals whether migration is needed.
            cf_value = existing_device.custom_field_data.get("librenms_id")
            if isinstance(cf_value, bool) or not (
                isinstance(cf_value, int) or (isinstance(cf_value, str) and cf_value.isdigit())
            ):
                return HttpResponse(
                    "Device librenms_id is already in JSON format; no migration needed.",
                    status=400,
                )
            # Normalise string-digit to int for consistent comparison
            cf_int = int(cf_value) if isinstance(cf_value, str) else cf_value
            # Verify the stored legacy ID matches the active LibreNMS device_id so we don't
            # migrate a stale/incorrect association to the wrong server mapping.
            if cf_int != librenms_id:
                return HttpResponse(
                    f"Legacy librenms_id ({cf_int}) does not match the active device ID "
                    f"({librenms_id}); cannot migrate safely.",
                    status=400,
                )
            if not validation.get("serial_confirmed") and not force:
                return HttpResponse(
                    "Serial number not confirmed. Check the force checkbox to migrate without serial verification.",
                    status=400,
                )
            with transaction.atomic():
                try:
                    locked_device = existing_model.objects.select_for_update().get(pk=existing_device.pk)
                except existing_model.DoesNotExist:
                    return HttpResponse(
                        "Object no longer exists; it may have been deleted concurrently.",
                        status=409,
                    )
                # Re-check under lock — another request may have already migrated it
                cf_locked = locked_device.custom_field_data.get("librenms_id")
                if isinstance(cf_locked, bool) or not (
                    isinstance(cf_locked, int) or (isinstance(cf_locked, str) and cf_locked.isdigit())
                ):
                    return HttpResponse(
                        "Device librenms_id is already in JSON format; no migration needed.",
                        status=400,
                    )
                cf_locked_int = int(cf_locked) if isinstance(cf_locked, str) else cf_locked
                if cf_locked_int != librenms_id:
                    return HttpResponse(
                        f"Legacy librenms_id changed under lock ({cf_locked_int} != {librenms_id}); cannot migrate safely.",
                        status=400,
                    )
                # Check that no other object already owns this ID (server-scoped or legacy)
                server_key = self.librenms_api.server_key
                from netbox_librenms_plugin.utils import find_by_librenms_id

                match = find_by_librenms_id(existing_model, cf_locked_int, server_key)
                conflict = match is not None and match.pk != locked_device.pk
                if conflict:
                    return HttpResponse(
                        f"Another device already has librenms_id {cf_locked_int} for server '{server_key}'; cannot migrate.",
                        status=409,
                    )
                if not migrate_legacy_librenms_id(locked_device, self.librenms_api.server_key):
                    return HttpResponse(
                        "Migration failed: librenms_id could not be converted.",
                        status=400,
                    )
                if err := _save_device(locked_device):
                    return err
            logger.info(
                f"Migrated legacy librenms_id on '{locked_device.name}' "
                f"to {{{self.librenms_api.server_key!r}: {cf_locked_int}}}"
            )

        else:
            return HttpResponse(f"Unknown action: {escape(action)}", status=400)

        # Clear cached validation so re-validation picks up the changes
        cache_key = get_import_device_cache_key(device_id, self.librenms_api.server_key)
        cache.delete(cache_key)

        # Re-validate and render updated row
        libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request)
        if not libre_device:
            return HttpResponse("Device not found after action", status=404)

        response = self.render_device_row(request, libre_device, validation, selections)
        response["HX-Trigger"] = "closeModal"
        return response


class SaveUserPrefView(LibreNMSPermissionMixin, View):
    """Save a user preference via POST. Used by JS toggle handlers."""

    ALLOWED_PREFS = {
        "use_sysname": "plugins.netbox_librenms_plugin.use_sysname",
        "strip_domain": "plugins.netbox_librenms_plugin.strip_domain",
        "interface_name_field": "plugins.netbox_librenms_plugin.interface_name_field",
    }

    def post(self, request):
        """Persist a user preference toggle value."""
        try:
            data = json.loads(request.body)
        except (json.JSONDecodeError, ValueError):
            return JsonResponse({"error": "Invalid JSON"}, status=400)

        key = data.get("key")
        value = data.get("value")

        if key not in self.ALLOWED_PREFS:
            return JsonResponse({"error": "Invalid preference key"}, status=400)

        save_user_pref(request, self.ALLOWED_PREFS[key], value)
        return JsonResponse({"status": "ok"})
