import logging
import re
import uuid

import djstripe
from django import forms
from django.contrib import admin, messages
from django.contrib.admin import SimpleListFilter
from django.contrib.sites.models import Site
from django.db import transaction
from django.db.models import Count, Prefetch  # For annotated counts
from django.db.models.expressions import OuterRef, Exists

from util.analytics import Analytics, AnalyticsEvent, AnalyticsSource
from api.agent.tasks import process_agent_events_task
from api.services.proactive_activation import ProactiveActivationService
from api.services.skill_analytics import track_global_agent_skill_event
from api.services.owner_execution_pause import (
    get_owner_execution_pause_state,
    pause_owner_execution,
    resume_owner_execution,
)
from api.services.user_fingerprint import (
    FingerprintConfigurationError,
    FingerprintTerminalError,
    enqueue_user_fingerprint_visit_refresh,
)
from api.services.daily_credit_limits import (
    calculate_daily_credit_slider_bounds,
    get_tier_credit_multiplier,
    scale_daily_credit_limit_for_tier_change,
)
from api.services.daily_credit_default_updates import (
    apply_default_daily_credit_target_to_matching_agents,
)
from api.services.daily_credit_settings import (
    get_daily_credit_settings_for_owner,
    serialize_daily_credit_configs,
)
from api.services.schedule_enforcement import (
    agents_for_plan,
    enforce_minimum_for_agents,
    tool_config_min_for_plan,
)
from api.agent.core.schedule_parser import ScheduleParser
from .admin_forms import (
    FindReleaseCandidatesForm,
    ReleaseSmsNumbersForm,
    TestSmsForm,
    GrantPlanCreditsForm,
    GrantCreditsByUserIdsForm,
    BulkSetUserFlagsForm,
    AgentEmailAccountForm,
    StripeConfigForm,
    TrialPromoAdminForm,
    MCPServerConfigAdminForm,
    GlobalAgentSkillImportForm,
)
from .models import (
    ApiKey, UserQuota, UserFlags, UserReferral, TaskCredit, BrowserUseAgent, BrowserUseAgentTask, BrowserUseAgentTaskStep, PaidPlanIntent,
    DecodoCredential, DecodoIPBlock, DecodoIP, ProxyServer, DedicatedProxyAllocation, ProxyHealthCheckSpec, ProxyHealthCheckResult,
    PersistentAgent, PersistentAgentTemplate, PublicProfile, PersistentAgentCommsEndpoint, PersistentAgentMessage, PersistentAgentEmailFooter, PersistentAgentMessageAttachment, PersistentAgentConversation,
    AgentPeerLink, AgentCommPeerState,
    PersistentAgentStep, PersistentAgentPromptArchive, PersistentAgentSkill, PersistentAgentSystemMessage, PersistentAgentSystemMessageBroadcast,
    GlobalAgentSkill, GlobalAgentSkillCustomTool,
    CommsChannel, UserBilling, OrganizationBilling, SmsNumber, LinkShortener,
    AgentFileSpace, AgentFileSpaceAccess, AgentFsNode, Organization, OrganizationMembership, OrganizationInvite, CommsAllowlistEntry,
    AgentEmailAccount, ToolFriendlyName, TaskCreditConfig, ReferralIncentiveConfig, ReferralGrant, Plan, PlanVersion, PlanVersionPrice,
    EntitlementDefinition, PlanVersionEntitlement, DailyCreditConfig, BrowserConfig, PromptConfig, ToolCreditCost,
    StripeConfig, ToolConfig, ToolRateLimit, AddonEntitlement,
    MeteringBatch,
    UsageThresholdSent,
    PersistentAgentWebhook,
    MCPServerConfig,
    AgentColor,
    IntelligenceTier,
    EvalRun,
    EvalRunTask,
    AgentComputeSession,
    ComputeSnapshot,
    UserPreference,
    UserEmail,
    UserFlagChoiceGroup,
    UserFlagChoiceOption,
    UserFlagDefinition,
    UserFingerprintVisit,
    UserIdentitySignal,
    UserTrialEligibility,
    UserTrialActivation,
    TrialPromo,
    TrialPromoRedemption,
    ExecutionPauseReasonChoices,
)
from django.contrib.auth import get_user_model
from django.contrib.auth.admin import UserAdmin
from django.urls import NoReverseMatch, reverse, path
from django.utils.html import format_html
from django.utils import timezone
from django.utils.text import get_valid_filename
from django.http import HttpResponse, HttpResponseRedirect, FileResponse, StreamingHttpResponse
from django.template.response import TemplateResponse
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files.storage import default_storage
from django.db.models import Sum
from .agent.files.filespace_service import enqueue_import_after_commit
from .tasks import sync_ip_block, backfill_missing_proxy_records, proxy_health_check_single, garbage_collect_timed_out_tasks
from .tasks.sms_tasks import sync_twilio_numbers, send_test_sms
from .services.sms_number_inventory import (
    SmsNumberReleaseCandidate,
    find_sms_number_release_candidates,
    release_sms_number,
    retire_sms_number,
)
from .services.global_skill_json import (
    import_global_skill_from_payload,
    parse_global_skill_json_bytes,
    serialize_global_skill_to_json_bytes,
)
from .services.user_flags import (
    filter_users_by_flag,
    get_selected_user_flag_choice_option,
    get_enabled_user_flag_slugs,
    set_user_flag_choice,
    set_user_flag,
)
from config import settings
from constants.plans import PlanNamesChoices

from djstripe.models import Customer, BankAccount, Card
from djstripe.admin import StripeModelAdmin  # base admin with actions & changelist_view

import zstandard as zstd

SMS_RELEASE_CANDIDATES_SESSION_KEY = "admin_sms_release_candidates_phone_numbers"
SMS_RELEASE_CANDIDATES_PREFILL_SOURCE = "release_candidates"

# Replace dj-stripe's default registration
# 2.10.1 has removed some fields we still want to see, but their own admin still references them
admin.site.unregister(Customer)

@admin.register(Customer)
class PatchedCustomerAdmin(StripeModelAdmin):
    # remove the removed field; keep valid FKs
    list_select_related = ("subscriber", "djstripe_owner_account", "default_payment_method")

# --- BankAccount ---
admin.site.unregister(BankAccount)

@admin.register(BankAccount)
class PatchedBankAccountAdmin(StripeModelAdmin):
    # DO NOT include 'customer__default_source' (removed in 2.10)
    # Keep the common useful relations for query perf:
    list_select_related = ("customer", "djstripe_owner_account")

# --- Card ---
admin.site.unregister(Card)

@admin.register(Card)
class PatchedCardAdmin(StripeModelAdmin):
    # Valid relations only; 'customer__default_source' was removed in 2.10
    list_select_related = ("customer", "djstripe_owner_account")


@admin.register(ApiKey)
class ApiKeyAdmin(admin.ModelAdmin):
    list_display = (
        "prefix",
        "owner_display",
        "name",
        "created_by",
        "created_at",
        "revoked_at",
        "last_used_at",
    )
    search_fields = ("user__email", "organization__name", "prefix", "name")
    list_filter = ("organization",)
    readonly_fields = ("prefix", "hashed_key", "created_at", "last_used_at")

    @admin.display(description="Owner")
    def owner_display(self, obj):
        if obj.organization_id:
            return obj.organization
        return obj.user

@admin.register(UserQuota)
class UserQuotaAdmin(admin.ModelAdmin):
    list_display = ("user", "agent_limit", "max_intelligence_tier")
    search_fields = ("user__email", "user__id")


@admin.register(UserFlagDefinition)
class UserFlagDefinitionAdmin(admin.ModelAdmin):
    list_display = ("slug", "description_preview", "created_at", "updated_at")
    search_fields = ("slug", "description")
    readonly_fields = ("created_at", "updated_at")
    fields = ("slug", "description", "created_at", "updated_at")

    @admin.display(description="Description")
    def description_preview(self, obj):
        if len(obj.description) <= 80:
            return obj.description
        return f"{obj.description[:77]}..."

    def get_readonly_fields(self, request, obj=None):
        readonly_fields = list(super().get_readonly_fields(request, obj))
        if obj is not None:
            readonly_fields.append("slug")
        return tuple(readonly_fields)


class UserFlagChoiceOptionAdminForm(forms.ModelForm):
    class Meta:
        model = UserFlagChoiceOption
        fields = "__all__"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.instance and self.instance.pk:
            for field_name in ("group", "flag"):
                if field_name in self.fields:
                    self.fields[field_name].disabled = True


class UserFlagChoiceOptionInline(admin.TabularInline):
    model = UserFlagChoiceOption
    form = UserFlagChoiceOptionAdminForm
    extra = 1
    fields = ("label", "flag", "is_active", "created_at", "updated_at")
    readonly_fields = ("created_at", "updated_at")
    autocomplete_fields = ("flag",)

    def has_delete_permission(self, request, obj=None):
        return False


@admin.register(UserFlagChoiceGroup)
class UserFlagChoiceGroupAdmin(admin.ModelAdmin):
    list_display = ("label", "slug", "option_count", "created_at", "updated_at")
    search_fields = ("label", "slug", "description")
    readonly_fields = ("created_at", "updated_at")
    fields = ("label", "slug", "description", "created_at", "updated_at")
    inlines = (UserFlagChoiceOptionInline,)

    @admin.display(description="Options")
    def option_count(self, obj):
        return obj.options.count()

    def get_readonly_fields(self, request, obj=None):
        readonly_fields = list(super().get_readonly_fields(request, obj))
        if obj is not None:
            readonly_fields.append("slug")
        return tuple(readonly_fields)


@admin.register(UserFlagChoiceOption)
class UserFlagChoiceOptionAdmin(admin.ModelAdmin):
    form = UserFlagChoiceOptionAdminForm
    list_display = ("label", "group", "flag", "is_active", "created_at", "updated_at")
    list_filter = ("group", "is_active")
    search_fields = ("label", "group__label", "group__slug", "flag__slug")
    readonly_fields = ("created_at", "updated_at")
    autocomplete_fields = ("group", "flag")

    def has_delete_permission(self, request, obj=None):
        return False


@admin.register(UserEmail)
class UserEmailAdmin(admin.ModelAdmin):
    list_display = ("name", "event_name", "is_active", "updated_at")
    list_filter = ("is_active",)
    search_fields = ("name", "event_name")
    readonly_fields = ("created_at", "updated_at")
    fields = ("name", "event_name", "is_active", "created_at", "updated_at")


@admin.register(StripeConfig)
class StripeConfigAdmin(admin.ModelAdmin):
    form = StripeConfigForm
    list_display = ("release_env", "live_mode", "updated_at")
    search_fields = ("release_env",)
    list_filter = ("live_mode",)
    readonly_fields = (
        "created_at",
        "updated_at",
        "webhook_secret_status",
    )


    fieldsets = (
        (None, {"fields": ("release_env", "live_mode")}),
        (
            "Secrets",
            {
                "fields": (
                    "webhook_secret",
                    "clear_webhook_secret",
                    "webhook_secret_status",
                )
            },
        ),
        (
            "Startup (Pro)",
            {
                "fields": (
                    "startup_price_id",
                    "startup_trial_days",
                    "startup_additional_task_price_id",
                    "startup_task_pack_product_id",
                    "startup_task_pack_price_ids",
                    "startup_product_id",
                    "startup_contact_cap_product_id",
                    "startup_contact_cap_price_ids",
                    "startup_browser_task_limit_product_id",
                    "startup_browser_task_limit_price_ids",
                    "startup_advanced_captcha_resolution_product_id",
                    "startup_advanced_captcha_resolution_price_id",
                    "startup_dedicated_ip_price_id",
                    "startup_dedicated_ip_product_id",
                )
            },
        ),
        (
            "Scale",
            {
                "fields": (
                    "scale_product_id",
                    "scale_price_id",
                    "scale_trial_days",
                    "scale_additional_task_price_id",
                    "scale_task_pack_product_id",
                    "scale_task_pack_price_ids",
                    "scale_contact_cap_product_id",
                    "scale_contact_cap_price_ids",
                    "scale_browser_task_limit_product_id",
                    "scale_browser_task_limit_price_ids",
                    "scale_advanced_captcha_resolution_product_id",
                    "scale_advanced_captcha_resolution_price_id",
                    "scale_dedicated_ip_product_id",
                    "scale_dedicated_ip_price_id",
                )
            },
        ),
        (
            "Org Team",
            {
                "fields": (
                    "org_team_price_id",
                    "org_team_product_id",
                    "org_team_dedicated_ip_price_id",
                    "org_team_dedicated_ip_product_id",
                    "org_team_additional_task_product_id",
                    "org_team_additional_task_price_id",
                    "org_team_task_pack_product_id",
                    "org_team_task_pack_price_ids",
                    "org_team_contact_cap_product_id",
                    "org_team_contact_cap_price_ids",
                    "org_team_browser_task_limit_product_id",
                    "org_team_browser_task_limit_price_ids",
                    "org_team_advanced_captcha_resolution_product_id",
                    "org_team_advanced_captcha_resolution_price_id",
                )
            },
        ),
        (
            "Task Meters",
            {
                "fields": (
                    "task_meter_id",
                    "task_meter_event_name",
                    "org_team_task_meter_id",
                    "org_team_task_meter_event_name",
                    "org_task_meter_id",
                )
            },
        ),
        (
            "Metadata",
            {
                "fields": (
                    "created_at",
                    "updated_at",
                )
            },
        ),
    )

    def webhook_secret_status(self, obj):
        return "Configured" if obj.has_value("webhook_secret") else "Not set"

    webhook_secret_status.short_description = "Webhook secret"


@admin.register(TrialPromo)
class TrialPromoAdmin(admin.ModelAdmin):
    form = TrialPromoAdminForm
    list_display = (
        "name",
        "code_label",
        "plan",
        "trial_days",
        "payment_method_required",
        "repeat_trials_allowed",
        "trial_abuse_filtering_enabled",
        "max_redemptions",
        "is_active",
        "active_from",
        "active_until",
    )
    list_filter = (
        "plan",
        "payment_method_required",
        "repeat_trials_allowed",
        "trial_abuse_filtering_enabled",
        "is_active",
    )
    search_fields = ("name", "code_label", "headline", "description")
    readonly_fields = ("code_digest", "created_at", "updated_at")
    fields = (
        "name",
        "code",
        "code_label",
        "code_digest",
        "plan",
        "trial_days",
        "payment_method_required",
        "no_payment_method_end_behavior",
        "repeat_trials_allowed",
        "trial_abuse_filtering_enabled",
        "trial_credit_amount",
        "max_redemptions",
        "active_from",
        "active_until",
        "is_active",
        "headline",
        "description",
        "created_at",
        "updated_at",
    )


@admin.register(TrialPromoRedemption)
class TrialPromoRedemptionAdmin(admin.ModelAdmin):
    list_display = (
        "promo",
        "user",
        "status",
        "stripe_customer_id",
        "stripe_checkout_session_id",
        "stripe_subscription_id",
        "checkout_started_at",
        "checkout_completed_at",
        "checkout_expired_at",
    )
    list_filter = ("status", "promo", "checkout_started_at")
    search_fields = (
        "promo__name",
        "promo__code_label",
        "user__email",
        "user__id",
        "event_id",
        "stripe_customer_id",
        "stripe_checkout_session_id",
        "stripe_subscription_id",
    )
    readonly_fields = (
        "promo",
        "user",
        "status",
        "event_id",
        "stripe_customer_id",
        "stripe_checkout_session_id",
        "stripe_subscription_id",
        "metadata",
        "checkout_started_at",
        "checkout_completed_at",
        "checkout_expired_at",
        "checkout_failed_at",
        "created_at",
        "updated_at",
    )

    def has_add_permission(self, request):
        return False


@admin.register(AddonEntitlement)
class AddonEntitlementAdmin(admin.ModelAdmin):
    list_display = (
        "owner_display",
        "price_id",
        "quantity",
        "task_credits_delta",
        "contact_cap_delta",
        "browser_task_daily_delta",
        "advanced_captcha_resolution_delta",
        "starts_at",
        "expires_at",
        "is_recurring",
        "created_at",
    )
    search_fields = ("price_id", "product_id", "user__email", "organization__name")
    list_filter = ("is_recurring",)
    readonly_fields = ("created_at", "updated_at")
    fields = (
        "user",
        "organization",
        "product_id",
        "price_id",
        "quantity",
        "task_credits_delta",
        "contact_cap_delta",
        "browser_task_daily_delta",
        "advanced_captcha_resolution_delta",
        "starts_at",
        "expires_at",
        "is_recurring",
        "created_via",
        "created_at",
        "updated_at",
    )

    @admin.display(description="Owner")
    def owner_display(self, obj):
        if obj.organization_id:
            return obj.organization
        return obj.user


@admin.register(AgentColor)
class AgentColorAdmin(admin.ModelAdmin):
    list_display = (
        "name",
        "hex_value",
        "preview",
        "sort_order",
        "is_active",
        "created_at",
        "updated_at",
    )
    list_editable = ("hex_value", "sort_order", "is_active")
    search_fields = ("name", "hex_value")
    list_filter = ("is_active",)
    ordering = ("sort_order", "name")
    readonly_fields = ("preview", "created_at", "updated_at")
    fields = ("name", "hex_value", "sort_order", "is_active", "preview", "created_at", "updated_at")

    @admin.display(description="Preview")
    def preview(self, obj):
        color = (obj.hex_value or "").strip() or "#000000"
        return format_html(
            '<span style="display:inline-flex;align-items:center;gap:0.5rem;">'
            '<span style="width:2.75rem;height:1.25rem;border-radius:0.35rem;border:1px solid rgba(15,23,42,0.18);background:{};"></span>'
            '<code style="font-size:0.85rem;">{}</code>'
            '</span>',
            color,
            color.upper(),
        )
@admin.register(MCPServerConfig)
class MCPServerConfigAdmin(admin.ModelAdmin):
    form = MCPServerConfigAdminForm
    list_display = ("name", "display_name", "auth_method", "is_active", "transport_summary", "updated_at")
    search_fields = ("name", "display_name", "description")
    list_filter = ("is_active", "auth_method")
    readonly_fields = ("scope", "created_at", "updated_at")
    fieldsets = (
        (None, {"fields": ("name", "display_name", "description", "auth_method", "is_active")}),
        (
            "Transport",
            {"fields": ("command", "command_args", "url", "prefetch_apps", "metadata")},
        ),
        ("Secrets", {"fields": ("environment", "headers")}),
        ("Metadata", {"fields": ("scope", "created_at", "updated_at"), "classes": ("collapse",)}),
    )

    def get_queryset(self, request):
        queryset = super().get_queryset(request)
        return queryset.filter(scope=MCPServerConfig.Scope.PLATFORM)

    def save_model(self, request, obj, form, change):
        obj.scope = MCPServerConfig.Scope.PLATFORM
        obj.organization = None
        obj.user = None
        super().save_model(request, obj, form, change)

    @admin.display(description="Transport", ordering="command")
    def transport_summary(self, obj):
        if obj.command:
            args = " ".join(obj.command_args or [])
            return f"{obj.command} {args}".strip()
        return obj.url or "—"

# Ownership filter reused across models
class OwnershipTypeFilter(SimpleListFilter):
    title = 'Ownership'
    parameter_name = 'ownership'

    def lookups(self, request, model_admin):
        return (
            ('user', 'User-owned'),
            ('org', 'Org-owned'),
        )

    def queryset(self, request, queryset):
        if self.value() == 'user':
            return queryset.filter(organization__isnull=True)
        if self.value() == 'org':
            return queryset.filter(organization__isnull=False)
        return queryset


class SoftExpirationFilter(SimpleListFilter):
    title = 'Soft-expiration'
    parameter_name = 'soft_expired'

    def lookups(self, request, model_admin):
        return (
            ('yes', 'Expired'),
            ('no', 'Not expired'),
        )

    def queryset(self, request, queryset):
        val = self.value()
        if val == 'yes':
            return queryset.filter(life_state='expired')
        if val == 'no':
            return queryset.exclude(life_state='expired')
        return queryset


# --- TASK CREDIT ADMIN (Optimized) ---
@admin.register(TaskCredit)
class TaskCreditAdmin(admin.ModelAdmin):
    list_display = (
        "owner_display",
        "credits",
        "credits_used",
        "available_credits",
        "plan",
        "grant_type",
        "granted_date",
        "expiration_date",
        "additional_task",
        "voided",
        "comments_preview",
    )
    list_filter = [
        OwnershipTypeFilter,
        "plan",
        "additional_task",
        "expiration_date",
        "granted_date",
        "grant_type",
        "voided",
    ]
    search_fields = ("user__email", "stripe_invoice_id", "user__id", "organization__name", "organization__id", "comments")
    readonly_fields = ("id", "stripe_invoice_id")
    raw_id_fields = ("user", "organization")
    ordering = ("-granted_date",)

    @admin.display(description='Comments')
    def comments_preview(self, obj):
        if obj.comments:
            return obj.comments[:50] + '...' if len(obj.comments) > 50 else obj.comments
        return '-'

    # Performance: avoid an extra query per row for the user column.
    list_select_related = ("user", "organization")

    # UX: allow quick navigation via calendar drill-down
    date_hierarchy = "granted_date"
    change_list_template = "admin/taskcredit_change_list.html"

    @admin.display(description='Owner')
    def owner_display(self, obj):
        if obj.organization_id:
            return f"Org: {obj.organization.name} ({obj.organization_id})"
        if obj.user_id:
            return f"User: {obj.user.email} ({obj.user_id})"
        return "-"

    def get_search_results(self, request, queryset, search_term):
        queryset, use_distinct = super().get_search_results(request, queryset, search_term)
        term = (search_term or "").strip()
        if term.isdigit():
            try:
                queryset = queryset | self.model.objects.filter(user_id=int(term))
                use_distinct = True
            except ValueError:
                # The term is numeric but not a valid integer (e.g., too large),
                # so we skip the exact user ID search. The default search might still find it.
                pass
        return queryset, use_distinct

    # ---------------- Custom admin view: Grant by Plan -----------------
    def get_urls(self):
        from django.urls import path
        urls = super().get_urls()
        custom = [
            path(
                'grant-by-plan/',
                self.admin_site.admin_view(self.grant_by_plan_view),
                name='api_taskcredit_grant_by_plan',
            ),
            path(
                'grant-by-user-ids/',
                self.admin_site.admin_view(self.grant_by_user_ids_view),
                name='api_taskcredit_grant_by_user_ids',
            ),
        ]
        return custom + urls

    def grant_by_plan_view(self, request):
        from django.apps import apps

        if not request.user.has_perm("api.add_taskcredit"):
            messages.error(request, "You do not have permission to grant task credits.")
            return HttpResponseRedirect(reverse("admin:api_taskcredit_changelist"))

        form = GrantPlanCreditsForm(request.POST or None)
        context = dict(self.admin_site.each_context(request))
        context.update({
            "opts": self.model._meta,
            "title": "Grant Credits by Plan",
            "form": form,
        })

        if request.method == "POST" and form.is_valid():
            plan = form.cleaned_data["plan"]
            credits = form.cleaned_data["credits"]
            grant_type = form.cleaned_data["grant_type"]
            grant_date = form.cleaned_data["grant_date"]
            expiration_date = form.cleaned_data["expiration_date"]
            dry_run = form.cleaned_data["dry_run"]
            only_zero = form.cleaned_data["only_if_out_of_credits"]
            export_csv = form.cleaned_data["export_csv"]

            # Resolve model lazily to avoid import cycles
            TaskCredit = apps.get_model("api", "TaskCredit")
            User = get_user_model()
            from util.subscription_helper import get_user_plan
            from constants.grant_types import GrantTypeChoices

            # Iterate active users and match plan
            matched_users = []
            for user in User.objects.filter(is_active=True).iterator():
                try:
                    up = get_user_plan(user)
                    if up and up.get("id") == plan:
                        matched_users.append(user)
                except Exception as e:
                    logging.warning("Failed to get plan for user %s: %s", user.id, e)
                    continue

            # Optionally filter to users currently out of credits
            if only_zero:
                from django.db.models import Sum, Q, Value
                from django.db.models.functions import Coalesce

                now = timezone.now()
                user_ids = [user.id for user in matched_users]

                users_with_zero_credits_ids = set(
                    User.objects.filter(id__in=user_ids)
                    .annotate(
                        available_credits_sum=Coalesce(
                            Sum(
                                "task_credits__available_credits",
                                filter=Q(
                                    task_credits__granted_date__lte=now,
                                    task_credits__expiration_date__gte=now,
                                    task_credits__voided=False,
                                ),
                            ),
                            Value(0),
                        )
                    )
                    .filter(available_credits_sum__lte=0)
                    .values_list('id', flat=True)
                )

                matched_users = [user for user in matched_users if user.id in users_with_zero_credits_ids]

            # Dry-run CSV export
            if dry_run and export_csv:
                import csv
                from django.http import HttpResponse
                from django.db.models import Sum
                from util.subscription_helper import get_user_plan
                now = timezone.now().strftime('%Y%m%d_%H%M%S')
                filename = f"grant_by_plan_dry_run_{plan}_{now}.csv"
                response = HttpResponse(content_type='text/csv')
                response['Content-Disposition'] = f'attachment; filename="{filename}"'
                writer = csv.writer(response)
                writer.writerow(["user_id", "email", "plan_id", "available_credits"])
                for user in matched_users:
                    total = TaskCredit.objects.filter(
                        user=user,
                        granted_date__lte=timezone.now(),
                        expiration_date__gte=timezone.now(),
                        voided=False,
                    ).aggregate(s=Sum('available_credits'))['s'] or 0
                    up = None
                    try:
                        up = get_user_plan(user)
                    except Exception:
                        up = None
                    plan_id = (up.get('id') if isinstance(up, dict) else None) or ''
                    writer.writerow([str(user.id), user.email or '', plan_id, total])
                return response

            created = 0
            if not dry_run:
                with transaction.atomic():
                    for user in matched_users:
                        TaskCredit.objects.create(
                            user=user,
                            credits=credits,
                            credits_used=0,
                            granted_date=grant_date,
                            expiration_date=expiration_date,
                            plan=PlanNamesChoices(plan),
                            grant_type=grant_type,
                            additional_task=False,
                            voided=False,
                        )
                        created += 1
                messages.success(request, f"Granted {credits} credits to {created} users on plan '{plan}'.")
            else:
                messages.info(request, f"Dry-run: would grant {credits} credits to {len(matched_users)} users on plan '{plan}'.")

            return HttpResponseRedirect(reverse("admin:api_taskcredit_changelist"))

        return TemplateResponse(request, "admin/grant_plan_credits.html", context)

    def grant_by_user_ids_view(self, request):
        from django.apps import apps

        if not request.user.has_perm("api.add_taskcredit"):
            messages.error(request, "You do not have permission to grant task credits.")
            return HttpResponseRedirect(reverse("admin:api_taskcredit_changelist"))

        form = GrantCreditsByUserIdsForm(request.POST or None)
        context = dict(self.admin_site.each_context(request))
        context.update({
            "opts": self.model._meta,
            "title": "Grant Credits to User IDs",
            "form": form,
        })

        if request.method == "POST" and form.is_valid():
            raw = form.cleaned_data['user_ids']
            credits = form.cleaned_data['credits']
            selected_plan = form.cleaned_data['plan']
            grant_type = form.cleaned_data['grant_type']
            grant_date = form.cleaned_data['grant_date']
            expiration_date = form.cleaned_data['expiration_date']
            dry_run = form.cleaned_data['dry_run']
            only_zero = form.cleaned_data['only_if_out_of_credits']
            export_csv = form.cleaned_data['export_csv']

            # Parse IDs by commas or newlines
            ids = [s for s in re.split(r"[\s,]+", raw.strip()) if s]

            TaskCredit = apps.get_model("api", "TaskCredit")
            User = get_user_model()

            # ids are integers; invalid tokens are ignored by the filter
            users = list(User.objects.filter(id__in=ids, is_active=True))

            if only_zero:
                from django.db.models import Sum, Q, Value
                from django.db.models.functions import Coalesce

                now = timezone.now()
                user_ids = [user.id for user in users]

                users_with_zero_credits_ids = set(
                    User.objects.filter(id__in=user_ids)
                    .annotate(
                        available_credits_sum=Coalesce(
                            Sum(
                                "task_credits__available_credits",
                                filter=Q(
                                    task_credits__granted_date__lte=now,
                                    task_credits__expiration_date__gte=now,
                                    task_credits__voided=False,
                                ),
                            ),
                            Value(0),
                        )
                    )
                    .filter(available_credits_sum__lte=0)
                    .values_list('id', flat=True)
                )

                users = [user for user in users if user.id in users_with_zero_credits_ids]

            # Dry-run CSV export
            if dry_run and export_csv:
                import csv
                from django.http import HttpResponse
                from django.db.models import Sum
                from util.subscription_helper import get_user_plan
                now = timezone.now().strftime('%Y%m%d_%H%M%S')
                filename = f"grant_by_user_ids_dry_run_{now}.csv"
                response = HttpResponse(content_type='text/csv')
                response['Content-Disposition'] = f'attachment; filename="{filename}"'
                writer = csv.writer(response)
                writer.writerow(["user_id", "email", "plan_id", "available_credits"])
                for user in users:
                    total = TaskCredit.objects.filter(
                        user=user,
                        granted_date__lte=timezone.now(),
                        expiration_date__gte=timezone.now(),
                        voided=False,
                    ).aggregate(s=Sum('available_credits'))['s'] or 0
                    up = None
                    try:
                        up = get_user_plan(user)
                    except Exception:
                        up = None
                    plan_id = (up.get('id') if isinstance(up, dict) else None) or ''
                    writer.writerow([str(user.id), user.email or '', plan_id, total])
                return response

            created = 0
            if not dry_run:
                with transaction.atomic():
                    for user in users:
                        # Use the selected plan for the TaskCredit record
                        plan_choice = PlanNamesChoices(selected_plan)
                        TaskCredit.objects.create(
                            user=user,
                            credits=credits,
                            credits_used=0,
                            granted_date=grant_date,
                            expiration_date=expiration_date,
                            plan=plan_choice,
                            grant_type=grant_type,
                            additional_task=False,
                            voided=False,
                        )
                        created += 1
                messages.success(request, f"Granted {credits} credits to {created} users.")
            else:
                messages.info(request, f"Dry-run: would grant {credits} credits to {len(users)} users.")

            return HttpResponseRedirect(reverse("admin:api_taskcredit_changelist"))

        return TemplateResponse(request, "admin/grant_user_ids_credits.html", context)


@admin.register(TaskCreditConfig)
class TaskCreditConfigAdmin(admin.ModelAdmin):
    list_display = ("default_task_cost", "updated_at")
    readonly_fields = ("singleton_id", "created_at", "updated_at")
    fieldsets = (
        (None, {"fields": ("default_task_cost",)}),
        ("Metadata", {"fields": ("singleton_id", "created_at", "updated_at")}),
    )

    def has_add_permission(self, request):
        if TaskCreditConfig.objects.exists():
            return False
        return super().has_add_permission(request)

    def has_delete_permission(self, request, obj=None):  # pragma: no cover - defensive guard
        return False


@admin.register(ReferralIncentiveConfig)
class ReferralIncentiveConfigAdmin(admin.ModelAdmin):
    list_display = (
        "referrer_direct_credits",
        "referred_direct_credits",
        "referrer_template_credits",
        "referred_template_credits",
        "direct_referral_cap",
        "template_referral_cap",
        "expiration_days",
        "updated_at",
    )
    readonly_fields = ("singleton_id", "created_at", "updated_at")
    fieldsets = (
        ("Direct Referral Credits", {
            "fields": ("referrer_direct_credits", "referred_direct_credits", "direct_referral_cap"),
        }),
        ("Template Referral Credits", {
            "fields": ("referrer_template_credits", "referred_template_credits", "template_referral_cap"),
        }),
        ("Expiration", {"fields": ("expiration_days",)}),
        ("Metadata", {"fields": ("singleton_id", "created_at", "updated_at")}),
    )

    def has_add_permission(self, request):
        if ReferralIncentiveConfig.objects.exists():
            return False
        return super().has_add_permission(request)

    def has_delete_permission(self, request, obj=None):  # pragma: no cover - defensive guard
        return False


@admin.register(ReferralGrant)
class ReferralGrantAdmin(admin.ModelAdmin):
    list_display = ("referral_type", "referrer_email", "referred_email", "granted_at")
    list_filter = ("referral_type",)
    search_fields = ("referrer__email", "referred__email", "template_code", "referrer__id", "referred__id")
    raw_id_fields = ("referrer", "referred", "referrer_task_credit", "referred_task_credit")
    readonly_fields = ("id", "granted_at", "created_at", "config_snapshot")
    list_select_related = ("referrer", "referred")

    def has_add_permission(self, request):
        return False

    def has_delete_permission(self, request, obj=None):  # pragma: no cover - audit log
        return False

    @admin.display(description="Referrer")
    def referrer_email(self, obj):
        return obj.referrer.email if obj.referrer else "-"

    @admin.display(description="Referred")
    def referred_email(self, obj):
        return obj.referred.email if obj.referred else "-"


@admin.register(UserReferral)
class UserReferralAdmin(admin.ModelAdmin):
    list_display = ("referral_code", "user_email", "created_at")
    search_fields = ("referral_code", "user__email", "user__id")
    readonly_fields = ("referral_code", "created_at")
    raw_id_fields = ("user",)
    ordering = ("-created_at",)

    @admin.display(description='User')
    def user_email(self, obj):
        return obj.user.email if obj.user else '-'


@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
    list_display = ("slug", "is_org", "is_active", "created_at", "updated_at")
    search_fields = ("slug",)
    list_filter = ("is_org", "is_active")
    readonly_fields = ("created_at", "updated_at")
    fields = ("slug", "is_org", "is_active", "created_at", "updated_at")


@admin.register(PlanVersion)
class PlanVersionAdmin(admin.ModelAdmin):
    list_display = (
        "plan",
        "version_code",
        "legacy_plan_code",
        "is_active_for_new_subs",
        "display_name",
        "effective_start_at",
        "effective_end_at",
        "updated_at",
    )
    search_fields = ("plan__slug", "version_code", "legacy_plan_code", "display_name")
    list_filter = ("plan", "is_active_for_new_subs")
    list_select_related = ("plan",)
    autocomplete_fields = ("plan",)
    readonly_fields = ("created_at", "updated_at")
    fieldsets = (
        (None, {"fields": ("plan", "version_code", "legacy_plan_code", "is_active_for_new_subs")}),
        ("Marketing", {"fields": ("display_name", "tagline", "description", "marketing_features")}),
        ("Availability", {"fields": ("effective_start_at", "effective_end_at")}),
        ("Metadata", {"fields": ("created_at", "updated_at")}),
    )


@admin.register(PlanVersionPrice)
class PlanVersionPriceAdmin(admin.ModelAdmin):
    list_display = (
        "plan_version",
        "kind",
        "billing_interval",
        "price_id",
        "product_id",
        "updated_at",
    )
    search_fields = (
        "price_id",
        "product_id",
        "plan_version__plan__slug",
        "plan_version__version_code",
        "plan_version__legacy_plan_code",
    )
    list_filter = ("kind", "billing_interval")
    list_select_related = ("plan_version",)
    autocomplete_fields = ("plan_version",)
    readonly_fields = ("created_at", "updated_at")
    fields = ("plan_version", "kind", "billing_interval", "price_id", "product_id", "created_at", "updated_at")


@admin.register(EntitlementDefinition)
class EntitlementDefinitionAdmin(admin.ModelAdmin):
    list_display = ("key", "display_name", "value_type", "unit", "updated_at")
    search_fields = ("key", "display_name", "description")
    list_filter = ("value_type",)
    readonly_fields = ("created_at", "updated_at")
    fields = ("key", "display_name", "description", "value_type", "unit", "created_at", "updated_at")


@admin.register(PlanVersionEntitlement)
class PlanVersionEntitlementAdmin(admin.ModelAdmin):
    list_display = ("plan_version", "entitlement", "value_summary", "currency", "updated_at")
    search_fields = (
        "plan_version__plan__slug",
        "plan_version__version_code",
        "plan_version__legacy_plan_code",
        "entitlement__key",
        "entitlement__display_name",
        "value_text",
    )
    list_filter = ("plan_version", "entitlement")
    list_select_related = ("plan_version", "entitlement")
    autocomplete_fields = ("plan_version", "entitlement")
    readonly_fields = ("created_at", "updated_at")
    fields = (
        "plan_version",
        "entitlement",
        "value_int",
        "value_decimal",
        "value_bool",
        "value_text",
        "value_json",
        "currency",
        "created_at",
        "updated_at",
    )

    @admin.display(description="Value")
    def value_summary(self, obj):
        if obj.value_int is not None:
            return obj.value_int
        if obj.value_decimal is not None:
            return obj.value_decimal
        if obj.value_bool is not None:
            return obj.value_bool
        if obj.value_text:
            return obj.value_text
        if obj.value_json is not None:
            return obj.value_json
        return ""


class DailyCreditConfigAdminForm(forms.ModelForm):
    apply_default_daily_credit_target_to_matching_agents = forms.BooleanField(
        required=False,
        help_text=(
            "Update agents that still match the previous default daily credit limit "
            "for this plan to the newly configured default."
        ),
    )

    class Meta:
        model = DailyCreditConfig
        fields = "__all__"


@admin.register(DailyCreditConfig)
class DailyCreditConfigAdmin(admin.ModelAdmin):
    form = DailyCreditConfigAdminForm
    list_display = (
        "plan_name",
        "default_daily_credit_target",
        "slider_min",
        "slider_max",
        "slider_step",
        "burn_rate_threshold_per_hour",
        "offpeak_burn_rate_threshold_per_hour",
        "burn_rate_window_minutes",
        "burn_rate_threshold_24h",
        "hard_limit_multiplier",
        "updated_at",
    )
    list_filter = ("plan_name",)
    readonly_fields = ("created_at", "updated_at")
    fieldsets = (
        (
            None,
            {
                "fields": (
                    "plan_name",
                    "default_daily_credit_target",
                    "apply_default_daily_credit_target_to_matching_agents",
                    "slider_min",
                    "slider_max",
                    "slider_step",
                )
            },
        ),
        (
            "Burn rate guidance",
            {
                "fields": (
                    "burn_rate_threshold_per_hour",
                    "offpeak_burn_rate_threshold_per_hour",
                    "burn_rate_window_minutes",
                    "burn_rate_threshold_24h",
                )
            },
        ),
        (
            "Hard limit",
            {"fields": ("hard_limit_multiplier",)},
        ),
        ("Metadata", {"fields": ("created_at", "updated_at")}),
    )

    def has_delete_permission(self, request, obj=None):  # pragma: no cover
        return False

    def save_model(self, request, obj, form, change):
        previous_config = None
        previous_settings_map = None
        apply_existing_defaults = bool(
            form.cleaned_data.get("apply_default_daily_credit_target_to_matching_agents")
        )
        if change and obj.pk:
            previous_config = DailyCreditConfig.objects.select_related("plan_version__plan").get(pk=obj.pk)
            if apply_existing_defaults:
                previous_settings_map = serialize_daily_credit_configs(
                    DailyCreditConfig.objects.select_related("plan_version__plan").all()
                )

        super().save_model(request, obj, form, change)

        if (
            not change
            or not apply_existing_defaults
            or "default_daily_credit_target" not in form.changed_data
            or previous_config is None
            or previous_settings_map is None
        ):
            return

        updated_count = apply_default_daily_credit_target_to_matching_agents(
            previous_config,
            previous_settings_map,
        )
        if updated_count:
            self.message_user(
                request,
                f"Updated {updated_count} agent(s) that still matched the previous default daily credit limit.",
                level=messages.SUCCESS,
            )
        else:
            self.message_user(
                request,
                "No agents matched the previous default daily credit limit for this configuration.",
                level=messages.INFO,
            )


@admin.register(BrowserConfig)
class BrowserConfigAdmin(admin.ModelAdmin):
    list_display = (
        "plan_name",
        "max_browser_steps",
        "max_browser_tasks",
        "max_active_browser_tasks",
        "vision_detail_level",
        "updated_at",
    )
    list_filter = ("plan_name",)
    readonly_fields = ("created_at", "updated_at")
    fieldsets = (
        (None, {"fields": ("plan_name",)}),
        (
            "Limits",
            {"fields": ("max_browser_steps", "max_browser_tasks", "max_active_browser_tasks")},
        ),
        ("Vision", {"fields": ("vision_detail_level",)}),
        ("Metadata", {"fields": ("created_at", "updated_at")}),
    )

    def has_delete_permission(self, request, obj=None):  # pragma: no cover
        return False


class ToolRateLimitInline(admin.TabularInline):
    model = ToolRateLimit
    extra = 0
    fields = ("tool_name", "max_calls_per_hour", "created_at", "updated_at")
    readonly_fields = ("created_at", "updated_at")
    ordering = ("tool_name",)


@admin.register(ToolConfig)
class ToolConfigAdmin(admin.ModelAdmin):
    list_display = (
        "plan_name",
        "min_cron_schedule_minutes",
        "search_web_result_count",
        "search_engine_batch_query_limit",
        "brightdata_amazon_product_search_limit",
        "duplicate_similarity_threshold",
        "tool_search_auto_enable_apps",
        "updated_at",
    )
    list_filter = ("plan_name",)
    readonly_fields = ("created_at", "updated_at")
    inlines = (ToolRateLimitInline,)
    change_list_template = "admin/toolconfig_change_list.html"
    fieldsets = (
        (None, {"fields": ("plan_name",)}),
        (
            "Schedules",
            {"fields": ("min_cron_schedule_minutes",)},
        ),
        (
            "Search web",
            {"fields": ("search_web_result_count", "search_engine_batch_query_limit")},
        ),
        (
            "Bright Data",
            {"fields": ("brightdata_amazon_product_search_limit",)},
        ),
        (
            "Duplicates",
            {"fields": ("duplicate_similarity_threshold",)},
        ),
        (
            "Tool search",
            {"fields": ("tool_search_auto_enable_apps",)},
        ),
        ("Metadata", {"fields": ("created_at", "updated_at")}),
    )

    def has_delete_permission(self, request, obj=None):  # pragma: no cover
        return False

    def get_urls(self):
        urls = super().get_urls()
        custom = [
            path(
                "enforce-min-schedule/",
                self.admin_site.admin_view(self.enforce_min_schedule_view),
                name="api_toolconfig_enforce_schedule",
            ),
        ]
        return custom + urls

    def enforce_min_schedule_view(self, request):
        """Enforce or dry-run minimum schedules for a given plan."""
        changelist_url = reverse("admin:api_toolconfig_changelist")
        base_context = {
            **self.admin_site.each_context(request),
            "title": "Enforce Minimum Cron Schedule",
            "plans": PlanNamesChoices.values,
            "selected_plan": None,
            "dry_run": True,
            "result": None,
        }

        if request.method != "POST":
            return TemplateResponse(
                request,
                "admin/toolconfig_enforce_min_schedule.html",
                base_context,
            )

        plan_name = (request.POST.get("plan_name") or "").strip() or PlanNamesChoices.FREE
        dry_run = request.POST.get("dry_run") == "on"
        min_minutes = tool_config_min_for_plan(plan_name)
        if min_minutes is None:
            self.message_user(
                request,
                f"No ToolConfig found for plan '{plan_name}'.",
                level=messages.ERROR,
            )
            return HttpResponseRedirect(changelist_url)

        base_context.update({"selected_plan": plan_name, "dry_run": dry_run})

        agents = agents_for_plan(plan_name)
        result = enforce_minimum_for_agents(
            agents,
            min_minutes,
            dry_run=dry_run,
            include_snapshots=True,
        )

        base_context["result"] = result

        status_level = messages.SUCCESS if not result.get("errors") else messages.WARNING
        summary_msg = (
            f"Scanned {result.get('scanned', 0)} agent(s); "
            f"would update {result.get('updated', 0)} schedule(s)"
        )
        if result.get("snapshot_updated"):
            summary_msg += f" and {result.get('snapshot_updated')} snapshot(s)"
        if dry_run:
            summary_msg = "[DRY RUN] " + summary_msg
        self.message_user(request, summary_msg, level=status_level)

        if result.get("errors"):
            self.message_user(
                request,
                f"Encountered {result['errors']} error(s) during enforcement.",
                level=messages.ERROR,
            )

        return TemplateResponse(
            request,
            "admin/toolconfig_enforce_min_schedule.html",
            base_context,
        )


@admin.register(PromptConfig)
class PromptConfigAdmin(admin.ModelAdmin):
    list_display = (
        "standard_prompt_token_budget",
        "premium_prompt_token_budget",
        "max_prompt_token_budget",
        "ultra_prompt_token_budget",
        "ultra_max_prompt_token_budget",
        "standard_message_history_limit",
        "premium_message_history_limit",
        "max_message_history_limit",
        "ultra_message_history_limit",
        "ultra_max_message_history_limit",
        "standard_tool_call_history_limit",
        "premium_tool_call_history_limit",
        "max_tool_call_history_limit",
        "ultra_tool_call_history_limit",
        "ultra_max_tool_call_history_limit",
        "standard_skill_prompt_limit",
        "premium_skill_prompt_limit",
        "max_skill_prompt_limit",
        "ultra_skill_prompt_limit",
        "ultra_max_skill_prompt_limit",
        "internal_reasoning_history_limit",
        "standard_enabled_tool_limit",
        "premium_enabled_tool_limit",
        "max_enabled_tool_limit",
        "ultra_enabled_tool_limit",
        "ultra_max_enabled_tool_limit",
        "standard_unified_history_limit",
        "premium_unified_history_limit",
        "max_unified_history_limit",
        "ultra_unified_history_limit",
        "ultra_max_unified_history_limit",
        "standard_unified_history_hysteresis",
        "premium_unified_history_hysteresis",
        "max_unified_history_hysteresis",
        "ultra_unified_history_hysteresis",
        "ultra_max_unified_history_hysteresis",
        "updated_at",
    )
    readonly_fields = ("singleton_id", "created_at", "updated_at")
    fieldsets = (
        (
            "Prompt token budgets",
            {
                "fields": (
                    "standard_prompt_token_budget",
                    "premium_prompt_token_budget",
                    "max_prompt_token_budget",
                    "ultra_prompt_token_budget",
                    "ultra_max_prompt_token_budget",
                )
            },
        ),
        (
            "Message history limits",
            {
                "fields": (
                    "standard_message_history_limit",
                    "premium_message_history_limit",
                    "max_message_history_limit",
                    "ultra_message_history_limit",
                    "ultra_max_message_history_limit",
                )
            },
        ),
        (
            "Tool call history limits",
            {
                "fields": (
                    "standard_tool_call_history_limit",
                    "premium_tool_call_history_limit",
                    "max_tool_call_history_limit",
                    "ultra_tool_call_history_limit",
                    "ultra_max_tool_call_history_limit",
                )
            },
        ),
        (
            "Skill prompt limits",
            {
                "fields": (
                    "standard_skill_prompt_limit",
                    "premium_skill_prompt_limit",
                    "max_skill_prompt_limit",
                    "ultra_skill_prompt_limit",
                    "ultra_max_skill_prompt_limit",
                )
            },
        ),
        (
            "Enabled tool limits",
            {
                "fields": (
                    "standard_enabled_tool_limit",
                    "premium_enabled_tool_limit",
                    "max_enabled_tool_limit",
                    "ultra_enabled_tool_limit",
                    "ultra_max_enabled_tool_limit",
                )
            },
        ),
        (
            "Unified history limits",
            {
                "fields": (
                    "standard_unified_history_limit",
                    "premium_unified_history_limit",
                    "max_unified_history_limit",
                    "ultra_unified_history_limit",
                    "ultra_max_unified_history_limit",
                    "internal_reasoning_history_limit",
                )
            },
        ),
        (
            "Unified history hysteresis",
            {
                "fields": (
                    "standard_unified_history_hysteresis",
                    "premium_unified_history_hysteresis",
                    "max_unified_history_hysteresis",
                    "ultra_unified_history_hysteresis",
                    "ultra_max_unified_history_hysteresis",
                )
            },
        ),
        ("Metadata", {"fields": ("singleton_id", "created_at", "updated_at")}),
    )

    def has_add_permission(self, request):
        if PromptConfig.objects.exists():
            return False
        return super().has_add_permission(request)

    def has_delete_permission(self, request, obj=None):  # pragma: no cover
        return False


@admin.register(ToolCreditCost)
class ToolCreditCostAdmin(admin.ModelAdmin):
    list_display = ("tool_name", "credit_cost", "updated_at")
    search_fields = ("tool_name",)
    list_filter = ("updated_at",)
    readonly_fields = ("created_at", "updated_at")
    fieldsets = (
        (None, {"fields": ("tool_name", "credit_cost")}),
        ("Metadata", {"fields": ("created_at", "updated_at")}),
    )


@admin.register(MeteringBatch)
class MeteringBatchAdmin(admin.ModelAdmin):
    list_display = (
        "batch_key",
        "user",
        "rounded_quantity",
        "total_credits",
        "period_start",
        "period_end",
        "stripe_event_id",
        "created_at",
    )
    search_fields = (
        "batch_key",
        "idempotency_key",
        "stripe_event_id",
        "user__email",
        "user__id",
    )
    list_filter = ("period_start", "period_end", "created_at")
    date_hierarchy = "created_at"
    readonly_fields = ("id", "batch_key", "idempotency_key", "created_at", "updated_at", "usage_links")
    raw_id_fields = ("user",)
    ordering = ("-created_at",)

    @admin.display(description="Usage Rows")
    def usage_links(self, obj):
        try:
            tasks_count = BrowserUseAgentTask.objects.filter(meter_batch_key=obj.batch_key).count()
            steps_count = PersistentAgentStep.objects.filter(meter_batch_key=obj.batch_key).count()
        except Exception:
            tasks_count = 0
            steps_count = 0

        tasks_url = (
            reverse("admin:api_browseruseagenttask_changelist") + f"?meter_batch_key__exact={obj.batch_key}"
        )
        steps_url = (
            reverse("admin:api_persistentagentstep_changelist") + f"?meter_batch_key__exact={obj.batch_key}"
        )
        return format_html(
            '<a href="{}">Tasks: {}</a> &nbsp;|&nbsp; <a href="{}">Steps: {}</a>',
            tasks_url, tasks_count, steps_url, steps_count
        )


def _organization_invite_status(invite):
    if invite.accepted_at is not None:
        return "Accepted"
    if invite.revoked_at is not None:
        return "Revoked"
    if invite.expires_at < timezone.now():
        return "Expired"
    return "Pending"


def _approve_organization_invite(invite):
    """Approve a pending invite by creating/reactivating the matching user's membership."""
    if invite.accepted_at is not None:
        return "already_accepted"
    if invite.revoked_at is not None:
        return "revoked"
    if invite.expires_at < timezone.now():
        return "expired"

    user = get_user_model().objects.filter(email__iexact=invite.email).first()
    if user is None:
        return "missing_user"

    with transaction.atomic():
        invite = (
            OrganizationInvite.objects
            .select_for_update()
            .select_related("org")
            .get(pk=invite.pk)
        )
        if invite.accepted_at is not None:
            return "already_accepted"
        if invite.revoked_at is not None:
            return "revoked"
        if invite.expires_at < timezone.now():
            return "expired"

        membership, created = OrganizationMembership.objects.get_or_create(
            org=invite.org,
            user=user,
            defaults={
                "role": invite.role,
                "status": OrganizationMembership.OrgStatus.ACTIVE,
            },
        )
        if not created and (
            membership.status != OrganizationMembership.OrgStatus.ACTIVE
            or membership.role != invite.role
        ):
            membership.status = OrganizationMembership.OrgStatus.ACTIVE
            membership.role = invite.role
            membership.save(update_fields=["status", "role"])

        invite.accepted_at = timezone.now()
        invite.save(update_fields=["accepted_at"])

    return "approved"


class OrganizationMembershipInline(admin.TabularInline):
    model = OrganizationMembership
    extra = 0
    autocomplete_fields = ("user",)
    fields = ("user", "role", "status")
    show_change_link = True

    def get_queryset(self, request):
        return super().get_queryset(request).select_related("user")


class OrganizationInviteInline(admin.TabularInline):
    model = OrganizationInvite
    extra = 0
    fields = (
        "email",
        "role",
        "approval_status",
        "approve_link",
        "invited_by",
        "sent_at",
        "expires_at",
        "accepted_at",
        "revoked_at",
    )
    readonly_fields = fields
    show_change_link = True

    def has_add_permission(self, request, obj=None):
        return False

    def get_queryset(self, request):
        return super().get_queryset(request).select_related("invited_by")

    @admin.display(description="Status")
    def approval_status(self, obj):
        return _organization_invite_status(obj)

    @admin.display(description="Approve")
    def approve_link(self, obj):
        if obj is None or obj.pk is None:
            return "-"
        if _organization_invite_status(obj) != "Pending":
            return "-"
        url = reverse("admin:api_organizationinvite_approve", args=[obj.pk])
        return format_html('<a class="button" href="{}">Approve</a>', url)


# Minimal admin for Organization to enable autocomplete/search
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
    search_fields = ("name", "slug")
    list_display = ("name", "slug", "is_active", "created_at")
    list_filter = ("is_active", "plan")
    inlines = (OrganizationMembershipInline, OrganizationInviteInline)


@admin.register(OrganizationMembership)
class OrganizationMembershipAdmin(admin.ModelAdmin):
    list_select_related = ("org", "user")
    autocomplete_fields = ("org", "user")
    list_display = ("org", "user", "role", "status")
    list_filter = ("role", "status")
    search_fields = ("org__name", "org__id", "user__email", "user__username", "user__id")
    actions = ("mark_members_active", "mark_members_removed")

    @admin.action(description="Mark selected memberships active")
    def mark_members_active(self, request, queryset):
        updated = queryset.update(status=OrganizationMembership.OrgStatus.ACTIVE)
        self.message_user(request, f"Marked {updated} membership(s) active.", level=messages.SUCCESS)

    @admin.action(description="Mark selected memberships removed")
    def mark_members_removed(self, request, queryset):
        updated = queryset.update(status=OrganizationMembership.OrgStatus.REMOVED)
        self.message_user(request, f"Marked {updated} membership(s) removed.", level=messages.SUCCESS)


@admin.register(OrganizationInvite)
class OrganizationInviteAdmin(admin.ModelAdmin):
    list_select_related = ("org", "invited_by")
    autocomplete_fields = ("org", "invited_by")
    list_display = ("org", "email", "role", "approval_status", "invited_by", "sent_at", "expires_at")
    list_filter = ("role", "accepted_at", "revoked_at", "expires_at")
    search_fields = ("org__name", "org__id", "email", "invited_by__email", "token")
    readonly_fields = ("token", "sent_at", "accepted_at", "revoked_at", "approval_status", "approve_link")
    fields = (
        "org",
        "email",
        "role",
        "approval_status",
        "approve_link",
        "token",
        "expires_at",
        "sent_at",
        "invited_by",
        "accepted_at",
        "revoked_at",
    )
    actions = ("approve_selected_pending_invites",)

    def has_add_permission(self, request):
        return False

    def get_urls(self):
        custom_urls = [
            path(
                "<path:object_id>/approve/",
                self.admin_site.admin_view(self.approve_invite_view),
                name="api_organizationinvite_approve",
            ),
        ]
        return custom_urls + super().get_urls()

    @admin.display(description="Status")
    def approval_status(self, obj):
        return _organization_invite_status(obj)

    @admin.display(description="Approve")
    def approve_link(self, obj):
        if obj is None or obj.pk is None:
            return "-"
        if _organization_invite_status(obj) != "Pending":
            return "-"
        url = reverse("admin:api_organizationinvite_approve", args=[obj.pk])
        return format_html('<a class="button" href="{}">Approve</a>', url)

    def approve_invite_view(self, request, object_id):
        invite = self.get_object(request, object_id)
        if invite is None:
            self.message_user(request, "Organization invite not found.", level=messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_organizationinvite_changelist"))
        if not self.has_change_permission(request, invite):
            raise PermissionDenied

        result = _approve_organization_invite(invite)
        self._message_approval_result(request, invite, result)
        redirect_to = request.META.get("HTTP_REFERER") or reverse("admin:api_organizationinvite_changelist")
        return HttpResponseRedirect(redirect_to)

    @admin.action(description="Approve selected pending invites")
    def approve_selected_pending_invites(self, request, queryset):
        approved = 0
        missing_user = 0
        skipped = 0
        for invite in queryset.select_related("org"):
            result = _approve_organization_invite(invite)
            if result == "approved":
                approved += 1
            elif result == "missing_user":
                missing_user += 1
            else:
                skipped += 1

        level = messages.SUCCESS if missing_user == 0 and skipped == 0 else messages.WARNING
        self.message_user(
            request,
            (
                f"Approved {approved} invite(s). "
                f"Missing user account: {missing_user}. Skipped: {skipped}."
            ),
            level=level,
        )

    def _message_approval_result(self, request, invite, result):
        if result == "approved":
            self.message_user(request, f"Approved invite for {invite.email}.", level=messages.SUCCESS)
            return
        if result == "missing_user":
            self.message_user(
                request,
                f"No user account exists for {invite.email}; create the user first, then approve the invite.",
                level=messages.WARNING,
            )
            return
        self.message_user(request, f"Invite for {invite.email} was not approved ({result}).", level=messages.WARNING)

# --- TASKS INSIDE AGENT (BrowserUseAgent) ---
class BrowserUseAgentTaskInline(admin.TabularInline):
    model = BrowserUseAgentTask
    extra = 0
    fields = ("id", "prompt_summary", "status", "created_at", "view_task_link")
    readonly_fields = ("id", "prompt_summary", "status", "created_at", "view_task_link")
    show_change_link = False # Using a custom link

    # Limit the number of tasks displayed inline to avoid rendering thousands of rows.
    MAX_DISPLAY = 50  # Show the 50 most-recent tasks only

    def get_queryset(self, request):
        """
        Return only the most recent MAX_DISPLAY tasks for the parent agent,
        avoiding issues with admin filtering.
        """
        # Get the full queryset of all tasks
        qs = super().get_queryset(request)
        
        # Extract parent object_id from the URL
        object_id = request.resolver_match.kwargs.get("object_id")
        
        if not object_id:
            # We are on an add page, no parent object yet
            return qs.none()
        
        # Filter tasks for the specific parent agent
        qs = qs.filter(agent__pk=object_id)
        
        # Order by creation date to get the most recent
        qs = qs.order_by('-created_at')
        
        # Get the primary keys of the most recent N tasks
        recent_pks = list(qs.values_list('pk', flat=True)[:self.MAX_DISPLAY])
        
        # Return a new, unsliced queryset filtered by those specific pks.
        # This is the safe way to limit results in an admin inline.
        return self.model.objects.filter(pk__in=recent_pks).order_by('-created_at')

    def prompt_summary(self, obj):
        # obj is BrowserUseAgentTask instance
        if obj.prompt:
            return (obj.prompt[:75] + '...') if len(obj.prompt) > 75 else obj.prompt
        return "-"
    prompt_summary.short_description = "Prompt"

    def view_task_link(self, obj):
        # obj is BrowserUseAgentTask instance
        if obj.pk:
            url = reverse("admin:api_browseruseagenttask_change", args=[obj.pk])
            return format_html('<a href="{}">View/Edit Task</a>', url)
        return "-"
    view_task_link.short_description = "Link to Task"

    def has_add_permission(self, request, obj=None):
        return False

    def has_delete_permission(self, request, obj=None):
        return False

# Link to the Task changelist, filtered to this agent, for Agent list display
def tasks_for_agent_link(obj):
    # obj here is a BrowserUseAgent instance
    url = (
        reverse("admin:api_browseruseagenttask_changelist")
        + f"?agent__id__exact={obj.pk}"
    )
    # Prefer annotated count if present to avoid an extra query per row.
    count = getattr(obj, "num_tasks", None)
    if count is None:
        count = obj.tasks.count()
    return format_html('<a href="{}">{} Tasks</a>', url, count)
tasks_for_agent_link.short_description = "Tasks (Filtered List)"

@admin.register(BrowserUseAgent)
class BrowserUseAgentAdmin(admin.ModelAdmin):
    list_display = ("id", "name", "user_email_display", tasks_for_agent_link, "persistent_agent_link", "created_at", "updated_at")
    search_fields = ("name", "user__email", "id") 
    readonly_fields = ("id", "created_at", "updated_at", "persistent_agent_link", "tasks_summary_link")
    list_filter = ("user",) 
    raw_id_fields = ('user',)
    inlines = [BrowserUseAgentTaskInline] # Added inline for tasks

    # ------------------------------------------------------------------
    # Performance: annotate task counts & use select_related to reduce queries
    # ------------------------------------------------------------------
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        return qs.select_related('user').annotate(num_tasks=Count('tasks'))

    fieldsets = (
        ('Basic Information', {
            'fields': ('id', 'name', 'user', 'created_at', 'updated_at')
        }),
        ('Configuration', {
            'fields': ('preferred_proxy',)
        }),
        ('Relationships', {
            'fields': ('persistent_agent_link', 'tasks_summary_link')
        }),
    )

    # ------------------------------------------------------------------
    # Read-only summary + link to full task list (detail page)
    # ------------------------------------------------------------------
    @admin.display(description="Tasks")
    def tasks_summary_link(self, obj):
        url = (
            reverse("admin:api_browseruseagenttask_changelist")
            + f"?agent__id__exact={obj.pk}"
        )

        count = getattr(obj, "num_tasks", None)
        if count is None:
            count = obj.tasks.count()

        return format_html(
            '<a href="{}">View all&nbsp;{} tasks</a>', url, count
        )

    @admin.display(description='User Email')
    def user_email_display(self, obj):
        if obj.user:
            return obj.user.email
        return None # Or some placeholder if user can be None (not in this model)

    @admin.display(description='Persistent Agent')
    def persistent_agent_link(self, obj):
        """Link to the associated persistent agent (if any)."""
        try:
            pa = obj.persistent_agent  # May raise PersistentAgent.DoesNotExist
        except obj._meta.get_field('persistent_agent').related_model.DoesNotExist:  # type: ignore[attr-defined]
            pa = None

        if pa:
            url = reverse("admin:api_persistentagent_change", args=[pa.pk])
            return format_html('<a href="{}">{}</a>', url, pa.name)
        return format_html('<span style="color: gray;">{}</span>', "None")
    persistent_agent_link.admin_order_field = 'persistent_agent__name'

# --- STEPS INSIDE TASK (BrowserUseAgentTask) ---
class BrowserUseAgentTaskStepInline(admin.TabularInline):
    model = BrowserUseAgentTaskStep
    extra = 0
    fields = ('step_number', 'description_summary', 'is_result', 'result_value_summary', 'view_step_link')
    readonly_fields = ('step_number', 'description_summary', 'is_result', 'result_value_summary', 'view_step_link')
    show_change_link = False

    # Limit the number of steps displayed inline to avoid rendering thousands of rows.
    MAX_DISPLAY = 50

    def get_queryset(self, request):
        """
        Return only the most recent MAX_DISPLAY steps for the parent task,
        avoiding issues with admin filtering.
        """
        # Get the full queryset of all steps
        qs = super().get_queryset(request)
        
        # Extract parent object_id from the URL
        object_id = request.resolver_match.kwargs.get("object_id")
        
        if not object_id:
            # We are on an add page, no parent object yet
            return qs.none()
            
        # Filter steps for the specific parent task
        qs = qs.filter(task__pk=object_id)
        
        # Order by step number to get the most recent
        qs = qs.order_by('-step_number')
        
        # Get the primary keys of the most recent N steps
        recent_pks = list(qs.values_list('pk', flat=True)[:self.MAX_DISPLAY])
        
        # Return a new, unsliced queryset filtered by those specific pks.
        return self.model.objects.filter(pk__in=recent_pks).order_by('-step_number')

    def description_summary(self, obj):
        if obj.description:
            return (obj.description[:75] + '...') if len(obj.description) > 75 else obj.description
        return "-"
    description_summary.short_description = "Description"

    def result_value_summary(self, obj):
        if obj.result_value:
            # Simple string representation for summary; can be expanded
            value_str = str(obj.result_value)
            return (value_str[:75] + '...') if len(value_str) > 75 else value_str
        return "-"
    result_value_summary.short_description = "Result Value"

    def view_step_link(self, obj):
        if obj.pk:
            url = reverse("admin:api_browseruseagenttaskstep_change", args=[obj.pk])
            return format_html('<a href="{}">View/Edit Step</a>', url)
        return "-"
    view_step_link.short_description = "Link to Step"

    def has_add_permission(self, request, obj=None):
        return False

    def has_delete_permission(self, request, obj=None):
        return False

@admin.register(BrowserUseAgentTask)
class BrowserUseAgentTaskAdmin(admin.ModelAdmin):
    change_list_template = "admin/browseruseagenttask_change_list.html"

    list_display = ('id', 'get_agent_name', 'get_user_email', 'status', 'credits_cost', 'display_task_result_summary', 'created_at', 'updated_at')
    list_filter = ('status', 'user', 'agent', 'meter_batch_key', 'metered')
    search_fields = ('id', 'agent__name', 'user__email')
    readonly_fields = ('id', 'created_at', 'updated_at', 'display_full_task_result', 'credits_cost') # Show charged credits
    raw_id_fields = ('agent', 'user')
    inlines = [BrowserUseAgentTaskStepInline] # Added inline for steps

    def get_queryset(self, request):
        """Optimize with select_related to prevent N+1 queries."""
        qs = super().get_queryset(request)
        return qs.select_related("agent", "user")

    def get_agent_name(self, obj):
        return obj.agent.name if obj.agent else None
    get_agent_name.short_description = 'Agent Name'
    get_agent_name.admin_order_field = 'agent__name'

    def get_user_email(self, obj):
        return obj.user.email if obj.user else None
    get_user_email.short_description = 'User Email'
    get_user_email.admin_order_field = 'user__email'

    def display_task_result_summary(self, obj):
        # obj is BrowserUseAgentTask instance
        result_step = obj.steps.filter(is_result=True).first()
        if result_step:
            if result_step.result_value:
                return format_html("<b>Result:</b> Present <small>(Step {})</small>", result_step.step_number)
            else:
                return format_html("<span style='color: orange;'>Result: Empty (Step {})</span>", result_step.step_number)
        return "No Result Step"
    display_task_result_summary.short_description = "Task Result Summary"

    def display_full_task_result(self, obj):
        # obj is BrowserUseAgentTask instance
        result_step = obj.steps.filter(is_result=True).first()
        if result_step:
            if result_step.result_value:
                import json # For pretty printing
                try:
                    # Attempt to pretty-print if it's JSON, otherwise just stringify
                    pretty_result = json.dumps(result_step.result_value, indent=2, sort_keys=True)
                    return format_html("<pre>Step {}:<br>{}</pre>", result_step.step_number, pretty_result)
                except (TypeError, ValueError):
                     return format_html("Step {}:<br>{}", result_step.step_number, str(result_step.result_value))
            else:
                return format_html("Step {} marked as result, but <code>result_value</code> is empty.", result_step.step_number)
        return "No step is marked as the result for this task."
    display_full_task_result.short_description = "Task Result Details"

    # ------------------------------------------------------------------
    #  Custom view + button: Run Garbage Collection
    # ------------------------------------------------------------------
    def get_urls(self):
        urls = super().get_urls()
        custom_urls = [
            path(
                'run-gc/',
                self.admin_site.admin_view(self.run_gc_view),
                name='api_browseruseagenttask_run_gc',
            ),
        ]
        return custom_urls + urls

    @admin.display(description="Run GC")
    def run_gc_button(self, obj=None):
        """Display a fixed Run GC button in admin changelist (object-tools)."""
        url = reverse('admin:api_browseruseagenttask_run_gc')
        return format_html('<a class="button" href="{}">🗑️ Run&nbsp;Garbage&nbsp;Collection</a>', url)

    def run_gc_view(self, request, *args, **kwargs):
        """Admin view that queues the garbage collection task and redirects back."""
        try:
            garbage_collect_timed_out_tasks.delay()
            self.message_user(request, "Garbage-collection task queued – refresh in a minute.", messages.SUCCESS)
        except Exception as e:
            self.message_user(request, f"Error queuing garbage collection: {e}", messages.ERROR)
        # Redirect back to the changelist
        changelist_url = reverse('admin:api_browseruseagenttask_changelist')
        return HttpResponseRedirect(changelist_url)

@admin.register(BrowserUseAgentTaskStep)
class BrowserUseAgentTaskStepAdmin(admin.ModelAdmin):
    list_display = ("id", "task", "step_number", "is_result", "created_at")
    list_filter = ("is_result", "created_at")
    search_fields = ("task__id", "description")
    ordering = ("-created_at",)

@admin.register(PaidPlanIntent)
class PaidPlanIntentAdmin(admin.ModelAdmin):
    list_display = ("user", "plan_name", "requested_at")
    list_filter = ("plan_name", "requested_at")
    search_fields = ("user__email", "user__username")
    ordering = ("-requested_at",)
    readonly_fields = ("requested_at", "id")


@admin.register(UsageThresholdSent)
class UsageThresholdSentAdmin(admin.ModelAdmin):
    list_display = ("user", "period_ym", "threshold", "plan_limit", "sent_at")
    list_filter = ("threshold",)
    search_fields = ("user__email", "user__id", "period_ym")
    date_hierarchy = "sent_at"
    readonly_fields = ("user", "period_ym", "threshold", "plan_limit", "sent_at")
    ordering = ("-sent_at",)

    def has_add_permission(self, request):
        return False

    def has_change_permission(self, request, obj=None):
        return False


# --- TASK CREDITS INSIDE USER (CustomUserAdmin) ---
class TaskCreditInlineForUser(admin.TabularInline):
    model = TaskCredit
    extra = 0
    fields = ("credits", "credits_used", "remaining_display", "plan", "granted_date", "expiration_date", "additional_task")
    readonly_fields = ("remaining_display", "granted_date")
    ordering = ("-granted_date",)
    
    def remaining_display(self, obj):
        return obj.remaining
    remaining_display.short_description = "Remaining"

# --- AGENTS INSIDE USER (CustomUserAdmin) ---
class BrowserUseAgentInlineForUser(admin.TabularInline):
    model = BrowserUseAgent
    extra = 0
    fields = ("name", "created_at", "tasks_for_this_agent_link", "view_agent_link")
    readonly_fields = ("name", "created_at", "tasks_for_this_agent_link", "view_agent_link")
    show_change_link = False # Using custom link

    def tasks_for_this_agent_link(self, obj):
        # obj here is a BrowserUseAgent instance
        if obj.pk:
            url = (
                reverse("admin:api_browseruseagenttask_changelist")
                + f"?agent__id__exact={obj.pk}"
            )
            return format_html('<a href="{}">View Tasks</a>', url)
        return "N/A (Agent not saved)"
    tasks_for_this_agent_link.short_description = "Tasks"

    def view_agent_link(self, obj):
        # obj here is a BrowserUseAgent instance
        if obj.pk:
            url = reverse("admin:api_browseruseagent_change", args=[obj.pk])
            return format_html('<a href="{}">View/Edit {}</a>', url, obj.name or "Agent")
        return "N/A (Agent not saved)"
    view_agent_link.short_description = "Agent Page"

    def has_add_permission(self, request, obj=None):
        return False 
    def has_delete_permission(self, request, obj=None):
        return False


class UserFlagsInlineForUser(admin.StackedInline):
    model = UserFlags
    extra = 1
    max_num = 1
    can_delete = True
    fields = ("is_vip", "is_freemium_grandfathered")


class UserReferralInlineForUser(admin.StackedInline):
    model = UserReferral
    extra = 0
    max_num = 1
    can_delete = False
    readonly_fields = ("referral_code", "created_at")
    fields = ("referral_code", "created_at")


class UserPreferenceAdminForm(forms.ModelForm):
    timezone = forms.CharField(
        required=False,
        label="Timezone",
        help_text="IANA timezone (for example: America/New_York). Leave blank to keep it unset.",
    )

    class Meta:
        model = UserPreference
        fields = "__all__"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.instance and getattr(self.instance, "pk", None) and self.instance.user_id:
            self.fields["timezone"].initial = UserPreference.resolve_user_timezone(
                self.instance.user,
                fallback_to_utc=False,
            )

    def clean_timezone(self):
        timezone_value = self.cleaned_data.get("timezone", "")
        return UserPreference.normalize_user_timezone_value(timezone_value)


@admin.register(UserPreference)
class UserPreferenceAdmin(admin.ModelAdmin):
    form = UserPreferenceAdminForm
    list_display = ("user", "timezone_display", "updated_at")
    search_fields = ("user__email", "user__id")
    readonly_fields = ("created_at", "updated_at")
    fields = ("user", "timezone", "preferences", "created_at", "updated_at")

    @admin.display(description="Timezone")
    def timezone_display(self, obj):
        if not obj.user_id:
            return ""
        return UserPreference.resolve_user_timezone(obj.user, fallback_to_utc=False)

    def save_model(self, request, obj, form, change):
        timezone_value = form.cleaned_data.get("timezone", "")
        current_preferences = obj.preferences if isinstance(obj.preferences, dict) else {}
        updated_preferences = dict(current_preferences)
        updated_preferences[UserPreference.KEY_USER_TIMEZONE] = timezone_value
        obj.preferences = updated_preferences
        super().save_model(request, obj, form, change)


@admin.register(UserIdentitySignal)
class UserIdentitySignalAdmin(admin.ModelAdmin):
    list_display = (
        "user",
        "signal_type",
        "signal_value",
        "first_seen_source",
        "last_seen_source",
        "last_seen_at",
        "observation_count",
    )
    search_fields = ("user__email", "user__id", "signal_value")
    list_filter = ("signal_type", "first_seen_source", "last_seen_source")
    readonly_fields = (
        "user",
        "signal_type",
        "signal_value",
        "first_seen_at",
        "last_seen_at",
        "first_seen_source",
        "last_seen_source",
        "observation_count",
        "created_at",
        "updated_at",
    )

    def has_add_permission(self, request):
        return False


@admin.register(UserFingerprintVisit)
class UserFingerprintVisitAdmin(admin.ModelAdmin):
    change_form_template = "admin/userfingerprintvisit_change_form.html"
    list_display = (
        "user",
        "source",
        "fingerprint_event_id",
        "fingerprint_visitor_id",
        "fetch_status",
        "country_code",
        "suspect_score",
        "vpn",
        "proxy",
        "tor",
        "tampering",
        "high_activity_device",
        "event_timestamp",
        "fetched_at",
    )
    search_fields = (
        "user__email",
        "user__id",
        "fingerprint_event_id",
        "fingerprint_server_event_id",
        "fingerprint_visitor_id",
        "asn_name",
    )
    list_filter = (
        "fetch_status",
        "source",
        "vpn",
        "proxy",
        "tor",
        "tampering",
        "high_activity_device",
        "country_code",
    )
    readonly_fields = (
        "user",
        "source",
        "fingerprint_event_id",
        "fingerprint_server_event_id",
        "fingerprint_visitor_id",
        "fetch_status",
        "fetch_attempt_count",
        "last_fetch_attempt_at",
        "fetched_at",
        "error_message",
        "event_timestamp",
        "visitor_first_seen_at",
        "replayed",
        "visitor_found",
        "visitor_confidence_score",
        "suspect_score",
        "bot",
        "vpn",
        "vpn_confidence",
        "proxy",
        "proxy_confidence",
        "proxy_type",
        "tor",
        "tampering",
        "tampering_confidence",
        "tampering_ml_score",
        "high_activity_device",
        "ip_blocklist_email_spam",
        "ip_blocklist_attack_source",
        "ip_blocklist_tor_node",
        "datacenter",
        "asn",
        "asn_name",
        "asn_type",
        "country_code",
        "country_name",
        "subdivision_name",
        "city_name",
        "timezone",
        "ip_address",
        "browser_name",
        "browser_major_version",
        "browser_full_version",
        "os",
        "os_version",
        "device",
        "sdk",
        "velocity",
        "vpn_methods",
        "tampering_details",
        "raw_payload",
        "created_at",
        "updated_at",
    )
    fields = readonly_fields

    def get_urls(self):
        urls = super().get_urls()
        custom_urls = [
            path(
                "<path:object_id>/fetch/",
                self.admin_site.admin_view(self.fetch_view),
                name="api_userfingerprintvisit_fetch",
            ),
        ]
        return custom_urls + urls

    def fetch_view(self, request, object_id, *args, **kwargs):
        visit = self.get_object(request, object_id)
        if visit is None:
            self.message_user(request, "Fingerprint visit not found.", level=messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_userfingerprintvisit_changelist"))
        if not self.has_change_permission(request, visit):
            raise PermissionDenied
        if request.method != "POST":
            return HttpResponseRedirect(reverse("admin:api_userfingerprintvisit_change", args=[visit.pk]))

        try:
            queued = enqueue_user_fingerprint_visit_refresh(visit)
        except (FingerprintConfigurationError, FingerprintTerminalError) as exc:
            self.message_user(request, str(exc), level=messages.ERROR)
        else:
            if queued:
                self.message_user(request, "Fingerprint fetch queued.", level=messages.SUCCESS)
            else:
                self.message_user(
                    request,
                    "Fingerprint fetch is already in progress.",
                    level=messages.INFO,
                )

        return HttpResponseRedirect(reverse("admin:api_userfingerprintvisit_change", args=[visit.pk]))

    def has_add_permission(self, request):
        return False


@admin.register(UserTrialEligibility)
class UserTrialEligibilityAdmin(admin.ModelAdmin):
    list_display = (
        "user",
        "user_id_display",
        "sign_up_date_display",
        "effective_status_display",
        "auto_status",
        "reason_display",
        "manual_action",
        "evaluated_at",
        "reviewed_by",
        "reviewed_at",
    )
    search_fields = ("user__email", "user__id", "review_note")
    list_filter = ("auto_status", "manual_action", "reviewed_at")
    readonly_fields = (
        "effective_status_display",
        "auto_status",
        "reason_codes",
        "evidence_summary",
        "evaluated_at",
        "reviewed_by",
        "reviewed_at",
        "created_at",
        "updated_at",
    )
    fields = (
        "user",
        "effective_status_display",
        "auto_status",
        "manual_action",
        "reason_codes",
        "evidence_summary",
        "review_note",
        "evaluated_at",
        "reviewed_by",
        "reviewed_at",
        "created_at",
        "updated_at",
    )

    @admin.display(description="Effective Status")
    def effective_status_display(self, obj):
        if obj is None:
            return "-"
        return obj.effective_status

    @admin.display(description="User Id", ordering="user_id")
    def user_id_display(self, obj):
        if obj is None:
            return "-"
        return obj.user_id

    @admin.display(description="Sign Up Date", ordering="user__date_joined")
    def sign_up_date_display(self, obj):
        if obj is None:
            return "-"
        return obj.user.date_joined

    @admin.display(description="Reason")
    def reason_display(self, obj):
        if obj is None or not obj.reason_codes:
            return "-"
        return ", ".join(obj.reason_codes)

    def save_model(self, request, obj, form, change):
        if change and form.changed_data:
            obj.reviewed_by = request.user
            obj.reviewed_at = timezone.now()
        super().save_model(request, obj, form, change)


@admin.register(UserTrialActivation)
class UserTrialActivationAdmin(admin.ModelAdmin):
    list_display = (
        "user",
        "is_activated",
        "activated_at",
        "last_assessed_at",
        "activation_version",
    )
    search_fields = ("user__email", "user__id", "activation_reason")
    list_filter = ("is_activated", "activation_version")
    readonly_fields = (
        "user",
        "is_activated",
        "activated_at",
        "last_assessed_at",
        "activation_version",
        "activation_reason",
        "created_at",
        "updated_at",
    )
    fields = (
        "user",
        "is_activated",
        "activated_at",
        "last_assessed_at",
        "activation_version",
        "activation_reason",
        "created_at",
        "updated_at",
    )

    def has_add_permission(self, request):
        return False

    def has_delete_permission(self, request, obj=None):
        return False


User = get_user_model()

if admin.site.is_registered(User):
    admin.site.unregister(User)


ADMIN_MANUAL_EXECUTION_PAUSE_REASON = ExecutionPauseReasonChoices.ADMIN_MANUAL_PAUSE


class CustomUserAdminForm(forms.ModelForm):
    user_flag_definitions: tuple[UserFlagDefinition, ...] = ()
    user_flag_choice_groups: tuple[UserFlagChoiceGroup, ...] = ()
    execution_paused_admin = forms.BooleanField(
        required=False,
        label="Execution Paused",
        help_text="When enabled, this user cannot start new agent or browser-task work.",
    )
    execution_pause_reason_admin = forms.ChoiceField(
        required=False,
        label="Execution Pause Reason",
        choices=[("", "---------"), *ExecutionPauseReasonChoices.choices],
        help_text="Reason stored with the pause. Defaults to admin_manual_pause.",
    )

    class Meta:
        model = User
        fields = "__all__"

    @staticmethod
    def user_flag_field_name(definition: UserFlagDefinition) -> str:
        return f"user_flag_definition_{definition.pk}"

    @staticmethod
    def user_flag_choice_field_name(group: UserFlagChoiceGroup) -> str:
        return f"user_flag_choice_group_{group.pk}"

    @staticmethod
    def user_flag_choice_option_label(option: UserFlagChoiceOption) -> str:
        if option.is_active:
            return option.label
        return f"{option.label} (inactive)"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.instance and getattr(self.instance, "pk", None):
            pause_state = get_owner_execution_pause_state(self.instance)
            self.fields["execution_paused_admin"].initial = pause_state["paused"]
            self.fields["execution_pause_reason_admin"].initial = pause_state["reason"]

        self._user_flag_definitions = list(
            self.user_flag_definitions
            or tuple(UserFlagDefinition.objects.filter(choice_options__isnull=True).order_by("slug"))
        )
        self._user_flag_choice_groups = list(
            self.user_flag_choice_groups or tuple(UserFlagChoiceGroup.objects.order_by("label", "slug"))
        )
        self._user_flag_choice_options_by_group_id: dict[int, dict[str, UserFlagChoiceOption]] = {}
        enabled_flag_slugs = get_enabled_user_flag_slugs(self.instance)

        for definition in self._user_flag_definitions:
            field_name = self.user_flag_field_name(definition)
            if field_name not in self.fields:
                self.fields[field_name] = forms.BooleanField(required=False)
            self.fields[field_name].label = definition.slug
            self.fields[field_name].help_text = definition.description
            self.initial[field_name] = definition.slug in enabled_flag_slugs

        for group in self._user_flag_choice_groups:
            selected_option = get_selected_user_flag_choice_option(group, self.instance)
            options = [
                option
                for option in group.options.select_related("flag").order_by("label", "pk")
                if option.is_active or (selected_option is not None and option.pk == selected_option.pk)
            ]
            options.sort(key=lambda option: (option.label.casefold(), option.flag.slug.casefold()))
            options_by_id = {str(option.pk): option for option in options}
            self._user_flag_choice_options_by_group_id[group.pk] = options_by_id

            field_name = self.user_flag_choice_field_name(group)
            if field_name not in self.fields:
                self.fields[field_name] = forms.ChoiceField(required=False)
            self.fields[field_name].label = group.label
            self.fields[field_name].help_text = group.description
            self.fields[field_name].choices = [
                ("", "---------"),
                *[
                    (str(option.pk), self.user_flag_choice_option_label(option))
                    for option in options
                ],
            ]
            self.initial[field_name] = str(selected_option.pk) if selected_option else ""

    def get_user_flag_field_names(self) -> tuple[str, ...]:
        return (
            tuple(self.user_flag_field_name(definition) for definition in self._user_flag_definitions)
            + tuple(self.user_flag_choice_field_name(group) for group in self._user_flag_choice_groups)
        )

    def save_user_flags(self, user) -> None:
        for definition in self._user_flag_definitions:
            field_name = self.user_flag_field_name(definition)
            set_user_flag(
                definition,
                user,
                bool(self.cleaned_data.get(field_name, False)),
            )
        for group in self._user_flag_choice_groups:
            field_name = self.user_flag_choice_field_name(group)
            option_id = str(self.cleaned_data.get(field_name) or "")
            option = self._user_flag_choice_options_by_group_id.get(group.pk, {}).get(option_id)
            set_user_flag_choice(group, user, option)

# ------------------------------------------------------------------
# CUSTOM USER ADMIN (Optimized)  ------------------------------------
# ------------------------------------------------------------------

@admin.register(User)
class CustomUserAdmin(UserAdmin):
    form = CustomUserAdminForm
    change_list_template = "admin/user_change_list.html"
    USER_FLAG_SEARCH_PATTERN = re.compile(r"^(?P<slug>[-A-Za-z0-9_]+)=(?P<value>true|false)$")
    # Keep lightweight inlines only (flags, referral, agents); omit heavy TaskCredit inline.
    inlines = [UserFlagsInlineForUser, UserReferralInlineForUser, BrowserUseAgentInlineForUser]

    actions = ['queue_rollup_for_selected_users']

    def _configured_user_flag_definitions(self) -> list[UserFlagDefinition]:
        return list(UserFlagDefinition.objects.filter(choice_options__isnull=True).order_by("slug"))

    def _all_user_flag_definitions(self) -> list[UserFlagDefinition]:
        return list(UserFlagDefinition.objects.order_by("slug"))

    def _configured_user_flag_choice_groups(self) -> list[UserFlagChoiceGroup]:
        option_queryset = UserFlagChoiceOption.objects.select_related("flag").order_by("label", "pk")
        return list(
            UserFlagChoiceGroup.objects.prefetch_related(
                Prefetch("options", queryset=option_queryset),
            ).order_by("label", "slug")
        )

    def _user_flag_field_names(self) -> tuple[str, ...]:
        return (
            tuple(
                CustomUserAdminForm.user_flag_field_name(definition)
                for definition in self._configured_user_flag_definitions()
            )
            + tuple(
                CustomUserAdminForm.user_flag_choice_field_name(group)
                for group in self._configured_user_flag_choice_groups()
            )
        )

    def _bulk_set_flags_url_name(self) -> str:
        opts = self.model._meta
        return f"admin:{opts.app_label}_{opts.model_name}_bulk_set_flags"

    def _changelist_url_name(self) -> str:
        opts = self.model._meta
        return f"admin:{opts.app_label}_{opts.model_name}_changelist"

    @admin.action(description="Queue metering rollup for selected users")
    def queue_rollup_for_selected_users(self, request, queryset):
        from api.tasks.billing_rollup import rollup_usage_for_user
        queued = 0
        for user in queryset:
            try:
                rollup_usage_for_user.delay(user.id)
                queued += 1
            except Exception as e:
                logging.error("Failed to queue rollup for user %s: %s", user.id, e)
                continue
        self.message_user(request, f"Queued rollup for {queued} user(s).", level=messages.INFO)

    def get_queryset(self, request):
        """Annotate credit totals to avoid N+1 queries in the changelist."""
        qs = super().get_queryset(request).select_related("flags")
        return qs.annotate(
            total_credits=Sum("task_credits__credits"),
            used_credits=Sum("task_credits__credits_used"),
        )

    def get_form(self, request, obj=None, change=False, **kwargs):
        base_form = kwargs.get("form") or (self.add_form if obj is None else self.form)
        definitions = tuple(self._configured_user_flag_definitions()) if obj is not None else ()
        choice_groups = tuple(self._configured_user_flag_choice_groups()) if obj is not None else ()
        if not definitions and not choice_groups:
            kwargs["form"] = base_form
            return super().get_form(request, obj, change=change, **kwargs)
        dynamic_fields = {
            CustomUserAdminForm.user_flag_field_name(definition): forms.BooleanField(
                required=False,
                label=definition.slug,
                help_text=definition.description,
            )
            for definition in definitions
        }
        dynamic_fields.update(
            {
                CustomUserAdminForm.user_flag_choice_field_name(group): forms.ChoiceField(
                    required=False,
                    choices=(),
                    label=group.label,
                    help_text=group.description,
                )
                for group in choice_groups
            }
        )
        dynamic_form = type(
            "DynamicCustomUserAdminForm",
            (base_form,),
            dynamic_fields,
        )
        dynamic_form.user_flag_definitions = definitions
        dynamic_form.user_flag_choice_groups = choice_groups
        kwargs["form"] = dynamic_form
        return super().get_form(request, obj, change=change, **kwargs)

    def get_urls(self):
        urls = super().get_urls()
        opts = self.model._meta
        custom = [
            path(
                "bulk-set-flags/",
                self.admin_site.admin_view(self.bulk_set_flags_view),
                name=f"{opts.app_label}_{opts.model_name}_bulk_set_flags",
            ),
        ]
        return custom + urls

    def changelist_view(self, request, extra_context=None):
        extra_context = dict(extra_context or {})
        extra_context["bulk_set_user_flags_url"] = reverse(self._bulk_set_flags_url_name())
        return super().changelist_view(request, extra_context=extra_context)

    def _extract_user_flag_search_filters(
        self,
        search_term: str,
    ) -> tuple[str, list[tuple[UserFlagDefinition, bool]]]:
        definitions_by_slug = {
            definition.slug: definition
            for definition in self._all_user_flag_definitions()
        }
        remaining_terms: list[str] = []
        flag_filters: list[tuple[UserFlagDefinition, bool]] = []

        for term in (search_term or "").split():
            match = self.USER_FLAG_SEARCH_PATTERN.fullmatch(term)
            if match is None:
                remaining_terms.append(term)
                continue

            definition = definitions_by_slug.get(match.group("slug"))
            if definition is None:
                remaining_terms.append(term)
                continue

            flag_filters.append(
                (definition, match.group("value") == "true")
            )

        return " ".join(remaining_terms), flag_filters

    def get_search_results(self, request, queryset, search_term):
        filtered_search_term, flag_filters = self._extract_user_flag_search_filters(search_term)
        queryset, use_distinct = super().get_search_results(request, queryset, filtered_search_term)

        for definition, enabled in flag_filters:
            queryset = filter_users_by_flag(queryset, definition, enabled=enabled)

        return queryset, use_distinct

    def bulk_set_flags_view(self, request):
        if not self.has_change_permission(request):
            self.message_user(request, "You do not have permission to edit users.", messages.ERROR)
            return HttpResponseRedirect(reverse(self._changelist_url_name()))

        form = BulkSetUserFlagsForm(request.POST or None)
        context = dict(self.admin_site.each_context(request))
        context.update(
            {
                "opts": self.model._meta,
                "title": "Bulk Set User Flags",
                "form": form,
                "changelist_url": reverse(self._changelist_url_name()),
            }
        )

        if request.method == "POST" and form.is_valid():
            user_ids = form.cleaned_data["user_ids"]
            flag = form.cleaned_data["flag"]
            enabled = form.cleaned_data["value"]
            choice_option = flag.choice_options.select_related("group").first()
            users_by_id = {
                user.id: user
                for user in self.model.objects.filter(id__in=user_ids)
            }
            updated_count = 0
            missing_user_ids: list[int] = []

            for user_id in user_ids:
                user = users_by_id.get(user_id)
                if user is None:
                    missing_user_ids.append(user_id)
                    continue

                if enabled and choice_option is not None:
                    set_user_flag_choice(choice_option.group, user, choice_option)
                else:
                    set_user_flag(flag, user, enabled)
                updated_count += 1

            state_label = "enabled" if enabled else "disabled"
            self.message_user(
                request,
                f"Set '{flag.slug}' to {state_label} for {updated_count} user(s).",
                messages.SUCCESS,
            )

            invalid_tokens = getattr(form, "invalid_user_id_tokens", [])
            if invalid_tokens:
                self.message_user(
                    request,
                    f"Skipped invalid user ID tokens: {', '.join(invalid_tokens)}.",
                    messages.WARNING,
                )

            if missing_user_ids:
                self.message_user(
                    request,
                    f"Skipped missing user IDs: {', '.join(str(user_id) for user_id in missing_user_ids)}.",
                    messages.WARNING,
                )

            return HttpResponseRedirect(reverse(self._changelist_url_name()))

        return TemplateResponse(request, "admin/bulk_set_user_flags.html", context)


    # Add a summary field for task credits (read-only).
    def get_readonly_fields(self, request, obj=None):
        # Preserve any readonly fields defined by UserAdmin.
        base = super().get_readonly_fields(request, obj)
        return base + (
            "taskcredit_summary_link",
            "timezone_display",
            "execution_paused_at_display",
            "execution_pause_resume_at_display",
        )

    def get_fieldsets(self, request, obj=None):
        if obj is None:
            return super().get_fieldsets(request, obj)
        # Append a dedicated "Task Credits" fieldset to the default ones.
        fieldsets = list(super().get_fieldsets(request, obj))
        fieldsets.append(("Preferences", {"fields": ("timezone_display",)}))
        configured_user_flag_fields = self._user_flag_field_names()
        if configured_user_flag_fields:
            fieldsets.append(("Configured User Flags", {"fields": configured_user_flag_fields}))
        fieldsets.append(("Task Credits", {"fields": ("taskcredit_summary_link",)}))
        if obj is not None:
            fieldsets.append(
                (
                    "Execution Control",
                    {
                        "fields": (
                            "execution_paused_admin",
                            "execution_pause_reason_admin",
                            "execution_paused_at_display",
                            "execution_pause_resume_at_display",
                        )
                    },
                )
            )
        return tuple(fieldsets)

    @admin.display(description="Timezone")
    def timezone_display(self, obj):
        return UserPreference.resolve_user_timezone(obj, fallback_to_utc=False)

    @admin.display(description="Execution Paused At")
    def execution_paused_at_display(self, obj):
        pause_state = get_owner_execution_pause_state(obj)
        paused_at = pause_state["paused_at"]
        return paused_at or ""

    @admin.display(description="Execution Pause Resume At")
    def execution_pause_resume_at_display(self, obj):
        pause_state = get_owner_execution_pause_state(obj)
        resume_at = pause_state["resume_at"]
        return resume_at or ""

    @admin.display(description="Task Credits")
    def taskcredit_summary_link(self, obj):
        """Compact summary + link to full TaskCredit list for this user."""
        # Use annotated values if available (from get_queryset)
        total = getattr(obj, "total_credits", 0) or 0
        used = getattr(obj, "used_credits", 0) or 0
        
        # Fallback to aggregation if not on the changelist view (e.g., on the change form)
        if not hasattr(obj, "total_credits"):
            summary = obj.task_credits.aggregate(
                total=Sum("credits"),
                used=Sum("credits_used"),
            )
            total = summary["total"] or 0
            used = summary["used"] or 0

        remaining = total - used

        url = (
            reverse("admin:api_taskcredit_changelist") + f"?user__id__exact={obj.pk}"
        )
        return format_html(
            "{} total / {} used / {} remaining&nbsp;&nbsp;<a href=\"{}\">View details</a>",
            total,
            used,
            remaining,
            url,
        )

    def save_model(self, request, obj, form, change):
        super().save_model(request, obj, form, change)
        save_user_flags = getattr(form, "save_user_flags", None)
        if callable(save_user_flags):
            save_user_flags(obj)

        if not change:
            return

        should_pause = bool(form.cleaned_data.get("execution_paused_admin"))
        pause_reason = (
            str(form.cleaned_data.get("execution_pause_reason_admin", "") or "").strip()
            or ADMIN_MANUAL_EXECUTION_PAUSE_REASON
        )
        pause_state = get_owner_execution_pause_state(obj)

        if should_pause:
            if not pause_state["paused"] or pause_state["reason"] != pause_reason:
                pause_owner_execution(
                    obj,
                    pause_reason,
                    source="django_admin.user_change",
                    trigger_agent_cleanup=False,
                    analytics_source=AnalyticsSource.WEB,
                )
            return

        if pause_state["paused"]:
            resume_owner_execution(
                obj,
                source="django_admin.user_change",
            )


# --- DECODO MODELS ---

class DecodoIPBlockInline(admin.TabularInline):
    model = DecodoIPBlock
    extra = 0
    fields = ('endpoint', 'proxy_type', 'start_port', 'block_size', 'created_at', 'view_ip_block_link')
    readonly_fields = ('created_at', 'view_ip_block_link')

    def view_ip_block_link(self, obj):
        if obj.pk:
            url = reverse("admin:api_decodoipblock_change", args=[obj.pk])
            return format_html('<a href="{}">View/Edit Block</a>', url)
        return "-"
    view_ip_block_link.short_description = "Block Details"


@admin.register(DecodoCredential)
class DecodoCredentialAdmin(admin.ModelAdmin):
    list_display = ('username', 'ip_blocks_count', 'created_at', 'updated_at')
    search_fields = ('username',)
    readonly_fields = ('id', 'created_at', 'updated_at')
    inlines = [DecodoIPBlockInline]

    def ip_blocks_count(self, obj):
        return obj.ip_blocks.count()
    ip_blocks_count.short_description = 'IP Blocks'


class DecodoIPInline(admin.TabularInline):
    model = DecodoIP
    extra = 0
    fields = ('ip_address', 'country_name', 'city_name', 'isp_name', 'view_ip_link')
    readonly_fields = ('view_ip_link',)

    def view_ip_link(self, obj):
        if obj.pk:
            url = reverse("admin:api_decodoip_change", args=[obj.pk])
            return format_html('<a href="{}">View/Edit IP</a>', url)
        return "-"
    view_ip_link.short_description = "IP Details"


@admin.register(DecodoIPBlock)
class DecodoIPBlockAdmin(admin.ModelAdmin):
    list_display = ('endpoint', 'proxy_type', 'start_port', 'block_size', 'credential_username', 'ip_count', 'sync_now', 'created_at')
    list_filter = ('endpoint', 'proxy_type', 'credential')
    search_fields = ('endpoint', 'credential__username')
    readonly_fields = ('id', 'created_at', 'updated_at')
    raw_id_fields = ('credential',)
    inlines = [DecodoIPInline]

    def get_urls(self):
        """Add custom URL for sync functionality."""
        urls = super().get_urls()
        custom_urls = [
            path(
                '<path:object_id>/sync/',
                self.admin_site.admin_view(self.sync_view),
                name='api_decodoipblock_sync',
            ),
        ]
        return custom_urls + urls

    @admin.display(description="")
    def sync_now(self, obj):
        """Render the sync button for this IP block."""
        url = reverse("admin:api_decodoipblock_sync", args=[obj.pk])
        return format_html('<a class="button" href="{}">Sync&nbsp;Now</a>', url)

    def sync_view(self, request, object_id, *args, **kwargs):
        """Handle the sync button click - queue a Celery task and redirect."""
        try:
            # Verify the object exists
            ip_block = DecodoIPBlock.objects.get(pk=object_id)

            # Queue the sync task
            sync_ip_block.delay(str(ip_block.id))

            # Show success message
            self.message_user(
                request,
                f"Sync queued for IP block {ip_block.endpoint}:{ip_block.start_port}",
                messages.SUCCESS
            )

        except DecodoIPBlock.DoesNotExist:
            self.message_user(
                request,
                "IP block not found",
                messages.ERROR
            )
        except Exception as e:
            self.message_user(
                request,
                f"Error queuing sync: {str(e)}",
                messages.ERROR
            )

        # Redirect back to the change form
        return HttpResponseRedirect(
            reverse("admin:api_decodoipblock_change", args=[object_id])
        )

    def credential_username(self, obj):
        return obj.credential.username if obj.credential else None
    credential_username.short_description = 'Credential'
    credential_username.admin_order_field = 'credential__username'

    def ip_count(self, obj):
        return obj.ip_addresses.count()
    ip_count.short_description = 'IP Count'


@admin.register(DecodoIP)
class DecodoIPAdmin(admin.ModelAdmin):
    list_display = ('ip_address', 'port', 'country_name', 'city_name', 'isp_name', 'ip_block_endpoint', 'created_at')
    list_filter = ('country_code', 'country_name', 'isp_name', 'ip_block__credential')
    search_fields = ('ip_address', 'country_name', 'city_name', 'isp_name', 'ip_block__endpoint')
    readonly_fields = ('id', 'created_at', 'updated_at')
    raw_id_fields = ('ip_block',)

    fieldsets = (
        ('Basic Information', {
            'fields': ('id', 'ip_block', 'ip_address', 'port', 'created_at', 'updated_at')
        }),
        ('ISP Information', {
            'fields': ('isp_name', 'isp_asn', 'isp_domain', 'isp_organization')
        }),
        ('Location Information', {
            'fields': ('country_code', 'country_name', 'country_continent', 'city_name', 'city_code', 'city_state', 'city_timezone', 'city_zip_code', 'city_latitude', 'city_longitude')
        }),
    )

    def ip_block_endpoint(self, obj):
        return f"{obj.ip_block.endpoint}:{obj.ip_block.start_port}" if obj.ip_block else None
    ip_block_endpoint.short_description = 'IP Block'
    ip_block_endpoint.admin_order_field = 'ip_block__endpoint'


@admin.register(ProxyServer)
class ProxyServerAdmin(admin.ModelAdmin):
    list_display = ('name', 'proxy_type', 'host', 'port', 'username', 'static_ip', 'is_active', 'is_dedicated', 'health_results_link', 'decodo_ip_link', 'test_now', 'created_at')
    list_filter = ('proxy_type', 'is_active', 'is_dedicated', 'created_at')
    search_fields = ('name', 'host', 'username', 'static_ip', 'notes')
    readonly_fields = ('id', 'created_at', 'updated_at')
    raw_id_fields = ('decodo_ip',)
    fieldsets = (
        ('Details', {
            'fields': (
                'id', 'name', 'proxy_type', 'host', 'port', 'username', 'password', 'static_ip',
                'is_active', 'is_dedicated', 'notes', 'decodo_ip', 'created_at', 'updated_at'
            )
        }),
    )
    
    def get_urls(self):
        """Add custom URL for health check functionality."""
        urls = super().get_urls()
        custom_urls = [
            path(
                '<path:object_id>/health-check/',
                self.admin_site.admin_view(self.health_check_view),
                name='api_proxyserver_health_check',
            ),
        ]
        return custom_urls + urls

    @admin.action(description="Mark selected proxies as dedicated")
    def mark_as_dedicated(self, request, queryset):
        updated = queryset.update(is_dedicated=True)
        self.message_user(request, f"{updated} proxy server(s) marked as dedicated.", level=messages.SUCCESS)

    @admin.action(description="Mark selected proxies as shared")
    def mark_as_shared(self, request, queryset):
        updated = queryset.update(is_dedicated=False)
        self.message_user(request, f"{updated} proxy server(s) marked as shared.", level=messages.SUCCESS)

    @admin.display(description="")
    def test_now(self, obj):
        """Render the health check button for this proxy server."""
        if obj.is_active:
            url = reverse("admin:api_proxyserver_health_check", args=[obj.pk])
            return format_html('<a class="button" href="{}">Test&nbsp;Now</a>', url)
        return format_html('<span style="color: gray;">{}</span>', "Inactive")

    def health_check_view(self, request, object_id, *args, **kwargs):
        """Handle the health check button click - queue a Celery task and redirect."""
        try:
            # Verify the object exists
            proxy_server = ProxyServer.objects.get(pk=object_id)

            # Queue the health check task
            proxy_health_check_single.delay(str(proxy_server.id))

            # Show success message
            self.message_user(
                request,
                f"Health check queued for proxy {proxy_server.host}:{proxy_server.port}",
                messages.SUCCESS
            )

        except ProxyServer.DoesNotExist:
            self.message_user(
                request,
                "Proxy server not found",
                messages.ERROR
            )
        except Exception as e:
            self.message_user(
                request,
                f"Error queuing health check: {str(e)}",
                messages.ERROR
            )

        # Redirect back to the change form
        return HttpResponseRedirect(
            reverse("admin:api_proxyserver_change", args=[object_id])
        )
    
    fieldsets = (
        ('Basic Configuration', {
            'fields': ('id', 'name', 'proxy_type', 'host', 'port', 'is_active')
        }),
        ('Authentication', {
            'fields': ('username', 'password'),
            'classes': ('collapse',)
        }),
        ('IP Information', {
            'fields': ('static_ip', 'decodo_ip')
        }),
        ('Metadata', {
            'fields': ('notes', 'created_at', 'updated_at'),
            'classes': ('collapse',)
        }),
    )
    
    def health_results_link(self, obj):
        """Link to view health check results for this proxy."""
        count = obj.health_check_results.count()
        if count > 0:
            url = reverse("admin:api_proxyhealthcheckresult_changelist") + f"?proxy_server__id__exact={obj.id}"
            recent_passed = obj.health_check_results.filter(status='PASSED').order_by('-checked_at').first()
            recent_failed = obj.health_check_results.filter(status__in=['FAILED', 'ERROR', 'TIMEOUT']).order_by('-checked_at').first()
            
            # Determine status color
            if recent_passed and (not recent_failed or recent_passed.checked_at > recent_failed.checked_at):
                color = "green"
                icon = "✓"
            elif recent_failed:
                color = "red" 
                icon = "✗"
            else:
                color = "gray"
                icon = "?"
                
            return format_html('<a href="{}" style="color: {};">{} {} results</a>', url, color, icon, count)
        return format_html('<span style="color: gray;">{}</span>', "No tests")
    health_results_link.short_description = 'Health Status'

    def decodo_ip_link(self, obj):
        if obj.decodo_ip:
            url = reverse("admin:api_decodoip_change", args=[obj.decodo_ip.pk])
            return format_html('<a href="{}">{}</a>', url, obj.decodo_ip.ip_address)
        return None
    decodo_ip_link.short_description = 'Decodo IP'
    
    actions = ['mark_as_dedicated', 'mark_as_shared', 'backfill_missing_proxies', 'test_selected_proxies']
    
    def backfill_missing_proxies(self, request, queryset):
        """Action to backfill missing proxy records for all Decodo IPs."""
        try:
            backfill_missing_proxy_records.delay()
            self.message_user(
                request,
                "Backfill task queued to create missing proxy records for all Decodo IPs",
                messages.SUCCESS
            )
        except Exception as e:
            self.message_user(
                request,
                f"Error queuing backfill task: {str(e)}",
                messages.ERROR
            )
    backfill_missing_proxies.short_description = "Backfill missing proxy records for Decodo IPs"
    
    def test_selected_proxies(self, request, queryset):
        """Action to run health checks on selected proxy servers."""
        active_proxies = queryset.filter(is_active=True)
        inactive_count = queryset.count() - active_proxies.count()
        
        if not active_proxies.exists():
            self.message_user(
                request,
                "No active proxy servers selected for health check",
                messages.WARNING
            )
            return
        
        try:
            # Queue health check tasks for each selected active proxy
            queued_count = 0
            for proxy in active_proxies:
                proxy_health_check_single.delay(str(proxy.id))
                queued_count += 1
            
            message = f"Health checks queued for {queued_count} proxy server(s)"
            if inactive_count > 0:
                message += f" (skipped {inactive_count} inactive proxy server(s))"
            
            self.message_user(
                request,
                message,
                messages.SUCCESS
            )
        except Exception as e:
            self.message_user(
                request,
                f"Error queuing health checks: {str(e)}",
                messages.ERROR
            )
    test_selected_proxies.short_description = "Run health checks on selected proxy servers"


@admin.register(DedicatedProxyAllocation)
class DedicatedProxyAllocationAdmin(admin.ModelAdmin):
    list_display = ('proxy', 'owner_display', 'allocated_at', 'updated_at')
    list_filter = ('owner_user', 'owner_organization')
    search_fields = (
        'proxy__name',
        'proxy__host',
        'owner_user__email',
        'owner_user__username',
        'owner_organization__name',
    )
    raw_id_fields = ('proxy', 'owner_user', 'owner_organization')
    readonly_fields = ('id', 'allocated_at', 'updated_at')
    ordering = ('-allocated_at',)
    fieldsets = (
        ('Allocation', {
            'fields': ('id', 'proxy', 'allocated_at', 'updated_at')
        }),
        ('Owner', {
            'fields': ('owner_user', 'owner_organization', 'notes')
        }),
    )

    def owner_display(self, obj):
        return obj.owner
    owner_display.short_description = 'Owner'


@admin.register(ProxyHealthCheckSpec)
class ProxyHealthCheckSpecAdmin(admin.ModelAdmin):
    list_display = ('name', 'is_active', 'results_count', 'created_at', 'updated_at')
    list_filter = ('is_active', 'created_at')
    search_fields = ('name', 'prompt')
    readonly_fields = ('id', 'created_at', 'updated_at')
    
    fieldsets = (
        ('Basic Information', {
            'fields': ('id', 'name', 'is_active')
        }),
        ('Health Check Configuration', {
            'fields': ('prompt',)
        }),
        ('Metadata', {
            'fields': ('created_at', 'updated_at'),
            'classes': ('collapse',)
        }),
    )
    
    def results_count(self, obj):
        """Show the number of health check results for this spec."""
        count = obj.results.count()
        if count > 0:
            url = reverse("admin:api_proxyhealthcheckresult_changelist") + f"?health_check_spec__id__exact={obj.id}"
            return format_html('<a href="{}">{} results</a>', url, count)
        return "0 results"
    results_count.short_description = "Results"


@admin.register(ProxyHealthCheckResult)
class ProxyHealthCheckResultAdmin(admin.ModelAdmin):
    list_display = ('proxy_server_link', 'health_check_spec_link', 'status', 'response_time_ms', 'checked_at', 'created_at')
    list_filter = ('status', 'checked_at', 'health_check_spec', 'proxy_server__proxy_type', 'proxy_server__is_active')
    search_fields = ('proxy_server__name', 'proxy_server__host', 'health_check_spec__name', 'error_message')
    readonly_fields = ('id', 'checked_at', 'created_at')
    raw_id_fields = ('proxy_server', 'health_check_spec')
    date_hierarchy = 'checked_at'
    
    fieldsets = (
        ('Test Information', {
            'fields': ('id', 'proxy_server', 'health_check_spec', 'checked_at')
        }),
        ('Results', {
            'fields': ('status', 'response_time_ms', 'error_message')
        }),
        ('Raw Data', {
            'fields': ('task_result', 'notes'),
            'classes': ('collapse',)
        }),
        ('Metadata', {
            'fields': ('created_at',),
            'classes': ('collapse',)
        }),
    )
    
    def proxy_server_link(self, obj):
        """Link to the proxy server being tested."""
        if obj.proxy_server:
            url = reverse("admin:api_proxyserver_change", args=[obj.proxy_server.pk])
            return format_html('<a href="{}">{}</a>', url, f"{obj.proxy_server.host}:{obj.proxy_server.port}")
        return None
    proxy_server_link.short_description = 'Proxy Server'
    proxy_server_link.admin_order_field = 'proxy_server__host'
    
    def health_check_spec_link(self, obj):
        """Link to the health check spec used."""
        if obj.health_check_spec:
            url = reverse("admin:api_proxyhealthcheckspec_change", args=[obj.health_check_spec.pk])
            return format_html('<a href="{}">{}</a>', url, obj.health_check_spec.name)
        return None
    health_check_spec_link.short_description = 'Health Check Spec'
    health_check_spec_link.admin_order_field = 'health_check_spec__name'
    
    def get_queryset(self, request):
        """Optimize queryset with select_related for better performance."""
        return super().get_queryset(request).select_related('proxy_server', 'health_check_spec')


# --- PERSISTENT AGENT MODELS ---


class PersistentAgentWebhookInline(admin.TabularInline):
    """Inline to manage outbound webhooks for an agent."""
    model = PersistentAgentWebhook
    extra = 0
    fields = ("name", "url", "last_triggered_at", "last_response_status", "last_error_message")
    readonly_fields = ("last_triggered_at", "last_response_status", "last_error_message")
    verbose_name = "Outbound webhook"
    verbose_name_plural = "Outbound webhooks"


class PersistentAgentCommsEndpointInline(admin.TabularInline):
    """Inline for viewing/editing agent communication endpoints."""
    model = PersistentAgentCommsEndpoint
    extra = 0
    fields = ('channel', 'address', 'is_primary', 'endpoint_link')
    readonly_fields = ('endpoint_link',)
    
    def endpoint_link(self, obj):
        if obj.pk:
            url = reverse("admin:api_persistentagentcommsendpoint_change", args=[obj.pk])
            return format_html('<a href="{}">Edit Details</a>', url)
        return "-"
    endpoint_link.short_description = "Details"

    def has_delete_permission(self, request, obj=None):
        # Allow deletion but be careful not to delete primary endpoints
        return True


class PersistentAgentSkillInline(admin.TabularInline):
    """Inline for viewing/editing persistent agent skills."""

    model = PersistentAgentSkill
    extra = 0
    fields = ("name", "global_skill", "version", "description", "tools", "secrets", "updated_at")
    readonly_fields = ("global_skill", "updated_at")
    ordering = ("name", "-version", "-updated_at")
    show_change_link = True


class GlobalAgentSkillCustomToolInline(admin.TabularInline):
    """Inline for bundled custom tools stored with a global skill."""

    model = GlobalAgentSkillCustomTool
    extra = 0
    fields = ("name", "tool_name", "description", "parameters_schema", "timeout_seconds", "source_file", "updated_at")
    readonly_fields = ("updated_at",)
    ordering = ("tool_name",)


class CommsAllowlistEntryInline(admin.TabularInline):
    """Inline to manage manual allowlist entries for an agent."""
    model = CommsAllowlistEntry
    extra = 0
    fields = (
        "channel",
        "address",
        "is_active",
        "verified",
        "sms_contact_purpose",
        "sms_contact_purpose_details",
        "sms_contact_permission_attested",
        "sms_contact_permission_attested_at",
    )
    readonly_fields = ()
    classes = ("collapse",)


class PersistentAgentMessageInlineForm(forms.ModelForm):
    class Meta:
        model = PersistentAgentMessage
        fields = "__all__"

    def _post_clean(self):
        # Read-only inline: skip model validation so legacy rows don't block saves.
        return


class AgentMessageInline(admin.TabularInline):
    """Inline for viewing agent conversation history."""
    model = PersistentAgentMessage
    fk_name = 'owner_agent'
    extra = 0
    form = PersistentAgentMessageInlineForm
    fields = ('timestamp', 'direction_display', 'from_to_display', 'body_preview', 'status_display', 'message_link')
    readonly_fields = ('timestamp', 'direction_display', 'from_to_display', 'body_preview', 'status_display', 'message_link')
    # Show newest messages first so the most relevant information is immediately visible.
    ordering = ('-timestamp',)

    # Limit how many messages we render inline to avoid very large HTML tables that
    # freeze the browser when an agent has thousands of messages.
    MAX_DISPLAY = 50  # Most-recent N messages to show inline

    def get_queryset(self, request):
        """
        Return only the most recent MAX_DISPLAY messages for the parent agent,
        avoiding issues with admin filtering.
        """
        # Get the full queryset of all messages
        qs = super().get_queryset(request)
        
        # Extract parent object_id from the URL, which is how inlines are linked
        object_id = request.resolver_match.kwargs.get("object_id")
        
        if not object_id:
            # We are on an add page, no parent object yet
            return qs.none()
        
        # Filter messages for the specific parent agent
        qs = qs.filter(owner_agent__pk=object_id)
        
        # Order by timestamp to get the most recent
        qs = qs.order_by('-timestamp')
        
        # Get the primary keys of the most recent N messages
        recent_pks = list(qs.values_list('pk', flat=True)[:self.MAX_DISPLAY])
        
        # Return a new, unsliced queryset filtered by those specific pks.
        # This is the safe way to limit results in an admin inline.
        return self.model.objects.filter(pk__in=recent_pks).order_by('-timestamp')

    can_delete = False
    
    def direction_display(self, obj):
        if obj.is_outbound:
            return format_html('<span style="color: blue;">{}</span>', "→ OUT")
        else:
            return format_html('<span style="color: green;">{}</span>', "← IN")
    direction_display.short_description = "Direction"
    
    def from_to_display(self, obj):
        from_addr = obj.from_endpoint.address if obj.from_endpoint else "Unknown"
        to_addr = obj.to_endpoint.address if obj.to_endpoint else "Conversation"
        return f"{from_addr} → {to_addr}"
    from_to_display.short_description = "From → To"
    
    def body_preview(self, obj):
        if obj.body:
            preview = obj.body.replace('\n', ' ').strip()
            return (preview[:75] + '...') if len(preview) > 75 else preview
        return "-"
    body_preview.short_description = "Message"
    
    def status_display(self, obj):
        status = obj.latest_status
        color_map = {
            'queued': 'orange',
            'sent': 'green', 
            'failed': 'red',
            'delivered': 'blue'
        }
        color = color_map.get(status, 'gray')
        return format_html('<span style="color: {};">{}</span>', color, status.title())
    status_display.short_description = "Status"
    
    def message_link(self, obj):
        if obj.pk:
            url = reverse("admin:api_persistentagentmessage_change", args=[obj.pk])
            return format_html('<a href="{}">View</a>', url)
        return "-"
    message_link.short_description = "Details"

    def has_add_permission(self, request, obj=None):
        return False


class PersistentAgentSystemMessageInline(admin.TabularInline):
    """Inline to show recent system directives issued via the admin."""

    model = PersistentAgentSystemMessage
    extra = 0
    fields = ("body", "is_active", "delivered_at", "created_by", "created_at")
    readonly_fields = ("body", "is_active", "delivered_at", "created_by", "created_at")
    can_delete = False
    ordering = ("-created_at",)
    classes = ("collapse",)
    verbose_name = "System directive"
    verbose_name_plural = "System directives"


class SystemMessageForm(forms.Form):
    """Simple form to capture a high-priority system directive."""

    message = forms.CharField(
        label="System message",
        widget=forms.Textarea(attrs={"rows": 5, "cols": 80}),
        max_length=2000,
        help_text="This text will be injected into the agent's system prompt as a Gobii system directive.",
        strip=True,
    )

    def clean_message(self):
        data = (self.cleaned_data.get("message") or "").strip()
        if not data:
            raise forms.ValidationError("System message cannot be blank.")
        return data


class PersistentAgentAdminForm(forms.ModelForm):
    class Meta:
        model = PersistentAgent
        fields = "__all__"

    def clean(self):
        cleaned_data = super().clean()
        is_deleted = cleaned_data.get("is_deleted", self.instance.is_deleted)
        if not self.instance.pk or is_deleted:
            return cleaned_data

        was_deleted = self.instance.is_deleted
        if not was_deleted:
            return cleaned_data

        user = cleaned_data["user"] if "user" in cleaned_data else self.instance.user
        if user is None:
            return cleaned_data
        organization = (
            cleaned_data["organization"]
            if "organization" in cleaned_data
            else self.instance.organization
        )
        name = (
            cleaned_data["name"]
            if "name" in cleaned_data
            else self.instance.name
        )
        name = (name or "").strip()
        has_conflict = PersistentAgent.has_active_name_conflict(
            user_id=getattr(user, "id", None),
            organization_id=getattr(organization, "id", None),
            name=name,
            exclude_id=self.instance.pk,
        )
        if has_conflict:
            self.add_error(
                "name",
                "Cannot restore agent because another active agent with this name already exists for this owner.",
            )
        return cleaned_data


@admin.register(PersistentAgent)
class PersistentAgentAdmin(admin.ModelAdmin):
    form = PersistentAgentAdminForm
    change_list_template = "admin/persistentagent_change_list.html"
    list_display = (
        'name', 'user_email', 'ownership_scope', 'organization', 'browser_use_agent_link',
        'is_active', 'sms_disabled', 'is_deleted', 'execution_environment', 'schedule', 'life_state', 'last_interaction_at',
        'message_count', 'created_at'
    )
    list_filter = (OwnershipTypeFilter, SoftExpirationFilter, 'organization', 'is_active', 'sms_disabled', 'is_deleted', 'execution_environment', 'created_at')
    search_fields = ('name', 'user__email', 'organization__name', 'charter', 'short_description', 'visual_description')
    raw_id_fields = ('user', 'browser_use_agent')
    readonly_fields = (
        'id', 'ownership_scope', 'created_at', 'updated_at',
        'browser_use_agent_link', 'agent_actions', 'messages_summary_link', 'audit_link',
        'last_expired_at', 'sleep_email_sent_at', 'deleted_at',
        'short_description', 'short_description_charter_hash', 'short_description_requested_hash',
        'avatar_charter_hash', 'avatar_requested_hash', 'avatar_last_generation_attempt_at',
        'visual_description', 'visual_description_charter_hash', 'visual_description_requested_hash',
    )
    actions = ("soft_delete_selected_agents", "undelete_selected_agents")
    inlines = [
        PersistentAgentCommsEndpointInline,
        PersistentAgentSkillInline,
        PersistentAgentWebhookInline,
        CommsAllowlistEntryInline,
        AgentMessageInline,
        PersistentAgentSystemMessageInline,
    ]

    # ------------------------------------------------------------------
    # Performance: annotate message counts so we don't do a COUNT query per row
    # ------------------------------------------------------------------
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        return qs.annotate(num_messages=Count('agent_messages'))
    
    fieldsets = (
        ('Basic Information', {
            'fields': (
                'id', 'name', 'user', 'organization', 'ownership_scope',
                'charter', 'short_description', 'short_description_charter_hash',
                'short_description_requested_hash', 'avatar',
                'avatar_charter_hash', 'avatar_requested_hash', 'avatar_last_generation_attempt_at',
                'visual_description', 'visual_description_charter_hash',
                'visual_description_requested_hash', 'created_at', 'updated_at',
            )
        }),
        ('Configuration', {
            'fields': (
                'browser_use_agent',
                'browser_use_agent_link',
                'preferred_llm_tier',
                'schedule',
                'is_active',
                'sms_disabled',
                'execution_environment',
            )
        }),
        ('Soft Delete', {
            'description': 'Soft-deleted agents are hidden from user-facing views but remain available for audit/history.',
            'fields': ('is_deleted', 'deleted_at'),
        }),
        ('Soft Expiration (Testing)', {
            'description': 'Override last_interaction_at to simulate inactivity windows. last_expired_at and notices are read-only for audit.',
            'fields': ('life_state', 'last_interaction_at', 'last_expired_at', 'sleep_email_sent_at')
        }),
        ('Actions', {
            'fields': ('agent_actions',)
        }),
        ('Data Links', {
            'fields': ('messages_summary_link', 'audit_link')
        }),
    )
    
    def get_urls(self):
        """Add custom URLs for simulation and processing actions."""
        urls = super().get_urls()
        custom_urls = [
            path(
                '<path:object_id>/simulate-email/',
                self.admin_site.admin_view(self.simulate_email_view),
                name='api_persistentagent_simulate_email',
            ),
            path(
                '<path:object_id>/simulate-sms/',
                self.admin_site.admin_view(self.simulate_sms_view),
                name='api_persistentagent_simulate_sms',
            ),
            path(
                '<path:object_id>/force-proactive/',
                self.admin_site.admin_view(self.force_proactive_view),
                name='api_persistentagent_force_proactive',
            ),
            path(
                '<path:object_id>/system-message/',
                self.admin_site.admin_view(self.system_message_view),
                name='api_persistentagent_system_message',
            ),
            path(
                'trigger-processing/',
                self.admin_site.admin_view(self.trigger_processing_view),
                name='api_persistentagent_trigger_processing',
            ),
            path(
                'reschedule/',
                self.admin_site.admin_view(self.reschedule_view),
                name='api_persistentagent_reschedule',
            ),
        ]
        return custom_urls + urls

    @admin.display(description='User Email')
    def user_email(self, obj):
        return obj.user.email

    @admin.display(description='Ownership')
    def ownership_scope(self, obj):
        try:
            if obj and obj.organization:
                return f"Org-owned: {obj.organization.name}"
            if obj:
                return "User-owned (no organization)"
        except Exception:
            pass
        return "User-owned by default unless organization is set"

    def get_form(self, request, obj=None, **kwargs):
        form = super().get_form(request, obj, **kwargs)
        # Hint: name uniqueness depends on owner scope
        name_field = form.base_fields.get('name')
        if name_field:
            extra = (
                "Name must be unique within the selected owner: "
                "user when no organization; organization when set."
            )
            name_field.help_text = f"{name_field.help_text} {extra}" if name_field.help_text else extra

        org_field = form.base_fields.get('organization')
        if org_field:
            extra_org = (
                "Leave blank for user-owned agents; set to make this org-owned."
            )
            org_field.help_text = f"{org_field.help_text} {extra_org}" if org_field.help_text else extra_org
        return form

    def save_model(self, request, obj, form, change):
        should_release_endpoints = False
        should_restore_resources = False
        was_deleted = False
        if change and obj.pk:
            was_deleted = bool(
                PersistentAgent.objects.filter(pk=obj.pk).values_list("is_deleted", flat=True).first()
            )
        if obj.is_deleted:
            obj.soft_delete(save=False)
            should_release_endpoints = bool(obj.pk)
        else:
            if was_deleted:
                obj.restore(save=False)
                should_restore_resources = True
            else:
                obj.deleted_at = None

        if change and obj.pk and 'preferred_llm_tier' in form.changed_data:
            previous = (
                PersistentAgent.objects.select_related('preferred_llm_tier')
                .only('preferred_llm_tier_id', 'daily_credit_limit')
                .filter(pk=obj.pk)
                .first()
            )
            if previous is not None:
                owner = obj.organization or obj.user
                credit_settings = get_daily_credit_settings_for_owner(owner)
                new_multiplier = get_tier_credit_multiplier(obj.preferred_llm_tier)
                slider_bounds = calculate_daily_credit_slider_bounds(
                    credit_settings,
                    tier_multiplier=new_multiplier,
                )
                if 'daily_credit_limit' not in form.changed_data:
                    obj.daily_credit_limit = scale_daily_credit_limit_for_tier_change(
                        previous.daily_credit_limit,
                        from_multiplier=get_tier_credit_multiplier(previous.preferred_llm_tier),
                        to_multiplier=new_multiplier,
                        slider_min=slider_bounds['slider_min'],
                        slider_max=slider_bounds['slider_limit_max'],
                    )
                elif obj.daily_credit_limit is not None:
                    if obj.daily_credit_limit < slider_bounds['slider_min']:
                        obj.daily_credit_limit = int(slider_bounds['slider_min'])
                    if obj.daily_credit_limit > slider_bounds['slider_limit_max']:
                        obj.daily_credit_limit = int(slider_bounds['slider_limit_max'])

        super().save_model(request, obj, form, change)
        if should_release_endpoints:
            obj.apply_persisted_soft_delete_side_effects()
        if should_restore_resources:
            from api.services.persistent_agent_restore import PersistentAgentRestoreRepairService

            PersistentAgentRestoreRepairService.repair(
                obj,
                apply=True,
                provision_email_fallback=True,
            )

    @admin.action(description="Soft-delete selected agents")
    def soft_delete_selected_agents(self, request, queryset):
        updated = 0
        for agent in queryset.alive():
            if agent.soft_delete():
                updated += 1
        self.message_user(request, f"Soft-deleted {updated} agent(s).", level=messages.SUCCESS)

    @admin.action(description="Undelete selected agents")
    def undelete_selected_agents(self, request, queryset):
        restored = 0
        skipped = 0
        for agent in queryset.filter(is_deleted=True):
            try:
                if agent.restore():
                    restored += 1
            except ValidationError:
                skipped += 1
        self.message_user(request, f"Undeleted {restored} agent(s).", level=messages.SUCCESS)
        if skipped:
            self.message_user(
                request,
                (
                    f"Skipped {skipped} agent(s) because an active agent with the same owner/name "
                    "already exists."
                ),
                level=messages.WARNING,
            )

    @admin.display(description='Browser Use Agent')
    def browser_use_agent_link(self, obj):
        """Link to the associated browser use agent."""
        bua = obj.browser_use_agent  # Direct FK; could be None if allowed
        if bua:
            url = reverse("admin:api_browseruseagent_change", args=[bua.pk])
            return format_html('<a href="{}">{}</a>', url, bua.name)
        return format_html('<span style="color: gray;">{}</span>', "None")
    browser_use_agent_link.admin_order_field = 'browser_use_agent__name'

    @admin.display(description='Messages')
    def message_count(self, obj):
        # Prefer the annotated value when available to avoid an extra DB query.
        count = getattr(obj, 'num_messages', None)
        if count is None:
            count = obj.agent_messages.count()

        if count > 0:
            url = reverse("admin:api_persistentagentmessage_changelist") + f"?owner_agent__id__exact={obj.pk}"
            return format_html('<a href="{}">{} messages</a>', url, count)
        return "0 messages"
    
    @admin.display(description="All Messages")
    def messages_summary_link(self, obj):
        """Link to view all messages for this agent in the dedicated admin."""
        url = reverse("admin:api_persistentagentmessage_changelist") + f"?owner_agent__id__exact={obj.pk}"
        
        count = getattr(obj, 'num_messages', None)
        if count is None:
            count = obj.agent_messages.count()
        
        return format_html(
            '<a href="{}">View all {} messages</a>', url, count
        )

    @admin.display(description="Staff Audit")
    def audit_link(self, obj):
        if not obj or not obj.pk:
            return "-"
        try:
            url = reverse("console-agent-audit", args=[obj.pk])
        except NoReverseMatch:
            return "-"
        return format_html('<a href="{}" target="_blank">Open audit timeline</a>', url)

    @admin.display(description='Agent Actions')
    def agent_actions(self, obj):
        """Renders action buttons on the agent's detail page."""
        if obj and obj.pk:
            simulate_email_url = reverse("admin:api_persistentagent_simulate_email", args=[obj.pk])
            simulate_sms_url = reverse("admin:api_persistentagent_simulate_sms", args=[obj.pk])
            force_proactive_url = reverse("admin:api_persistentagent_force_proactive", args=[obj.pk])
            system_message_url = reverse("admin:api_persistentagent_system_message", args=[obj.pk])
            return format_html(
                '<a class="button" href="{}">Simulate Email</a>&nbsp;'
                '<a class="button" href="{}">Simulate SMS</a>&nbsp;'
                '<a class="button" href="{}">Force Proactive Outreach</a>&nbsp;'
                '<a class="button" href="{}">Send System Message</a>',
                simulate_email_url,
                simulate_sms_url,
                force_proactive_url,
                system_message_url,
            )
        return "Save agent to see actions"

    def trigger_processing_view(self, request):
        """Queue event processing for the provided persistent agent IDs."""
        changelist_url = reverse('admin:api_persistentagent_changelist')
        default_context = {
            "title": "Trigger Event Processing",
            "agent_ids": "",
            "only_with_user": True,
            "skip_expired": False,
        }

        if request.method != 'POST':
            return TemplateResponse(
                request,
                "admin/persistentagent_trigger_processing.html",
                default_context,
            )

        raw_ids = request.POST.get('agent_ids', '')
        only_with_user = request.POST.get('only_with_user') is not None
        skip_expired = request.POST.get('skip_expired') is not None
        parsed_ids: list[str] = []
        invalid_entries: list[str] = []

        for line in raw_ids.splitlines():
            candidate = line.strip()
            if not candidate:
                continue
            try:
                parsed_ids.append(str(uuid.UUID(candidate)))
            except (ValueError, TypeError):
                invalid_entries.append(candidate)

        # Deduplicate, preserve order, and check for existence
        unique_ids = list(dict.fromkeys(parsed_ids))
        agents = list(
            PersistentAgent.objects.filter(id__in=unique_ids).only(
                "id",
                "is_active",
                "life_state",
                "user_id",
            )
        )
        agents_by_id = {str(agent.id): agent for agent in agents}
        existing_ids = [agent_id for agent_id in unique_ids if agent_id in agents_by_id]
        non_existent_ids = [agent_id for agent_id in unique_ids if agent_id not in agents_by_id]

        user_ids = {agent.user_id for agent in agents if agent.user_id}
        user_model = get_user_model()
        existing_user_ids = (
            set(user_model.objects.filter(id__in=user_ids).values_list('id', flat=True))
            if user_ids
            else set()
        )

        queued = 0
        failures: list[str] = []
        skipped_inactive: list[str] = []
        skipped_missing_user: list[str] = []
        skipped_expired: list[str] = []

        for agent_id in existing_ids:
            agent = agents_by_id[agent_id]
            if not agent.is_active:
                skipped_inactive.append(agent_id)
                continue
            if skip_expired and agent.life_state == PersistentAgent.LifeState.EXPIRED:
                skipped_expired.append(agent_id)
                continue
            if only_with_user and agent.user_id not in existing_user_ids:
                skipped_missing_user.append(agent_id)
                continue
            try:
                process_agent_events_task.delay(agent_id)
                queued += 1
            except Exception:  # pragma: no cover - defensive logging
                logging.exception("Failed to queue event processing for persistent agent %s", agent_id)
                failures.append(agent_id)

        if queued:
            plural = "s" if queued != 1 else ""
            self.message_user(
                request,
                f"Queued event processing for {queued} persistent agent{plural}.",
                level=messages.SUCCESS,
            )
        else:
            self.message_user(
                request,
                "No persistent agents were queued. Provide at least one valid persistent agent ID.",
                level=messages.WARNING,
            )

        if invalid_entries:
            self.message_user(
                request,
                "Skipped invalid ID(s): " + ", ".join(invalid_entries),
                level=messages.WARNING,
            )

        if non_existent_ids:
            self.message_user(
                request,
                "Skipped non-existent ID(s): " + ", ".join(non_existent_ids),
                level=messages.WARNING,
            )

        if skipped_inactive:
            self.message_user(
                request,
                "Skipped inactive agent ID(s): " + ", ".join(skipped_inactive),
                level=messages.WARNING,
            )

        if skipped_missing_user:
            self.message_user(
                request,
                "Skipped agent ID(s) missing a user: " + ", ".join(skipped_missing_user),
                level=messages.WARNING,
            )

        if skipped_expired:
            self.message_user(
                request,
                "Skipped expired agent ID(s): " + ", ".join(skipped_expired),
                level=messages.WARNING,
            )

        if failures:
            self.message_user(
                request,
                "Failed to queue ID(s): " + ", ".join(failures),
                level=messages.ERROR,
            )

        return HttpResponseRedirect(changelist_url)

    def reschedule_view(self, request):
        """Bulk reschedule persistent agents by ID and cron string."""
        changelist_url = reverse('admin:api_persistentagent_changelist')
        base_context = {
            **self.admin_site.each_context(request),
            "title": "Bulk Reschedule Persistent Agents",
            "agent_lines": "",
        }

        if request.method != "POST":
            return TemplateResponse(
                request,
                "admin/persistentagent_reschedule.html",
                base_context,
            )

        raw_lines = request.POST.get("agent_lines", "")

        # Pass 1: Parse and validate lines to separate valid and invalid entries.
        parsed_updates = {}  # agent_id -> {schedule: str, original_line: str}
        error_lines_with_messages = []  # {original_line: str, message: str}

        for original_line in raw_lines.splitlines():
            line = original_line.strip()
            if not line:
                continue

            parts = line.split(",", 1)
            if len(parts) < 2:
                parts = line.split(None, 1)

            if len(parts) == 2:
                agent_part, schedule_part = parts[0].strip(), parts[1].strip()
            else:
                agent_part, schedule_part = None, None

            if not agent_part or not schedule_part:
                error_lines_with_messages.append({"original_line": original_line,
                                                  "message": f"Could not parse line '{original_line}'. Use 'agent_id,cron' on each line."})
                continue

            try:
                agent_id = str(uuid.UUID(agent_part))
            except ValueError:
                error_lines_with_messages.append(
                    {"original_line": original_line, "message": f"Invalid agent UUID: {agent_part}"})
                continue

            try:
                ScheduleParser.parse(schedule_part)
            except ValueError as exc:
                error_lines_with_messages.append(
                    {"original_line": original_line, "message": f"Invalid schedule for {agent_part}: {exc}"})
                continue

            parsed_updates[agent_id] = {"schedule": schedule_part, "original_line": original_line}

        # Pass 2: Fetch agents in bulk and perform updates.
        success_count = 0
        if parsed_updates:
            agent_ids_to_fetch = list(parsed_updates.keys())
            agents_map = {str(a.id): a for a in PersistentAgent.objects.filter(id__in=agent_ids_to_fetch)}

            for agent_id, data in parsed_updates.items():
                agent = agents_map.get(agent_id)
                if not agent:
                    error_lines_with_messages.append(
                        {"original_line": data["original_line"], "message": f"Persistent agent not found: {agent_id}"})
                    continue

                previous_schedule = agent.schedule
                try:
                    agent.schedule = data["schedule"]
                    agent.save(update_fields=["schedule", "updated_at"])
                    success_count += 1
                except ValidationError as exc:
                    agent.schedule = previous_schedule
                    error_list = exc.message_dict.get("schedule", [str(exc)]) if hasattr(exc, "message_dict") else [
                        str(exc)]
                    error_lines_with_messages.append({"original_line": data["original_line"],
                                                      "message": f"Schedule rejected for {agent_id}: {error_list[0]}"})
                except Exception as exc:  # pragma: no cover - defensive path
                    agent.schedule = previous_schedule
                    error_lines_with_messages.append(
                        {"original_line": data["original_line"], "message": f"Failed to reschedule {agent_id}: {exc}"})

        # Report successes and failures.
        if success_count:
            plural = "s" if success_count != 1 else ""
            self.message_user(request, f"Updated schedule for {success_count} agent{plural}.", level=messages.SUCCESS)

        if error_lines_with_messages:
            for error in error_lines_with_messages:
                self.message_user(request, error["message"], level=messages.ERROR)

            remaining_lines_text = "\n".join([e["original_line"] for e in error_lines_with_messages])
            context = {**base_context, "agent_lines": remaining_lines_text}
            return TemplateResponse(
                request,
                "admin/persistentagent_reschedule.html",
                context,
            )

        return HttpResponseRedirect(changelist_url)

    def force_proactive_view(self, request, object_id):
        """Force a proactive outreach cycle for a specific agent."""
        try:
            agent = PersistentAgent.objects.get(pk=object_id)
        except PersistentAgent.DoesNotExist:
            self.message_user(request, "Agent not found", level=messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_persistentagent_changelist"))

        if request.method == "POST":
            reason_value = (request.POST.get("reason") or "").strip()
            initiated_by = request.user.email or request.user.get_username()
            try:
                ProactiveActivationService.force_trigger(
                    agent,
                    initiated_by=initiated_by,
                    reason=reason_value or None,
                )
                process_agent_events_task.delay(str(agent.pk))
            except ValueError as exc:
                self.message_user(
                    request,
                    str(exc) or "Cannot trigger proactive outreach for this agent.",
                    level=messages.ERROR,
                )
            except Exception:
                logging.exception("Failed to force proactive trigger for persistent agent %s", agent.pk)
                self.message_user(
                    request,
                    "Failed to trigger proactive outreach. Check logs for details.",
                    level=messages.ERROR,
                )
            else:
                self.message_user(
                    request,
                    "Forced proactive outreach queued for this agent.",
                    level=messages.SUCCESS,
                )
                return HttpResponseRedirect(reverse("admin:api_persistentagent_change", args=[object_id]))
        else:
            reason_value = ""

        context = {
            "title": "Force Proactive Outreach",
            "agent": agent,
            "reason": reason_value,
            "opts": self.model._meta,
            "original": agent,
        }
        return TemplateResponse(
            request,
            "admin/persistentagent_force_proactive.html",
            context,
        )

    def system_message_view(self, request, object_id):
        """Allow staff to inject a one-off system directive into the agent prompt."""
        try:
            agent = PersistentAgent.objects.get(pk=object_id)
        except PersistentAgent.DoesNotExist:
            self.message_user(request, "Agent not found", level=messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_persistentagent_changelist"))

        if request.method == "POST":
            form = SystemMessageForm(request.POST)
            if form.is_valid():
                message_body = form.cleaned_data["message"]
                PersistentAgentSystemMessage.objects.create(
                    agent=agent,
                    body=message_body,
                    created_by=request.user if request.user.is_authenticated else None,
                )
                queued = True
                try:
                    process_agent_events_task.delay(str(agent.pk))
                except Exception:
                    queued = False
                    logging.exception("Failed to queue event processing for persistent agent %s", agent.pk)

                if queued:
                    self.message_user(
                        request,
                        "System message saved and processing queued for delivery.",
                        level=messages.SUCCESS,
                    )
                else:
                    self.message_user(
                        request,
                        "System message saved, but event processing could not be queued. Trigger processing manually.",
                        level=messages.WARNING,
                    )
                return HttpResponseRedirect(reverse("admin:api_persistentagent_change", args=[object_id]))
        else:
            form = SystemMessageForm()

        context = {
            "title": "Issue System Message",
            "agent": agent,
            "form": form,
            "opts": self.model._meta,
            "original": agent,
        }
        return TemplateResponse(
            request,
            "admin/persistentagent_system_message.html",
            context,
        )

    def simulate_email_view(self, request, object_id):
        """Handle email simulation for an agent."""
        try:
            agent = PersistentAgent.objects.get(pk=object_id)
        except PersistentAgent.DoesNotExist:
            self.message_user(request, "Agent not found", messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_persistentagent_changelist"))

        if request.method == 'POST':
            from_address = request.POST.get('from_address', '').strip()
            subject = request.POST.get('subject', '').strip()
            body = request.POST.get('body', '').strip()
            attachments = request.FILES.getlist('attachments') if hasattr(request, 'FILES') else []
            
            # Validation
            if not from_address:
                self.message_user(request, "From address is required", messages.ERROR)
            elif not body:
                self.message_user(request, "Message body is required", messages.ERROR)
            else:
                try:
                    # Find agent's primary email endpoint
                    to_endpoint = PersistentAgentCommsEndpoint.objects.filter(
                        owner_agent=agent, 
                        channel=CommsChannel.EMAIL, 
                        is_primary=True
                    ).first()
                    
                    if not to_endpoint:
                        # Fallback to any email endpoint
                        to_endpoint = PersistentAgentCommsEndpoint.objects.filter(
                            owner_agent=agent, 
                            channel=CommsChannel.EMAIL
                        ).first()
                    
                    if not to_endpoint:
                        self.message_user(
                            request, 
                            "Agent has no email address configured. Please add one first.", 
                            messages.ERROR
                        )
                        return HttpResponseRedirect(reverse('admin:api_persistentagent_change', args=[object_id]))
                    
                    # Normalize through the same ingestion pipeline as webhooks
                    from api.agent.comms.adapters import ParsedMessage
                    from api.agent.comms.message_service import ingest_inbound_message

                    parsed = ParsedMessage(
                        sender=from_address,
                        recipient=to_endpoint.address,
                        subject=subject or "",
                        body=body,
                        attachments=list(attachments or []),  # file-like objects supported by ingest
                        raw_payload={"_source": "admin_simulation"},
                        msg_channel=CommsChannel.EMAIL,
                    )

                    msg_info = ingest_inbound_message(CommsChannel.EMAIL, parsed)
                    message = msg_info.message
                    
                    self.message_user(
                        request, 
                        f"Incoming email simulated successfully from {from_address}. "
                        f"Message ID: {message.id}. The agent will react as in production (including wake-up).",
                        messages.SUCCESS
                    )
                    
                except Exception as e:
                    self.message_user(
                        request, 
                        f"Error creating simulated email: {str(e)}", 
                        messages.ERROR
                    )
            
            return HttpResponseRedirect(reverse('admin:api_persistentagent_change', args=[object_id]))
        
        else:
            # Display form
            context = {
                **self.admin_site.each_context(request),
                'agent': agent,
                'title': f'Simulate Incoming Email for {agent.name}',
                'opts': self.model._meta,
            }
            return TemplateResponse(request, "admin/api/persistentagent/simulate_email.html", context)

    def simulate_sms_view(self, request, object_id):
        """Handle SMS simulation for an agent using the same ingestion pipeline as webhooks."""
        try:
            agent = PersistentAgent.objects.get(pk=object_id)
        except PersistentAgent.DoesNotExist:
            self.message_user(request, "Agent not found", messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_persistentagent_changelist"))

        if request.method == 'POST':
            from_address = request.POST.get('from_address', '').strip()
            body = request.POST.get('body', '').strip()
            attachments = list(request.FILES.getlist('attachments') or [])

            # Validation
            if not from_address:
                self.message_user(request, "From address is required", messages.ERROR)
            elif not body and not attachments:
                self.message_user(request, "Message body or attachment is required", messages.ERROR)
            else:
                try:
                    # Find agent's primary email endpoint
                    to_endpoint = PersistentAgentCommsEndpoint.objects.filter(
                        owner_agent=agent,
                        channel=CommsChannel.SMS,
                        is_primary=True
                    ).first()

                    if not to_endpoint:
                        # Fallback to any email endpoint
                        to_endpoint = PersistentAgentCommsEndpoint.objects.filter(
                            owner_agent=agent,
                            channel=CommsChannel.SMS
                        ).first()

                    if not to_endpoint:
                        self.message_user(
                            request,
                            "Agent has no SMS number configured. Please add one first.",
                            messages.ERROR
                        )
                        return HttpResponseRedirect(reverse('admin:api_persistentagent_change', args=[object_id]))

                    # Normalize through the same ingestion pipeline as webhooks
                    from api.agent.comms.adapters import ParsedMessage
                    from api.agent.comms.message_service import ingest_inbound_message

                    parsed = ParsedMessage(
                        sender=from_address,
                        recipient=to_endpoint.address,
                        subject=None,
                        body=body,
                        attachments=attachments,
                        raw_payload={"_source": "admin_simulation"},
                        msg_channel=CommsChannel.SMS,
                    )
                    msg_info = ingest_inbound_message(CommsChannel.SMS, parsed)
                    message = msg_info.message

                    self.message_user(
                        request,
                        f"Incoming SMS simulated successfully from {from_address}. "
                        f"Message ID: {message.id}. The agent will react as in production (including wake-up).",
                        messages.SUCCESS
                    )

                except Exception as e:
                    self.message_user(
                        request,
                        f"Error creating simulated SMS: {str(e)}",
                        messages.ERROR
                    )

            return HttpResponseRedirect(reverse('admin:api_persistentagent_change', args=[object_id]))

        else:
            # Display form
            context = {
                **self.admin_site.each_context(request),
                'agent': agent,
                'title': f'Simulate Incoming SMS for {agent.name}',
                'opts': self.model._meta,
            }
            return TemplateResponse(request, "admin/api/persistentagent/simulate_sms.html", context)

@admin.register(PersistentAgentSkill)
class PersistentAgentSkillAdmin(admin.ModelAdmin):
    list_display = ("name", "version", "agent", "global_skill", "updated_at", "created_at")
    list_filter = ("global_skill", "created_at", "updated_at")
    search_fields = (
        "name",
        "description",
        "instructions",
        "global_skill__name",
        "agent__name",
        "agent__user__email",
    )
    raw_id_fields = ("agent", "global_skill")
    ordering = ("name", "-version", "-updated_at")
    list_select_related = ("agent", "global_skill")


@admin.register(GlobalAgentSkill)
class GlobalAgentSkillAdmin(admin.ModelAdmin):
    change_list_template = "admin/globalagentskill_change_list.html"
    change_form_template = "admin/globalagentskill_change_form.html"
    list_display = ("name", "is_active", "updated_at", "created_at")
    list_filter = ("is_active", "created_at", "updated_at")
    search_fields = ("name", "description", "instructions")
    readonly_fields = ("created_at", "updated_at")
    ordering = ("name",)
    fields = ("name", "description", "tools", "secrets", "instructions", "is_active", "created_at", "updated_at")
    inlines = (GlobalAgentSkillCustomToolInline,)

    def get_urls(self):
        urls = super().get_urls()
        custom_urls = [
            path(
                "import-json/",
                self.admin_site.admin_view(self.import_json_view),
                name="api_globalagentskill_import_json",
            ),
            path(
                "<uuid:pk>/export-json/",
                self.admin_site.admin_view(self.export_json_view),
                name="api_globalagentskill_export_json",
            ),
        ]
        return custom_urls + urls

    def _add_import_form_errors(self, form, exc: ValidationError):
        message_dict = getattr(exc, "message_dict", None)
        if message_dict:
            for field, errors in message_dict.items():
                target_field = "json_file" if field == "__all__" else field
                for error in errors:
                    if target_field in form.fields:
                        form.add_error(target_field, error)
                    else:
                        form.add_error(None, f"{target_field}: {error}")
            return

        for error in exc.messages:
            form.add_error(None, error)

    def save_model(self, request, obj, form, change):
        super().save_model(request, obj, form, change)
        track_global_agent_skill_event(
            user_id=getattr(request.user, "id", None),
            event=(
                AnalyticsEvent.GLOBAL_AGENT_SKILL_UPDATED
                if change
                else AnalyticsEvent.GLOBAL_AGENT_SKILL_CREATED
            ),
            skill=obj,
            source=AnalyticsSource.WEB,
        )

    def delete_model(self, request, obj):
        skill = obj
        super().delete_model(request, obj)
        track_global_agent_skill_event(
            user_id=getattr(request.user, "id", None),
            event=AnalyticsEvent.GLOBAL_AGENT_SKILL_DELETED,
            skill=skill,
            source=AnalyticsSource.WEB,
        )

    def delete_queryset(self, request, queryset):
        skills = list(queryset)
        super().delete_queryset(request, queryset)
        for skill in skills:
            track_global_agent_skill_event(
                user_id=getattr(request.user, "id", None),
                event=AnalyticsEvent.GLOBAL_AGENT_SKILL_DELETED,
                skill=skill,
                source=AnalyticsSource.WEB,
            )

    def import_json_view(self, request):
        changelist_url = reverse("admin:api_globalagentskill_changelist")

        if not (self.has_add_permission(request) or self.has_change_permission(request)):
            self.message_user(request, "You do not have permission to import global skills.", level=messages.ERROR)
            return HttpResponseRedirect(changelist_url)

        form = GlobalAgentSkillImportForm(request.POST or None, request.FILES or None)
        context = dict(self.admin_site.each_context(request))
        context.update(
            {
                "opts": self.model._meta,
                "title": "Import Global Skill JSON",
                "form": form,
                "has_change_permission": self.has_change_permission(request),
            }
        )

        if request.method == "POST" and form.is_valid():
            upload = form.cleaned_data["json_file"]
            try:
                payload = parse_global_skill_json_bytes(upload.read())
            except ValidationError as exc:
                self._add_import_form_errors(form, exc)
            else:
                skill_name = str(payload.get("name") or "").strip()
                existing_skill = (
                    GlobalAgentSkill.objects.only("pk")
                    .filter(name=skill_name)
                    .first()
                    if skill_name
                    else None
                )

                if existing_skill is None and not self.has_add_permission(request):
                    form.add_error(None, "You do not have permission to create global skills.")
                elif existing_skill is not None and not self.has_change_permission(request, existing_skill):
                    form.add_error(None, "You do not have permission to update existing global skills.")
                else:
                    try:
                        skill, created = import_global_skill_from_payload(payload)
                    except ValidationError as exc:
                        self._add_import_form_errors(form, exc)
                    else:
                        track_global_agent_skill_event(
                            user_id=getattr(request.user, "id", None),
                            event=(
                                AnalyticsEvent.GLOBAL_AGENT_SKILL_CREATED
                                if created
                                else AnalyticsEvent.GLOBAL_AGENT_SKILL_UPDATED
                            ),
                            skill=skill,
                            source=AnalyticsSource.WEB,
                        )
                        self.message_user(
                            request,
                            (
                                f"Imported global skill '{skill.name}'."
                                if created
                                else f"Updated global skill '{skill.name}' from JSON."
                            ),
                            level=messages.SUCCESS,
                        )
                        return HttpResponseRedirect(reverse("admin:api_globalagentskill_change", args=[skill.pk]))

        return TemplateResponse(request, "admin/global_skill_import.html", context)

    def export_json_view(self, request, pk, *args, **kwargs):
        skill = self.get_object(request, str(pk))
        if not skill:
            self.message_user(request, "Global skill not found.", level=messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_globalagentskill_changelist"))
        if not self.has_view_or_change_permission(request, skill):
            self.message_user(request, "You do not have permission to export this global skill.", level=messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_globalagentskill_changelist"))

        try:
            payload = serialize_global_skill_to_json_bytes(skill)
        except ValidationError as exc:
            message = "; ".join(exc.messages) if exc.messages else str(exc)
            self.message_user(request, f"Unable to export global skill JSON: {message}", level=messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_globalagentskill_change", args=[skill.pk]))

        filename = get_valid_filename(skill.name) or "global-skill"
        response = HttpResponse(payload, content_type="application/json")
        response["Content-Disposition"] = f'attachment; filename="{filename}.json"'
        return response


@admin.register(PersistentAgentCommsEndpoint)
class PersistentAgentCommsEndpointAdmin(admin.ModelAdmin):
    list_display = (
        'address', 'channel', 'owner_agent_name', 'is_primary', 'message_count',
        'test_smtp_button', 'test_imap_button', 'poll_imap_now_button'
    )
    list_filter = ('channel', 'is_primary', 'owner_agent')
    search_fields = ('address', 'owner_agent__name', 'owner_agent__user__email')
    raw_id_fields = ('owner_agent',)
    readonly_fields = ('test_smtp_button',)

    class AgentEmailAccountInline(admin.StackedInline):
        model = AgentEmailAccount
        form = AgentEmailAccountForm
        extra = 0
        can_delete = True
        verbose_name = "Agent Email Account"
        verbose_name_plural = "Agent Email Account"
        fields = (
            # SMTP
            'smtp_host', 'smtp_port', 'smtp_security', 'smtp_auth', 'smtp_username', 'smtp_password', 'is_outbound_enabled',
            # IMAP
            'imap_host', 'imap_port', 'imap_security', 'imap_username', 'imap_password', 'imap_folder', 'is_inbound_enabled', 'imap_idle_enabled', 'poll_interval_sec',
            # Health
            'connection_last_ok_at', 'connection_error',
        )
        readonly_fields = ('connection_last_ok_at', 'connection_error')

        def has_add_permission(self, request, obj):
            # Allow create only for email endpoints owned by an agent
            if not obj:
                return False
            return obj.channel == CommsChannel.EMAIL and obj.owner_agent_id is not None

    inlines = [AgentEmailAccountInline]

    def get_urls(self):
        urls = super().get_urls()
        custom = [
            path('<path:object_id>/test-smtp/', self.admin_site.admin_view(self.test_smtp_view), name='api_endpoint_test_smtp'),
            path('<path:object_id>/test-imap/', self.admin_site.admin_view(self.test_imap_view), name='api_endpoint_test_imap'),
            path('<path:object_id>/poll-imap-now/', self.admin_site.admin_view(self.poll_imap_now_view), name='api_endpoint_poll_imap_now'),
        ]
        return custom + urls

    def owner_agent_name(self, obj):
        if obj.owner_agent:
            url = reverse("admin:api_persistentagent_change", args=[obj.owner_agent.pk])
            return format_html('<a href="{}">{}</a>', url, obj.owner_agent.name)
        return format_html('<em>{}</em>', "External")
    owner_agent_name.short_description = "Owner Agent"
    owner_agent_name.admin_order_field = 'owner_agent__name'

    def message_count(self, obj):
        sent_count = obj.messages_sent.count()
        received_count = obj.messages_received.count()
        total = sent_count + received_count
        return f"{total} ({sent_count} sent, {received_count} received)"
    message_count.short_description = "Messages"

    @admin.display(description='Test SMTP')
    def test_smtp_button(self, obj):
        if obj.channel == CommsChannel.EMAIL and obj.owner_agent_id:
            url = reverse('admin:api_endpoint_test_smtp', args=[obj.pk])
            return format_html('<a class="button" href="{}">Test SMTP</a>', url)
        return '—'

    @admin.display(description='Test IMAP')
    def test_imap_button(self, obj):
        if obj.channel == CommsChannel.EMAIL and obj.owner_agent_id:
            url = reverse('admin:api_endpoint_test_imap', args=[obj.pk])
            return format_html('<a class="button" href="{}">Test IMAP</a>', url)
        return '—'

    @admin.display(description='Poll IMAP Now')
    def poll_imap_now_button(self, obj):
        if obj.channel == CommsChannel.EMAIL and obj.owner_agent_id:
            url = reverse('admin:api_endpoint_poll_imap_now', args=[obj.pk])
            return format_html('<a class="button" href="{}">Poll Now</a>', url)
        return '—'

    def test_smtp_view(self, request, object_id):
        try:
            endpoint = PersistentAgentCommsEndpoint.objects.select_related('owner_agent').get(pk=object_id)
        except PersistentAgentCommsEndpoint.DoesNotExist:
            self.message_user(request, "Endpoint not found", messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_persistentagentcommsendpoint_changelist"))

        if endpoint.channel != CommsChannel.EMAIL or not endpoint.owner_agent_id:
            self.message_user(request, "Test SMTP is only available for agent-owned email endpoints.", messages.ERROR)
            return HttpResponseRedirect(reverse('admin:api_persistentagentcommsendpoint_change', args=[object_id]))

        acct = getattr(endpoint, 'agentemailaccount', None)
        if not acct:
            self.message_user(request, "No Agent Email Account configured for this endpoint.", messages.ERROR)
            return HttpResponseRedirect(reverse('admin:api_persistentagentcommsendpoint_change', args=[object_id]))

        # Attempt connection
        try:
            import smtplib
            # Choose client
            if acct.smtp_security == AgentEmailAccount.SmtpSecurity.SSL:
                client = smtplib.SMTP_SSL(acct.smtp_host, int(acct.smtp_port or 465), timeout=30)
            else:
                client = smtplib.SMTP(acct.smtp_host, int(acct.smtp_port or 587), timeout=30)
            try:
                client.ehlo()
                if acct.smtp_security == AgentEmailAccount.SmtpSecurity.STARTTLS:
                    client.starttls()
                    client.ehlo()
                if acct.smtp_auth == AgentEmailAccount.AuthMode.OAUTH2:
                    from api.agent.comms.email_oauth import build_xoauth2_string, resolve_oauth_identity_and_token
                    identity, access_token, _credential = resolve_oauth_identity_and_token(acct, "smtp")
                    auth_string = build_xoauth2_string(identity, access_token)
                    client.auth("XOAUTH2", lambda _: auth_string)
                elif acct.smtp_auth != AgentEmailAccount.AuthMode.NONE:
                    client.login(acct.smtp_username or '', acct.get_smtp_password() or '')
                # Try NOOP
                try:
                    client.noop()
                except Exception:
                    pass
            finally:
                try:
                    client.quit()
                except Exception:
                    try:
                        client.close()
                    except Exception:
                        pass

            # Success
            acct.connection_last_ok_at = timezone.now()
            acct.connection_error = ""
            acct.save(update_fields=['connection_last_ok_at', 'connection_error'])
            self.message_user(request, "SMTP connection test succeeded.", messages.SUCCESS)
            # Analytics: SMTP Test Passed
            try:
                user_id = getattr(getattr(endpoint.owner_agent, 'user', None), 'id', None)
                if user_id:
                    Analytics.track_event(
                        user_id=user_id,
                        event=AnalyticsEvent.SMTP_TEST_PASSED,
                        source=AnalyticsSource.WEB,
                        properties={
                            'endpoint': endpoint.address,
                        },
                    )
            except Exception:
                pass
        except Exception as e:
            acct.connection_error = str(e)
            acct.save(update_fields=['connection_error'])
            self.message_user(request, f"SMTP connection test failed: {e}", messages.ERROR)
            # Analytics: SMTP Test Failed
            try:
                user_id = getattr(getattr(endpoint.owner_agent, 'user', None), 'id', None)
                if user_id:
                    Analytics.track_event(
                        user_id=user_id,
                        event=AnalyticsEvent.SMTP_TEST_FAILED,
                        source=AnalyticsSource.WEB,
                        properties={
                            'endpoint': endpoint.address,
                            'error': str(e)[:500],
                        },
                    )
            except Exception:
                pass

        return HttpResponseRedirect(reverse('admin:api_persistentagentcommsendpoint_change', args=[object_id]))

    def test_imap_view(self, request, object_id):
        try:
            endpoint = PersistentAgentCommsEndpoint.objects.select_related('owner_agent').get(pk=object_id)
        except PersistentAgentCommsEndpoint.DoesNotExist:
            self.message_user(request, "Endpoint not found", messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_persistentagentcommsendpoint_changelist"))

        if endpoint.channel != CommsChannel.EMAIL or not endpoint.owner_agent_id:
            self.message_user(request, "Test IMAP is only available for agent-owned email endpoints.", messages.ERROR)
            return HttpResponseRedirect(reverse('admin:api_persistentagentcommsendpoint_change', args=[object_id]))

        acct = getattr(endpoint, 'agentemailaccount', None)
        if not acct:
            self.message_user(request, "No Agent Email Account configured for this endpoint.", messages.ERROR)
            return HttpResponseRedirect(reverse('admin:api_persistentagentcommsendpoint_change', args=[object_id]))

        # Attempt IMAP connection
        try:
            import imaplib
            if acct.imap_security == AgentEmailAccount.ImapSecurity.SSL:
                client = imaplib.IMAP4_SSL(acct.imap_host, int(acct.imap_port or 993), timeout=30)
            else:
                client = imaplib.IMAP4(acct.imap_host, int(acct.imap_port or 143), timeout=30)
                if acct.imap_security == AgentEmailAccount.ImapSecurity.STARTTLS:
                    client.starttls()
            try:
                if acct.imap_auth == AgentEmailAccount.ImapAuthMode.OAUTH2:
                    from api.agent.comms.email_oauth import build_xoauth2_string, resolve_oauth_identity_and_token
                    identity, access_token, _credential = resolve_oauth_identity_and_token(acct, "imap")
                    auth_string = build_xoauth2_string(identity, access_token)
                    client.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
                elif acct.imap_auth != AgentEmailAccount.ImapAuthMode.NONE:
                    client.login(acct.imap_username or '', acct.get_imap_password() or '')
                client.select(acct.imap_folder or 'INBOX', readonly=True)
                try:
                    client.noop()
                except Exception:
                    pass
            finally:
                try:
                    client.logout()
                except Exception:
                    try:
                        client.shutdown()
                    except Exception:
                        pass

            acct.connection_last_ok_at = timezone.now()
            acct.connection_error = ""
            acct.save(update_fields=['connection_last_ok_at', 'connection_error'])
            self.message_user(request, "IMAP connection test succeeded.", messages.SUCCESS)
            # Analytics: IMAP Test Passed
            try:
                user_id = getattr(getattr(endpoint.owner_agent, 'user', None), 'id', None)
                if user_id:
                    Analytics.track_event(
                        user_id=user_id,
                        event=AnalyticsEvent.IMAP_TEST_PASSED,
                        source=AnalyticsSource.WEB,
                        properties={
                            'endpoint': endpoint.address,
                        },
                    )
            except Exception:
                pass
        except Exception as e:
            acct.connection_error = str(e)
            acct.save(update_fields=['connection_error'])
            self.message_user(request, f"IMAP connection test failed: {e}", messages.ERROR)
            # Analytics: IMAP Test Failed
            try:
                user_id = getattr(getattr(endpoint.owner_agent, 'user', None), 'id', None)
                if user_id:
                    Analytics.track_event(
                        user_id=user_id,
                        event=AnalyticsEvent.IMAP_TEST_FAILED,
                        source=AnalyticsSource.WEB,
                        properties={
                            'endpoint': endpoint.address,
                            'error': str(e)[:500],
                        },
                    )
            except Exception:
                pass

        return HttpResponseRedirect(reverse('admin:api_persistentagentcommsendpoint_change', args=[object_id]))

    def poll_imap_now_view(self, request, object_id):
        try:
            endpoint = PersistentAgentCommsEndpoint.objects.select_related('owner_agent').get(pk=object_id)
        except PersistentAgentCommsEndpoint.DoesNotExist:
            self.message_user(request, "Endpoint not found", messages.ERROR)
            return HttpResponseRedirect(reverse("admin:api_persistentagentcommsendpoint_changelist"))

        acct = getattr(endpoint, 'agentemailaccount', None)
        if not acct:
            self.message_user(request, "No Agent Email Account configured for this endpoint.", messages.ERROR)
            return HttpResponseRedirect(reverse('admin:api_persistentagentcommsendpoint_change', args=[object_id]))

        try:
            from api.agent.tasks import poll_imap_inbox
            poll_imap_inbox.delay(str(acct.pk))
            self.message_user(request, "IMAP poll enqueued.", messages.SUCCESS)
        except Exception as e:
            self.message_user(request, f"Failed to enqueue IMAP poll: {e}", messages.ERROR)

        return HttpResponseRedirect(reverse('admin:api_persistentagentcommsendpoint_change', args=[object_id]))


@admin.register(PersistentAgentSystemMessage)
class PersistentAgentSystemMessageAdmin(admin.ModelAdmin):
    list_display = ("agent", "created_by", "created_at", "delivered_at", "is_active", "broadcast")
    list_filter = ("is_active", "delivered_at")
    search_fields = ("agent__name", "agent__user__email", "body")
    raw_id_fields = ("agent", "created_by")
    readonly_fields = ("created_at", "delivered_at")
    ordering = ("-created_at",)


class PersistentAgentSystemMessageBroadcastForm(forms.ModelForm):
    class Meta:
        model = PersistentAgentSystemMessageBroadcast
        fields = ("body",)
        widgets = {
            "body": forms.Textarea(attrs={"rows": 5, "cols": 80}),
        }
        help_texts = {
            "body": "This directive will be duplicated for every persistent agent's system prompt.",
        }


@admin.register(PersistentAgentSystemMessageBroadcast)
class PersistentAgentSystemMessageBroadcastAdmin(admin.ModelAdmin):
    form = PersistentAgentSystemMessageBroadcastForm
    list_display = ("body_preview", "created_by", "created_at", "message_count")
    search_fields = ("body", "created_by__email")
    readonly_fields = ("created_by", "created_at", "message_count")
    ordering = ("-created_at",)
    change_form_template = "admin/persistentagentsystemmessagebroadcast_change_form.html"

    def get_queryset(self, request):
        qs = super().get_queryset(request)
        return qs.annotate(_message_count=Count("system_messages"))

    def get_fields(self, request, obj=None):
        if obj:
            return ("body", "created_by", "created_at", "message_count")
        return ("body",)

    @admin.display(description="Messages")
    def message_count(self, obj):
        return getattr(obj, "_message_count", None) or obj.system_messages.count()

    @admin.display(description="Preview")
    def body_preview(self, obj):
        return (obj.body[:60] + "…") if len(obj.body) > 60 else obj.body

    def render_change_form(self, request, context, add=False, change=False, form_url="", obj=None):
        context = context or {}
        context["agent_count"] = PersistentAgent.objects.count()
        return super().render_change_form(request, context, add=add, change=change, form_url=form_url, obj=obj)

    def save_model(self, request, obj, form, change):
        if change:
            if "body" not in form.changed_data:
                super().save_model(request, obj, form, change)
                self.message_user(request, "No changes detected. Broadcast not updated.", level=messages.INFO)
                return

            with transaction.atomic():
                super().save_model(request, obj, form, change)
                updated = PersistentAgentSystemMessage.objects.filter(
                    broadcast=obj,
                    delivered_at__isnull=True,
                ).update(body=obj.body)

            plural = "s" if updated != 1 else ""
            self.message_user(
                request,
                f"Broadcast updated and propagated to {updated} pending system message{plural}. Event processing was not triggered automatically.",
                level=messages.SUCCESS,
            )
            return

        agent_qs = PersistentAgent.objects.order_by("id").values_list("id", flat=True)
        agent_count = agent_qs.count()
        if agent_count == 0:
            self.message_user(
                request,
                "No persistent agents exist to receive this broadcast.",
                level=messages.WARNING,
            )
            return

        created_by = request.user if request.user.is_authenticated else None
        obj.created_by = created_by

        def _batched(iterable, size):
            batch = []
            for item in iterable:
                batch.append(item)
                if len(batch) == size:
                    yield batch
                    batch = []
            if batch:
                yield batch

        with transaction.atomic():
            super().save_model(request, obj, form, change)
            agent_iterator = agent_qs.iterator(chunk_size=1000)
            for agent_batch in _batched(agent_iterator, 500):
                system_messages = [
                    PersistentAgentSystemMessage(
                        agent_id=agent_id,
                        body=obj.body,
                        created_by=created_by,
                        broadcast=obj,
                    )
                    for agent_id in agent_batch
                ]
                PersistentAgentSystemMessage.objects.bulk_create(system_messages, batch_size=500)

        self.message_user(
            request,
            f"Broadcast saved for {agent_count} persistent agents. Event processing was not triggered automatically.",
            level=messages.SUCCESS,
        )


@admin.register(PersistentAgentMessage) 
class PersistentAgentMessageAdmin(admin.ModelAdmin):
    list_display = ('timestamp', 'owner_agent_link', 'direction_icon', 'from_address', 'to_address', 'body_summary', 'latest_status', 'conversation_link')
    list_filter = ('is_outbound', 'latest_status', 'timestamp', 'owner_agent', 'from_endpoint__channel')
    search_fields = ('body', 'from_endpoint__address', 'to_endpoint__address', 'owner_agent__name')
    readonly_fields = ('id', 'seq', 'timestamp', 'owner_agent', 'peer_agent', 'latest_sent_at')
    raw_id_fields = ('from_endpoint', 'to_endpoint', 'conversation', 'parent')
    date_hierarchy = 'timestamp'
    ordering = ('-timestamp',)

    def get_queryset(self, request):
        """Optimize with select_related to prevent N+1 queries."""
        qs = super().get_queryset(request)
        return qs.select_related("owner_agent", "from_endpoint", "to_endpoint", "peer_agent")

    fieldsets = (
        ('Message Information', {
            'fields': ('id', 'seq', 'timestamp', 'is_outbound', 'body')
        }),
        ('Routing', {
            'fields': ('from_endpoint', 'to_endpoint', 'conversation', 'parent', 'owner_agent', 'peer_agent')
        }),
        ('Delivery Status', {
            'fields': ('latest_status', 'latest_sent_at', 'latest_error_message'),
            'classes': ('collapse',)
        }),
        ('Raw Data', {
            'fields': ('raw_payload',),
            'classes': ('collapse',)
        }),
    )

    def owner_agent_link(self, obj):
        if obj.owner_agent:
            url = reverse("admin:api_persistentagent_change", args=[obj.owner_agent.pk])
            return format_html('<a href="{}">{}</a>', url, obj.owner_agent.name)
        return "-"
    owner_agent_link.short_description = "Agent"
    owner_agent_link.admin_order_field = 'owner_agent__name'

    def direction_icon(self, obj):
        if obj.is_outbound:
            return format_html('<span style="color: blue; font-weight: bold;">{}</span>', "→")
        else:
            return format_html('<span style="color: green; font-weight: bold;">{}</span>', "←")
    direction_icon.short_description = "Dir"

    def from_address(self, obj):
        return obj.from_endpoint.address if obj.from_endpoint else "Unknown"
    from_address.short_description = "From"
    from_address.admin_order_field = 'from_endpoint__address'

    def to_address(self, obj):
        return obj.to_endpoint.address if obj.to_endpoint else "Conversation"
    to_address.short_description = "To"

    def body_summary(self, obj):
        if obj.body:
            clean_body = obj.body.replace('\n', ' ').strip()
            return (clean_body[:100] + '...') if len(clean_body) > 100 else clean_body
        return "-"
    body_summary.short_description = "Message"

    def conversation_link(self, obj):
        if obj.conversation:
            url = reverse("admin:api_persistentagentconversation_change", args=[obj.conversation.pk])
            return format_html('<a href="{}">View</a>', url)
        return "-"
    conversation_link.short_description = "Thread"


@admin.register(PersistentAgentEmailFooter)
class PersistentAgentEmailFooterAdmin(admin.ModelAdmin):
    list_display = ("name", "is_active", "updated_at")
    list_filter = ("is_active",)
    search_fields = ("name", "html_content", "text_content")
    ordering = ("name",)
    readonly_fields = ("created_at", "updated_at")


@admin.register(AgentPeerLink)
class AgentPeerLinkAdmin(admin.ModelAdmin):
    list_display = (
        'agents_display',
        'is_enabled',
        'quota_display',
        'feature_flag',
        'created_at',
        'updated_at',
    )
    list_filter = ('is_enabled',)
    search_fields = (
        'agent_a__name',
        'agent_a__user__email',
        'agent_b__name',
        'agent_b__user__email',
        'pair_key',
    )
    autocomplete_fields = (
        'agent_a',
        'agent_b',
        'agent_a_endpoint',
        'agent_b_endpoint',
        'created_by',
    )
    readonly_fields = (
        'pair_key',
        'created_at',
        'updated_at',
        'conversation_link',
    )
    fieldsets = (
        ('Agents', {
            'fields': ('agent_a', 'agent_b', 'created_by', 'pair_key', 'conversation_link')
        }),
        ('Quota', {
            'fields': ('messages_per_window', 'window_hours', 'is_enabled', 'feature_flag')
        }),
        ('Preferred Endpoints', {
            'fields': ('agent_a_endpoint', 'agent_b_endpoint')
        }),
    )

    @admin.display(description='Agents')
    def agents_display(self, obj):
        agent_a_name = getattr(obj.agent_a, 'name', '—')
        agent_b_name = getattr(obj.agent_b, 'name', '—')
        return format_html('{} &harr; {}', agent_a_name, agent_b_name)

    @admin.display(description='Quota')
    def quota_display(self, obj):
        return f"{obj.messages_per_window} / {obj.window_hours}h"

    @admin.display(description='Conversation')
    def conversation_link(self, obj):
        conversation = getattr(obj, 'conversation', None)
        if conversation:
            url = reverse("admin:api_persistentagentconversation_change", args=[conversation.pk])
            return format_html('<a href="{}">Open thread</a>', url)
        return "—"


@admin.register(AgentCommPeerState)
class AgentCommPeerStateAdmin(admin.ModelAdmin):
    list_display = (
        'link',
        'channel',
        'messages_per_window',
        'window_hours',
        'credits_remaining',
        'window_reset_at',
        'last_message_at',
    )
    list_filter = ('channel',)
    search_fields = (
        'link__agent_a__name',
        'link__agent_b__name',
        'link__pair_key',
    )
    autocomplete_fields = ('link',)
    readonly_fields = ('created_at', 'updated_at')
    fieldsets = (
        (None, {
            'fields': (
                'link',
                'channel',
                'messages_per_window',
                'window_hours',
                'credits_remaining',
                'window_reset_at',
                'last_message_at',
                'debounce_seconds',
                'created_at',
                'updated_at',
            )
        }),
    )


@admin.register(PersistentAgentConversation)
class PersistentAgentConversationAdmin(admin.ModelAdmin):
    list_display = ('display_name_or_address', 'channel', 'owner_agent_link', 'message_count', 'participant_count', 'latest_message_date')
    list_filter = ('channel', 'owner_agent')
    search_fields = ('address', 'display_name', 'owner_agent__name')
    raw_id_fields = ('owner_agent',)

    def display_name_or_address(self, obj):
        return obj.display_name if obj.display_name else obj.address
    display_name_or_address.short_description = "Conversation"
    display_name_or_address.admin_order_field = 'address'

    def owner_agent_link(self, obj):
        if obj.owner_agent:
            url = reverse("admin:api_persistentagent_change", args=[obj.owner_agent.pk])
            return format_html('<a href="{}">{}</a>', url, obj.owner_agent.name)
        return "-"
    owner_agent_link.short_description = "Agent"
    owner_agent_link.admin_order_field = 'owner_agent__name'

    def message_count(self, obj):
        count = obj.messages.count()
        if count > 0:
            url = reverse("admin:api_persistentagentmessage_changelist") + f"?conversation__id__exact={obj.pk}"
            return format_html('<a href="{}">{} messages</a>', url, count)
        return "0"
    message_count.short_description = "Messages"

    def participant_count(self, obj):
        return obj.participants.count()
    participant_count.short_description = "Participants"

    def latest_message_date(self, obj):
        latest = obj.messages.order_by('-timestamp').first()
        return latest.timestamp if latest else None
    latest_message_date.short_description = "Latest Message"
    latest_message_date.admin_order_field = 'messages__timestamp'


@admin.register(PersistentAgentStep)
class PersistentAgentStepAdmin(admin.ModelAdmin):
    list_display = ('agent_link', 'description_preview', 'credits_cost', 'task_credit_link', 'created_at', 'step_type')
    list_filter = ('agent', 'created_at')
    search_fields = ('description', 'agent__name')
    readonly_fields = ('id', 'created_at', 'credits_cost', 'task_credit')
    raw_id_fields = ('agent', 'task_credit')
    date_hierarchy = 'created_at'
    ordering = ('-created_at',)

    def get_queryset(self, request):
        qs = super().get_queryset(request)
        return qs.select_related('agent', 'task_credit')

    def agent_link(self, obj):
        url = reverse("admin:api_persistentagent_change", args=[obj.agent.pk])
        return format_html('<a href="{}">{}</a>', url, obj.agent.name)
    agent_link.short_description = "Agent"
    agent_link.admin_order_field = 'agent__name'

    def description_preview(self, obj):
        if obj.description:
            preview = obj.description.replace('\n', ' ').strip()
            return (preview[:150] + '...') if len(preview) > 150 else preview
        return "-"
    description_preview.short_description = "Description"

    def step_type(self, obj):
        if hasattr(obj, 'tool_call'):
            return format_html('<span style="color: blue;">{}</span>', "Tool Call")
        elif hasattr(obj, 'cron_trigger'):
            return format_html('<span style="color: green;">{}</span>', "Cron")
        elif hasattr(obj, 'system_step'):
            return format_html('<span style="color: orange;">{}</span>', "System")
        else:
            return format_html('<span style="color: gray;">{}</span>', "General")
    step_type.short_description = "Type"

    @admin.display(description='Task Credit')
    def task_credit_link(self, obj):
        if obj.task_credit_id:
            url = reverse("admin:api_taskcredit_change", args=[obj.task_credit_id])
            return format_html('<a href="{}">{}</a>', url, obj.task_credit_id)
        return "-"


@admin.register(PersistentAgentPromptArchive)
class PersistentAgentPromptArchiveAdmin(admin.ModelAdmin):
    list_display = (
        "agent_link",
        "rendered_at",
        "tokens_before",
        "tokens_after",
        "tokens_saved",
        "compressed_bytes",
        "download_link",
    )
    readonly_fields = (
        "agent",
        "rendered_at",
        "storage_key",
        "raw_bytes",
        "compressed_bytes",
        "tokens_before",
        "tokens_after",
        "tokens_saved",
        "created_at",
    )
    search_fields = ("agent__name", "agent__user__email", "storage_key")
    list_filter = ("agent",)
    date_hierarchy = "rendered_at"
    ordering = ("-rendered_at",)
    list_select_related = ("agent",)

    def get_urls(self):
        urls = super().get_urls()
        custom_urls = [
            path(
                "<uuid:pk>/download/",
                self.admin_site.admin_view(self.download_view),
                name="api_persistentagentpromptarchive_download",
            ),
        ]
        return custom_urls + urls

    def agent_link(self, obj):
        url = reverse("admin:api_persistentagent_change", args=[obj.agent.pk])
        return format_html('<a href="{}">{}</a>', url, obj.agent.name)
    agent_link.short_description = "Agent"
    agent_link.admin_order_field = "agent__name"

    def download_link(self, obj):
        url = reverse("admin:api_persistentagentpromptarchive_download", args=[obj.pk])
        return format_html('<a href="{}">Download</a>', url)
    download_link.short_description = "Prompt"

    def download_view(self, request, pk, *args, **kwargs):
        archive = self.get_object(request, pk)
        changelist_url = reverse("admin:api_persistentagentpromptarchive_changelist")
        if not archive:
            self.message_user(request, "Prompt archive not found.", level=messages.ERROR)
            return HttpResponseRedirect(changelist_url)
        if not default_storage.exists(archive.storage_key):
            self.message_user(
                request,
                "Archived prompt payload is missing from storage.",
                level=messages.ERROR,
            )
            return HttpResponseRedirect(changelist_url)

        filename = archive.storage_key.rsplit("/", 1)[-1] or f"{archive.pk}.json.zst"
        if filename.endswith(".zst"):
            download_name = filename[:-4]
        else:
            download_name = filename
        if "." not in download_name:
            download_name += ".json"

        def content_stream():
            with default_storage.open(archive.storage_key, "rb") as stored:
                dctx = zstd.ZstdDecompressor()
                with dctx.stream_reader(stored) as reader:
                    while True:
                        chunk = reader.read(65536)
                        if not chunk:
                            break
                        yield chunk

        response = StreamingHttpResponse(content_stream(), content_type="application/json")
        response["Content-Disposition"] = f'attachment; filename="{download_name}"'
        return response

@admin.register(UserBilling)
class UserBillingAdmin(admin.ModelAdmin):
    list_select_related = ('user', 'plan_version', 'plan_version__plan')
    autocomplete_fields = ('plan_version',)
    list_display = [
        'id',
        'user_id',
        'user',
        'subscription',
        'plan_version',
        'max_extra_tasks',
        'max_contacts_per_agent',
        'billing_cycle_anchor',
        'execution_paused',
        'execution_pause_reason',
        'execution_pause_resume_at',
    ]
    list_filter = ['subscription', 'user_id', 'execution_paused', 'execution_pause_reason']
    search_fields = [
        'id',
        'subscription',
        'user__email',
        'user__username',
        'plan_version__plan__slug',
        'plan_version__version_code',
        'plan_version__legacy_plan_code',
    ]
    readonly_fields = ['id', 'user']
    fieldsets = (
        (None, {
            'fields': (
                'id',
                'user',
                'subscription',
                'plan_version',
                'billing_cycle_anchor',
                'downgraded_at',
            )
        }),
        ('Contact and Task Limits', {
            'fields': ('max_extra_tasks', 'max_contacts_per_agent'),
        }),
        ('Execution Control', {
            'fields': (
                'execution_paused',
                'execution_pause_reason',
                'execution_paused_at',
                'execution_pause_resume_at',
            ),
        }),
    )
    actions = [
        'align_anchor_from_stripe',
    ]

    @admin.action(description="Align anchor day with Stripe period start")
    def align_anchor_from_stripe(self, request, queryset):
        """Admin action: for selected UserBilling rows, set billing_cycle_anchor
        to the user's Stripe subscription current_period_start.day (when available).

        Skips rows without an active Stripe subscription.
        """
        from util.subscription_helper import get_active_subscription

        updated = 0
        skipped = 0
        errors = 0
        for ub in queryset.select_related('user'):
            try:
                sub = get_active_subscription(ub.user)
                if not sub or not getattr(sub.stripe_data, 'current_period_start', None):
                    skipped += 1
                    continue
                new_day = sub.stripe_data['current_period_start'].day
                if ub.billing_cycle_anchor != new_day:
                    ub.billing_cycle_anchor = new_day
                    ub.save(update_fields=["billing_cycle_anchor"])
                    updated += 1
                else:
                    skipped += 1
            except Exception as e:
                logging.error("Failed to align billing anchor for user %s: %s", ub.user.id, e)
                errors += 1

        self.message_user(
            request,
            f"Anchor alignment complete: updated={updated}, skipped={skipped}, errors={errors}",
            level=messages.INFO,
        )


@admin.register(OrganizationBilling)
class OrganizationBillingAdmin(admin.ModelAdmin):
    list_select_related = ('organization', 'plan_version', 'plan_version__plan')
    autocomplete_fields = ('plan_version',)
    list_display = [
        'id',
        'organization_id',
        'organization',
        'subscription',
        'plan_version',
        'billing_cycle_anchor',
        'stripe_customer_id',
        'stripe_subscription_id',
        'cancel_at',
        'cancel_at_period_end',
        'execution_paused',
        'execution_pause_reason',
        'execution_pause_resume_at',
    ]
    list_filter = ['subscription', 'cancel_at_period_end', 'execution_paused', 'execution_pause_reason']
    search_fields = [
        'id',
        'organization__name',
        'organization__id',
        'stripe_customer_id',
        'stripe_subscription_id',
        'plan_version__plan__slug',
        'plan_version__version_code',
        'plan_version__legacy_plan_code',
    ]
    readonly_fields = ['id', 'organization', 'created_at', 'updated_at']


@admin.action(description="Sync numbers from Twilio")
def sync_from_twilio(modeladmin, request, queryset):
    sync_twilio_numbers.delay()
    messages.success(request, "Background sync started – this may take a minute. Refresh the page to see updates.")


@admin.action(description="Retire selected numbers locally")
def retire_selected_sms_numbers(modeladmin, request, queryset):
    retired_count = 0
    already_retired_count = 0
    blocked_numbers = []

    for sms_number in queryset:
        try:
            changed = retire_sms_number(sms_number)
        except ValidationError:
            blocked_numbers.append(sms_number.phone_number)
            continue

        if changed:
            retired_count += 1
        else:
            already_retired_count += 1

    if retired_count:
        messages.success(
            request,
            f"Retired {retired_count} SMS number(s) locally. They will remain in history and will not be allocated again.",
        )
    if already_retired_count:
        messages.info(request, f"{already_retired_count} SMS number(s) were already retired.")
    if blocked_numbers:
        blocked_preview = ", ".join(blocked_numbers[:5])
        suffix = "..." if len(blocked_numbers) > 5 else ""
        messages.error(
            request,
            f"Could not retire {len(blocked_numbers)} SMS number(s) still assigned to SMS endpoints: {blocked_preview}{suffix}",
        )


@admin.register(SmsNumber)
class SmsNumberAdmin(admin.ModelAdmin):
    change_form_template = "admin/smsnumber_change_form.html"
    change_list_template = "admin/smsnumber_change_list.html"
    actions = [sync_from_twilio, retire_selected_sms_numbers]
    list_display = ('friendly_number', 'provider', 'is_active', 'released_at', 'in_use', 'country', 'created_at')
    list_filter = ('provider', 'is_active', 'created_at', 'released_at')
    search_fields = ('phone_number', 'provider')
    readonly_fields = (
        'id',
        'created_at',
        'updated_at',
        'provider',
        'is_sms_enabled',
        'is_mms_enabled',
        'messaging_service_sid',
        'extra',
        'released_at',
    )

    @admin.display(description="Phone", ordering="text")
    def friendly_number(self, obj):
        """Render +14155551234 → (415) 555-1234 or +1 415 555 1234."""
        import phonenumbers
        from phonenumbers import PhoneNumberFormat

        try:
            parsed = phonenumbers.parse(obj.phone_number, None)  # E.164 in, region auto-detected
            pretty = phonenumbers.format_number(
                parsed, PhoneNumberFormat.NATIONAL  # or INTERNATIONAL
            )

            return pretty
        except phonenumbers.NumberParseException:
            return  obj.phone_number # fall back if the data is malformed

    def get_queryset(self, request):
        qs = super().get_queryset(request)
        in_use_subquery = PersistentAgentCommsEndpoint.objects.filter(
            channel=CommsChannel.SMS,
            address__iexact=OuterRef('phone_number')
        )
        return qs.annotate(is_in_use=Exists(in_use_subquery))

    @admin.display(description="In Use", boolean=True, ordering="is_in_use")
    def in_use(self, obj):
        """Return True if any agent SMS endpoint uses this number."""
        return obj.is_in_use

    @staticmethod
    def _preview_items(items, limit=5, separator=", "):
        preview = separator.join(items[:limit])
        suffix = "..." if len(items) > limit else ""
        return f"{preview}{suffix}"

    fieldsets = (
        ('Basic Information', {
            'fields': ('id', 'phone_number', 'is_active', 'released_at')
        }),
        ('SMS/MMS Configuration', {
            'fields': ('is_sms_enabled', 'is_mms_enabled', 'messaging_service_sid', 'provider', 'extra')
        }),
        ('Location Information', {
            'fields': ('country', 'region')
        }),
        ('Metadata', {
            'fields': ('sid', 'created_at', 'updated_at'),
            'classes': ('collapse',)
        }),
    )

    def get_urls(self):
        urls = super().get_urls()
        extra = [
            path(
                "<path:object_id>/test/",
                self.admin_site.admin_view(self.test_sms_view),
                name="smsnumber_test_sms",
            ),
            path(
                "release/",
                self.admin_site.admin_view(self.release_view),
                name="smsnumber_release",
            ),
            path(
                "release-candidates/",
                self.admin_site.admin_view(self.release_candidates_view),
                name="smsnumber_release_candidates",
            ),
            path(
                "sync/",  # /admin/api/smsnumber/sync/
                self.admin_site.admin_view(self.sync_view),
                name="smsnumber_sync",
            )
        ]
        return extra + urls

    # ③ view that queues the task
    def sync_view(self, request):
        if not request.user.has_perm("api.change_smsnumber"):
            messages.error(request, "Permission denied.")
            return HttpResponseRedirect(reverse("admin:api_smsnumber_changelist"))

        sync_twilio_numbers.delay()
        messages.success(request, "Background sync started – refresh in a minute.")
        return HttpResponseRedirect(reverse("admin:api_smsnumber_changelist"))

    # 𝟑𝗮  view
    def test_sms_view(self, request, object_id):
        sms_number = self.get_object(request, object_id)

        if request.method == "POST":
            form = TestSmsForm(request.POST)
            if form.is_valid():
                send_test_sms.delay(
                    sms_number.id,
                    form.cleaned_data["to"],
                    form.cleaned_data["body"],
                )
                messages.success(request, "Test SMS queued – check your phone!")
                return HttpResponseRedirect(
                    reverse("admin:api_smsnumber_change", args=[object_id])
                )
        else:
            form = TestSmsForm()

        context = dict(
            self.admin_site.each_context(request),
            opts=self.model._meta,
            form=form,
            title=f"Send test SMS from {sms_number.phone_number}",
            original=sms_number,
        )

        return TemplateResponse(request, "admin/test_sms_form.html", context)

    def changelist_view(self, request, extra_context=None):
        """Inject counts of numbers in use for the change list template."""
        if extra_context is None:
            extra_context = {}

        inventory_numbers_qs = SmsNumber.objects.filter(released_at__isnull=True)
        in_use_numbers_qs = PersistentAgentCommsEndpoint.objects.filter(
            channel=CommsChannel.SMS,
        ).values("address")

        extra_context["in_use_count"] = inventory_numbers_qs.filter(
            phone_number__in=in_use_numbers_qs,
        ).count()
        extra_context["inventory_count"] = inventory_numbers_qs.count()
        extra_context["released_count"] = SmsNumber.objects.filter(released_at__isnull=False).count()

        return super().changelist_view(request, extra_context=extra_context)

    def release_view(self, request):
        changelist_url = reverse("admin:api_smsnumber_changelist")
        if not request.user.has_perm("api.change_smsnumber"):
            messages.error(request, "Permission denied.")
            return HttpResponseRedirect(changelist_url)

        initial = {}
        if request.method != "POST":
            prefilled_numbers = (request.GET.get("phone_numbers") or "").strip()
            if not prefilled_numbers and request.GET.get("prefill") == SMS_RELEASE_CANDIDATES_PREFILL_SOURCE:
                prefilled_numbers = (
                    request.session.get(SMS_RELEASE_CANDIDATES_SESSION_KEY, "")
                ).strip()
            if prefilled_numbers:
                initial["phone_numbers"] = prefilled_numbers

        form = ReleaseSmsNumbersForm(request.POST or None, initial=initial)
        if request.method == "POST" and form.is_valid():
            requested_numbers = form.cleaned_data["phone_numbers"]
            sms_numbers_by_phone = {
                sms_number.phone_number: sms_number
                for sms_number in SmsNumber.objects.filter(phone_number__in=requested_numbers)
            }

            released_results = []
            partial_results = []
            missing_numbers = []
            validation_errors = []
            detached_endpoint_total = 0

            for phone_number in requested_numbers:
                sms_number = sms_numbers_by_phone.get(phone_number)
                if sms_number is None:
                    missing_numbers.append(phone_number)
                    continue

                try:
                    result = release_sms_number(sms_number)
                except ValidationError as exc:
                    validation_errors.append(f"{phone_number}: {'; '.join(exc.messages)}")
                    continue

                detached_endpoint_total += result.detached_endpoint_count
                if result.succeeded:
                    released_results.append(result)
                else:
                    partial_results.append(result)

            if released_results:
                released_numbers = [result.phone_number for result in released_results]
                messages.success(
                    request,
                    f"Released {len(released_results)} SMS number(s) in Twilio: "
                    f"{self._preview_items(released_numbers)}",
                )

            if detached_endpoint_total:
                messages.info(
                    request,
                    f"Detached {detached_endpoint_total} SMS endpoint(s) from agents before release.",
                )

            if partial_results:
                partial_preview = self._preview_items(
                    [f"{result.phone_number} ({result.error})" for result in partial_results],
                    limit=3,
                    separator="; ",
                )
                messages.error(
                    request,
                    "Retired locally but failed to release in Twilio for "
                    f"{len(partial_results)} SMS number(s): {partial_preview}",
                )

            if missing_numbers:
                messages.error(
                    request,
                    f"Skipped {len(missing_numbers)} number(s) not found in SMS inventory: "
                    f"{self._preview_items(missing_numbers)}",
                )

            if validation_errors:
                messages.error(
                    request,
                    f"Could not release {len(validation_errors)} number(s): "
                    f"{self._preview_items(validation_errors, limit=3, separator=' | ')}",
                )

            if not any([released_results, partial_results, missing_numbers, validation_errors]):
                messages.warning(request, "No SMS numbers were released.")

            return HttpResponseRedirect(changelist_url)

        context = dict(
            self.admin_site.each_context(request),
            opts=self.model._meta,
            form=form,
            title="Release SMS Numbers",
            changelist_url=changelist_url,
        )

        return TemplateResponse(request, "admin/smsnumber_release_form.html", context)

    def release_candidates_view(self, request):
        changelist_url = reverse("admin:api_smsnumber_changelist")
        if not request.user.has_perm("api.view_smsnumber"):
            messages.error(request, "Permission denied.")
            return HttpResponseRedirect(changelist_url)

        form = FindReleaseCandidatesForm(request.GET or None)
        has_results = bool(request.GET)
        candidates = []
        if has_results and form.is_valid():
            candidates = find_sms_number_release_candidates(
                unused_days=form.cleaned_data["unused_days"],
                include_detached_unused=form.cleaned_data["include_detached_unused"],
                include_free_dormant_unused=form.cleaned_data["include_free_dormant_unused"],
            )

        grouped_candidates = {
            SmsNumberReleaseCandidate.DETACHED_UNUSED: [],
            SmsNumberReleaseCandidate.FREE_DORMANT_UNUSED: [],
        }
        for candidate in candidates:
            grouped_candidates.setdefault(candidate.tier, []).append(
                {
                    "phone_number": candidate.phone_number,
                    "friendly_name": candidate.friendly_name,
                    "last_activity_at": candidate.last_activity_at,
                    "owner_agent_name": candidate.owner_agent_name,
                    "owner_email": candidate.owner_email,
                    "owner_plan": candidate.owner_plan,
                    "sms_number_change_url": reverse("admin:api_smsnumber_change", args=[candidate.sms_number_id]),
                    "endpoint_change_url": (
                        reverse("admin:api_persistentagentcommsendpoint_change", args=[candidate.endpoint_id])
                        if candidate.endpoint_id
                        else ""
                    ),
                    "owner_agent_change_url": (
                        reverse("admin:api_persistentagent_change", args=[candidate.owner_agent_id])
                        if candidate.owner_agent_id
                        else ""
                    ),
                }
            )

        suggested_numbers = "\n".join(candidate.phone_number for candidate in candidates)
        release_review_url = ""
        if suggested_numbers:
            request.session[SMS_RELEASE_CANDIDATES_SESSION_KEY] = suggested_numbers
            release_review_url = (
                f"{reverse('admin:smsnumber_release')}?prefill={SMS_RELEASE_CANDIDATES_PREFILL_SOURCE}"
            )
        else:
            request.session.pop(SMS_RELEASE_CANDIDATES_SESSION_KEY, None)

        context = dict(
            self.admin_site.each_context(request),
            opts=self.model._meta,
            form=form,
            title="Find SMS Release Candidates",
            changelist_url=changelist_url,
            has_results=has_results,
            grouped_candidates=grouped_candidates,
            suggested_numbers=suggested_numbers,
            release_review_url=release_review_url,
        )

        return TemplateResponse(request, "admin/smsnumber_release_candidates.html", context)


@admin.register(LinkShortener)
class LinkShortenerAdmin(admin.ModelAdmin):
    list_display = ("code", "shortened", "url", "hits", "created_at")
    readonly_fields = ("hits", "shortened", "created_at", "updated_at")

    @admin.display(description="Short URL")
    def shortened(self, obj):
        try:
            return obj.get_absolute_url()
        except Exception:
            return f"/{obj.code}"


# --------------------------------------------------------------------------- #
#  LLM Provider + Endpoint Admin (DB-configurable LLM routing)
# --------------------------------------------------------------------------- #
from .models import (
    LLMProvider,
    PersistentModelEndpoint,
    PersistentTokenRange,
    PersistentLLMTier,
    PersistentTierEndpoint,
    EmbeddingsModelEndpoint,
    EmbeddingsLLMTier,
    EmbeddingsTierEndpoint,
    FileHandlerModelEndpoint,
    FileHandlerLLMTier,
    FileHandlerTierEndpoint,
    BrowserModelEndpoint,
    BrowserLLMPolicy,
    BrowserLLMTier,
    BrowserTierEndpoint,
    # Routing Profiles
    LLMRoutingProfile,
    ProfileTokenRange,
    ProfilePersistentTier,
    ProfilePersistentTierEndpoint,
    ProfileBrowserTier,
    ProfileBrowserTierEndpoint,
    ProfileEmbeddingsTier,
    ProfileEmbeddingsTierEndpoint,
)


from .admin_forms import LLMProviderForm


@admin.register(LLMProvider)
class LLMProviderAdmin(admin.ModelAdmin):
    form = LLMProviderForm
    list_display = ("display_name", "key", "enabled", "_key_source", "browser_backend")
    list_filter = ("enabled", "browser_backend")
    search_fields = ("display_name", "key", "env_var_name")
    readonly_fields = ("_key_source",)

    def get_readonly_fields(self, request, obj=None):
        # Only show Key Source after the object exists
        if obj is None:
            return tuple()
        return super().get_readonly_fields(request, obj)

    def get_fieldsets(self, request, obj=None):
        base = [
            (None, {"fields": ("display_name", "key", "enabled")}),
            ("Credentials", {"fields": ("api_key", "clear_api_key", "env_var_name")}),
            ("Provider Options", {"fields": ("browser_backend", "model_prefix", "supports_safety_identifier")}),
            ("Vertex (Google)", {"fields": ("vertex_project", "vertex_location")}),
        ]
        if obj is not None:
            # Append Key Source in credentials when editing existing provider
            base[1][1]["fields"] = ("api_key", "clear_api_key", "env_var_name", "_key_source")
        return base

    def _key_source(self, obj):
        if obj.api_key_encrypted:
            return "Admin"
        if obj.env_var_name:
            import os
            return "Env" if os.getenv(obj.env_var_name) else "Missing"
        return "Missing"
    _key_source.short_description = "Key Source"


@admin.register(PersistentModelEndpoint)
class PersistentModelEndpointAdmin(admin.ModelAdmin):
    list_display = (
        "key",
        "provider",
        "litellm_model",
        "api_base",
        "openrouter_preset",
        "max_input_tokens",
        "enabled",
        "low_latency",
        "supports_temperature",
        "supports_tool_choice",
        "use_parallel_tool_calls",
        "allow_implied_send",
        "supports_vision",
        "supports_reasoning",
        "reasoning_effort",
    )
    list_filter = ("enabled", "low_latency", "provider", "allow_implied_send", "supports_vision", "supports_reasoning")
    search_fields = ("key", "litellm_model")
    fields = (
        "key",
        "provider",
        "enabled",
        "low_latency",
        "litellm_model",
        "api_base",
        "openrouter_preset",
        "max_input_tokens",
        "temperature_override",
        "supports_temperature",
        "supports_tool_choice",
        "use_parallel_tool_calls",
        "allow_implied_send",
        "supports_vision",
        "supports_reasoning",
        "reasoning_effort",
    )


@admin.register(EmbeddingsModelEndpoint)
class EmbeddingsModelEndpointAdmin(admin.ModelAdmin):
    list_display = ("key", "provider", "litellm_model", "api_base", "low_latency", "enabled")
    list_filter = ("enabled", "low_latency", "provider")
    search_fields = ("key", "litellm_model", "api_base")
    fields = (
        "key",
        "provider",
        "enabled",
        "low_latency",
        "litellm_model",
        "api_base",
    )


class EmbeddingsTierEndpointInline(admin.TabularInline):
    model = EmbeddingsTierEndpoint
    extra = 0
    autocomplete_fields = ("endpoint",)


@admin.register(EmbeddingsLLMTier)
class EmbeddingsLLMTierAdmin(admin.ModelAdmin):
    list_display = ("order", "description")
    search_fields = ("description", "tier_endpoints__endpoint__key")
    ordering = ("order",)
    inlines = [EmbeddingsTierEndpointInline]


@admin.register(FileHandlerModelEndpoint)
class FileHandlerModelEndpointAdmin(admin.ModelAdmin):
    list_display = ("key", "provider", "litellm_model", "api_base", "low_latency", "supports_vision", "enabled")
    list_filter = ("enabled", "low_latency", "provider", "supports_vision")
    search_fields = ("key", "litellm_model", "api_base")
    fields = (
        "key",
        "provider",
        "enabled",
        "low_latency",
        "litellm_model",
        "api_base",
        "supports_vision",
    )


class FileHandlerTierEndpointInline(admin.TabularInline):
    model = FileHandlerTierEndpoint
    extra = 0
    autocomplete_fields = ("endpoint",)


@admin.register(FileHandlerLLMTier)
class FileHandlerLLMTierAdmin(admin.ModelAdmin):
    list_display = ("order", "description")
    search_fields = ("description", "tier_endpoints__endpoint__key")
    ordering = ("order",)
    inlines = [FileHandlerTierEndpointInline]


class IntelligenceTierAdminForm(forms.ModelForm):
    blacklisted_tools = forms.CharField(
        required=False,
        widget=forms.Textarea(attrs={"rows": 8, "cols": 80}),
        help_text="One tool function name per line, or comma-separated.",
    )

    class Meta:
        model = IntelligenceTier
        fields = "__all__"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        value = getattr(self.instance, "blacklisted_tools", None)
        if isinstance(value, list):
            self.initial["blacklisted_tools"] = "\n".join(str(item) for item in value)

    def clean_blacklisted_tools(self):
        from api.services.tool_blacklist import normalize_tool_blacklist

        return normalize_tool_blacklist(self.cleaned_data.get("blacklisted_tools"))

    def validate_constraints(self):
        # IntelligenceTier enforces a single default tier via a DB UniqueConstraint.
        # When an admin flips a different tier to default, we clear the prior default
        # in IntelligenceTierAdmin.save_model() (transactionally) after validation.
        #
        # Django validates model constraints during form validation; that would raise
        # a validation error before save_model() can clear the old default. Avoid the
        # false-positive by skipping constraint validation for the "is_default=True"
        # path; the DB constraint still protects integrity at save time.
        if self.cleaned_data.get("is_default"):
            return
        return super().validate_constraints()


@admin.register(IntelligenceTier)
class IntelligenceTierAdmin(admin.ModelAdmin):
    form = IntelligenceTierAdminForm
    list_display = (
        "display_name",
        "key",
        "rank",
        "credit_multiplier",
        "is_default",
        "blacklisted_tool_count",
        "updated_at",
    )
    list_filter = ("key", "is_default")
    search_fields = ("display_name", "key")
    ordering = ("rank", "key")

    @admin.display(description="Blacklisted tools")
    def blacklisted_tool_count(self, obj):
        return len(obj.blacklisted_tools or [])

    def save_model(self, request, obj, form, change):
        # Keep "default tier" selection unique without relying solely on constraint errors.
        if getattr(obj, "is_default", False):
            with transaction.atomic():
                qs = IntelligenceTier.objects.filter(is_default=True)
                if getattr(obj, "pk", None):
                    qs = qs.exclude(pk=obj.pk)
                qs.update(is_default=False)
                return super().save_model(request, obj, form, change)
        return super().save_model(request, obj, form, change)


class PersistentTierEndpointInline(admin.TabularInline):
    model = PersistentTierEndpoint
    extra = 0
    fields = ("tier", "endpoint", "weight", "reasoning_effort_override")


@admin.register(PersistentLLMTier)
class PersistentLLMTierAdmin(admin.ModelAdmin):
    list_display = ("token_range", "order", "description", "intelligence_tier")
    list_filter = ("token_range", "intelligence_tier")
    fields = ("token_range", "order", "description", "intelligence_tier")
    inlines = [PersistentTierEndpointInline]


@admin.register(PersistentTokenRange)
class PersistentTokenRangeAdmin(admin.ModelAdmin):
    list_display = ("name", "min_tokens", "max_tokens")
    ordering = ("min_tokens",)


@admin.register(BrowserModelEndpoint)
class BrowserModelEndpointAdmin(admin.ModelAdmin):
    list_display = (
        "key",
        "provider",
        "browser_model",
        "browser_base_url",
        "max_output_tokens",
        "enabled",
        "low_latency",
        "supports_temperature",
        "supports_vision",
    )
    list_filter = ("enabled", "low_latency", "provider", "supports_vision")
    search_fields = ("key", "browser_model", "browser_base_url")
    fields = (
        "key",
        "provider",
        "enabled",
        "low_latency",
        "browser_model",
        "browser_base_url",
        "max_output_tokens",
        "supports_temperature",
        "supports_vision",
    )


class BrowserTierEndpointInline(admin.TabularInline):
    model = BrowserTierEndpoint
    extra = 0


@admin.register(BrowserLLMTier)
class BrowserLLMTierAdmin(admin.ModelAdmin):
    list_display = ("policy", "order", "description", "intelligence_tier")
    list_filter = ("policy", "intelligence_tier")
    inlines = [BrowserTierEndpointInline]

@admin.register(BrowserLLMPolicy)
class BrowserLLMPolicyAdmin(admin.ModelAdmin):
    list_display = ("name", "is_active")
    list_filter = ("is_active",)
    search_fields = ("name",)
    search_fields = ("code", "url")

    @admin.display(description="Short URL")
    def shortened(self, obj):
        """Generate the URL for the landing page."""
        if not obj.pk or not obj.code:
            return "—"

        rel =  reverse('short_link', kwargs={'code': obj.code})
        current_site = Site.objects.get_current()

        # get if https from request
        protocol = 'https://'

        # Ensure the site domain is used to create the absolute URL
        absolute_url = f"{protocol}{current_site.domain}{rel}"

        return format_html(f'<a href="{absolute_url}" target="_blank">{absolute_url}</a>')


# --------------------------------------------------------------------------- #
#  LLM Routing Profiles Admin
# --------------------------------------------------------------------------- #

class ProfileTokenRangeInline(admin.TabularInline):
    model = ProfileTokenRange
    extra = 0
    fields = ("name", "min_tokens", "max_tokens")


class ProfilePersistentTierEndpointInline(admin.TabularInline):
    model = ProfilePersistentTierEndpoint
    extra = 0
    autocomplete_fields = ("endpoint",)
    fields = ("tier", "endpoint", "weight", "reasoning_effort_override")


class ProfileBrowserTierEndpointInline(admin.TabularInline):
    model = ProfileBrowserTierEndpoint
    extra = 0
    autocomplete_fields = ("endpoint",)


class ProfileEmbeddingsTierEndpointInline(admin.TabularInline):
    model = ProfileEmbeddingsTierEndpoint
    extra = 0
    autocomplete_fields = ("endpoint",)


@admin.register(LLMRoutingProfile)
class LLMRoutingProfileAdmin(admin.ModelAdmin):
    list_display = ("display_name", "name", "is_active", "is_eval_snapshot", "created_at", "updated_at")
    list_filter = ("is_active", "is_eval_snapshot")
    search_fields = ("name", "display_name", "description")
    readonly_fields = ("created_at", "updated_at", "created_by", "cloned_from", "is_eval_snapshot")
    fields = (
        "name",
        "display_name",
        "description",
        "is_active",
        "is_eval_snapshot",
        "created_at",
        "updated_at",
        "created_by",
        "cloned_from",
    )
    inlines = [ProfileTokenRangeInline]

    def get_queryset(self, request):
        """By default, exclude eval snapshots unless explicitly filtering for them."""
        qs = super().get_queryset(request)
        # Show all profiles if filtering by is_eval_snapshot, otherwise hide snapshots
        if "is_eval_snapshot__exact" in request.GET:
            return qs
        return qs.filter(is_eval_snapshot=False)

    def save_model(self, request, obj, form, change):
        if not change:  # Creating new profile
            obj.created_by = request.user
        super().save_model(request, obj, form, change)


@admin.register(ProfileTokenRange)
class ProfileTokenRangeAdmin(admin.ModelAdmin):
    list_display = ("profile", "name", "min_tokens", "max_tokens")
    list_filter = ("profile",)
    search_fields = ("name", "profile__name")
    ordering = ("profile", "min_tokens")


@admin.register(ProfilePersistentTier)
class ProfilePersistentTierAdmin(admin.ModelAdmin):
    list_display = ("token_range", "order", "description", "intelligence_tier")
    list_filter = ("token_range__profile", "intelligence_tier")
    search_fields = ("description", "token_range__name", "token_range__profile__name")
    ordering = ("token_range__profile", "token_range__min_tokens", "intelligence_tier__rank", "order")
    inlines = [ProfilePersistentTierEndpointInline]


@admin.register(ProfileBrowserTier)
class ProfileBrowserTierAdmin(admin.ModelAdmin):
    list_display = ("profile", "order", "description", "intelligence_tier")
    list_filter = ("profile", "intelligence_tier")
    search_fields = ("description", "profile__name")
    ordering = ("profile", "intelligence_tier__rank", "order")
    inlines = [ProfileBrowserTierEndpointInline]


@admin.register(ProfileEmbeddingsTier)
class ProfileEmbeddingsTierAdmin(admin.ModelAdmin):
    list_display = ("profile", "order", "description")
    list_filter = ("profile",)
    search_fields = ("description", "profile__name")
    ordering = ("profile", "order")
    inlines = [ProfileEmbeddingsTierEndpointInline]


# ------------------------------------------------------------------
# Attachments & Filespaces (Admin)
# ------------------------------------------------------------------

@admin.register(PersistentAgentMessageAttachment)
class PersistentAgentMessageAttachmentAdmin(admin.ModelAdmin):
    list_display = (
        'filename',
        'file_size',
        'content_type',
        'content_present',
        'filespace_node_link',
        'owner_agent_link',
        'message_timestamp',
        'download_link',
    )
    list_filter = (
        'content_type',
        'message__from_endpoint__channel',
        'message__owner_agent',
    )
    search_fields = (
        'filename',
        'message__body',
        'message__from_endpoint__address',
        'message__to_endpoint__address',
        'message__conversation__address',
        'message__owner_agent__name',
    )
    raw_id_fields = ('message', 'filespace_node')
    ordering = ('-message__timestamp',)

    @admin.display(description='Content Present', boolean=True)
    def content_present(self, obj):
        try:
            return bool(obj.file and getattr(obj.file, 'name', None) and obj.file.storage.exists(obj.file.name))
        except Exception:
            # If storage check fails (network, etc.), fall back to whether a name exists
            return bool(obj.file and getattr(obj.file, 'name', None))

    @admin.display(description='Agent')
    def owner_agent_link(self, obj):
        agent = getattr(obj.message, 'owner_agent', None)
        if agent:
            url = reverse("admin:api_persistentagent_change", args=[agent.pk])
            return format_html('<a href="{}">{}</a>', url, agent.name)
        return '—'

    @admin.display(description='Timestamp', ordering='message__timestamp')
    def message_timestamp(self, obj):
        return obj.message.timestamp if obj.message else None

    @admin.display(description='Download')
    def download_link(self, obj):
        try:
            if obj.file and getattr(obj.file, 'url', None):
                return format_html('<a href="{}" target="_blank">Download</a>', obj.file.url)
        except Exception:
            pass
        return '—'

    @admin.display(description='Filespace Node')
    def filespace_node_link(self, obj):
        node = getattr(obj, 'filespace_node', None)
        if not node:
            return '—'
        try:
            url = reverse("admin:api_agentfsnode_change", args=[node.pk])
            label = getattr(node, 'path', None) or str(node.pk)
            return format_html('<a href="{}">{}</a>', url, label)
        except Exception:
            return str(node.pk)


@admin.register(AgentFileSpace)
class AgentFileSpaceAdmin(admin.ModelAdmin):
    list_display = (
        'name',
        'owner_user',
        'agent_count',
        'node_count',
        'created_at',
        'browse_nodes_link',
    )
    search_fields = ('name', 'owner_user__email', 'id')
    list_filter = ('owner_user',)
    readonly_fields = ('id', 'created_at', 'updated_at')

    def get_queryset(self, request):
        qs = super().get_queryset(request)
        # Annotate counts without heavy joins in list_display
        return qs.annotate(_agent_count=Count('access'), _node_count=Count('nodes'))

    @admin.display(description='Agents', ordering='_agent_count')
    def agent_count(self, obj):
        return getattr(obj, '_agent_count', None) or obj.access.count()

    @admin.display(description='Nodes', ordering='_node_count')
    def node_count(self, obj):
        return getattr(obj, '_node_count', None) or obj.nodes.count()

    @admin.display(description='Browse')
    def browse_nodes_link(self, obj):
        url = reverse('admin:api_agentfsnode_changelist') + f'?filespace__id__exact={obj.pk}'
        return format_html('<a href="{}">Open Nodes</a>', url)


@admin.register(AgentFileSpaceAccess)
class AgentFileSpaceAccessAdmin(admin.ModelAdmin):
    list_display = ('filespace', 'agent', 'role', 'is_default', 'granted_at')
    list_filter = ('role', 'is_default', 'filespace', 'agent')
    search_fields = ('filespace__name', 'agent__name')
    raw_id_fields = ('filespace', 'agent')
    ordering = ('-granted_at',)


@admin.register(AgentFsNode)
class AgentFsNodeAdmin(admin.ModelAdmin):
    list_display = (
        'filespace',
        'path',
        'node_type',
        'size_bytes',
        'mime_type',
        'is_deleted',
        'created_at',
        'download_link',
    )
    list_filter = (
        'filespace',
        'node_type',
        'is_deleted',
        'mime_type',
    )
    search_fields = ('path', 'name', 'mime_type')
    raw_id_fields = ('filespace', 'parent', 'created_by_agent')
    readonly_fields = ('id', 'created_at', 'updated_at', 'path')
    date_hierarchy = 'created_at'
    ordering = ('filespace', 'path')

    @admin.display(description='Download')
    def download_link(self, obj):
        if obj.node_type != AgentFsNode.NodeType.FILE:
            return '—'
        try:
            if obj.pk:
                url = reverse('admin:api_agentfsnode_download', args=[obj.pk])
                return format_html('<a href="{}">Download</a>', url)
        except Exception:
            pass
        return '—'

    # Provide an authenticated download endpoint that streams from storage
    def get_urls(self):
        urls = super().get_urls()
        custom = [
            path(
                '<path:object_id>/download/',
                self.admin_site.admin_view(self.download_view),
                name='api_agentfsnode_download',
            )
        ]
        return custom + urls

    def download_view(self, request, object_id):
        obj = self.get_object(request, object_id)
        if not obj:
            self.message_user(request, 'File not found', messages.ERROR)
            return HttpResponseRedirect(reverse('admin:api_agentfsnode_changelist'))

        if obj.node_type != AgentFsNode.NodeType.FILE:
            self.message_user(request, 'This node is not a file', messages.ERROR)
            return HttpResponseRedirect(reverse('admin:api_agentfsnode_change', args=[object_id]))

        if not obj.content or not getattr(obj.content, 'name', None):
            self.message_user(request, 'No content associated with this file node', messages.ERROR)
            return HttpResponseRedirect(reverse('admin:api_agentfsnode_change', args=[object_id]))

        try:
            storage = obj.content.storage
            name = obj.content.name
            if hasattr(storage, 'exists') and not storage.exists(name):
                self.message_user(request, 'Stored blob is missing or has been moved', messages.ERROR)
                return HttpResponseRedirect(reverse('admin:api_agentfsnode_change', args=[object_id]))

            fh = storage.open(name, 'rb')
            filename = obj.name or 'file'
            content_type = obj.mime_type or 'application/octet-stream'
            response = FileResponse(fh, as_attachment=True, filename=filename, content_type=content_type)
            return response
        except Exception as e:
            self.message_user(request, f'Error streaming file: {e}', messages.ERROR)
            return HttpResponseRedirect(reverse('admin:api_agentfsnode_change', args=[object_id]))


@admin.register(PublicProfile)
class PublicProfileAdmin(admin.ModelAdmin):
    list_display = ("handle", "user", "updated_at")
    search_fields = ("handle", "user__email")
    ordering = ("handle",)
    readonly_fields = ("created_at", "updated_at")


@admin.register(PersistentAgentTemplate)
class PersistentAgentTemplateAdmin(admin.ModelAdmin):
    list_display = (
        'display_name', 'category', 'recommended_contact_channel', 'base_schedule',
        'schedule_jitter_minutes', 'priority', 'is_active', 'updated_at'
    )
    list_filter = ('category', 'recommended_contact_channel', 'is_active')
    search_fields = ('display_name', 'tagline', 'description', 'code')
    ordering = ('priority', 'display_name')
    readonly_fields = ('created_at', 'updated_at')
    prepopulated_fields = {"code": ("display_name",)}
    fieldsets = (
        ('Identity', {
            'fields': ('code', 'display_name', 'tagline', 'category', 'priority', 'is_active')
        }),
        ('Public Template', {
            'fields': ('public_profile', 'slug', 'source_agent', 'created_by')
        }),
        ('Narrative', {
            'fields': ('description', 'charter')
        }),
        ('Cadence & Triggers', {
            'fields': ('base_schedule', 'schedule_jitter_minutes', 'event_triggers')
        }),
        ('Tools & Communication', {
            'fields': ('default_tools', 'recommended_contact_channel', 'hero_image_path')
        }),
        ('Timestamps', {
            'fields': ('created_at', 'updated_at')
        }),
    )


@admin.register(ToolFriendlyName)
class ToolFriendlyNameAdmin(admin.ModelAdmin):
    list_display = ('tool_name', 'display_name', 'updated_at')
    search_fields = ('tool_name', 'display_name')
    ordering = ('tool_name',)
    readonly_fields = ('created_at', 'updated_at')


@admin.register(EvalRunTask)
class EvalRunTaskAdmin(admin.ModelAdmin):
    list_display = ('run', 'sequence', 'name', 'status', 'assertion_type', 'started_at', 'finished_at')
    list_filter = ('status', 'assertion_type', 'run__scenario_slug')
    search_fields = ('name', 'run__scenario_slug', 'run__id')
    raw_id_fields = ('run', 'first_step', 'first_message', 'first_browser_task')
    readonly_fields = ('created_at', 'updated_at')


class EvalRunTaskInline(admin.TabularInline):
    model = EvalRunTask
    extra = 0
    fields = ('sequence', 'name', 'status', 'assertion_type', 'started_at', 'finished_at')
    readonly_fields = ('sequence', 'name', 'status', 'assertion_type', 'started_at', 'finished_at')
    can_delete = False
    show_change_link = True


@admin.register(EvalRun)
class EvalRunAdmin(admin.ModelAdmin):
    list_display = ('scenario_slug', 'scenario_version', 'agent', 'status', 'run_type', 'started_at', 'finished_at', 'step_count')
    list_filter = ('status', 'run_type', 'scenario_slug', 'started_at')
    search_fields = ('scenario_slug', 'agent__name', 'id', 'budget_id')
    raw_id_fields = ('agent', 'initiated_by')
    readonly_fields = ('created_at', 'updated_at', 'tokens_used', 'credits_cost', 'completion_count', 'step_count')
    inlines = [EvalRunTaskInline]


@admin.register(AgentComputeSession)
class AgentComputeSessionAdmin(admin.ModelAdmin):
    list_display = (
        "agent",
        "state",
        "pod_name",
        "namespace",
        "proxy_server",
        "workspace_snapshot",
        "last_activity_at",
        "lease_expires_at",
        "last_filespace_sync_at",
        "updated_at",
    )
    list_filter = ("state", "namespace", "proxy_server", "created_at")
    search_fields = (
        "agent__name",
        "agent__user__email",
        "agent__organization__name",
        "agent__id",
        "pod_name",
        "namespace",
    )
    raw_id_fields = ("agent", "proxy_server", "workspace_snapshot")
    readonly_fields = ("created_at", "updated_at")
    list_select_related = ("agent", "proxy_server", "workspace_snapshot")


@admin.register(ComputeSnapshot)
class ComputeSnapshotAdmin(admin.ModelAdmin):
    list_display = ("id", "agent", "status", "k8s_snapshot_name", "size_bytes", "created_at")
    list_filter = ("status", "created_at")
    search_fields = ("id", "agent__name", "agent__user__email", "k8s_snapshot_name")
    raw_id_fields = ("agent",)
    readonly_fields = ("created_at",)
    list_select_related = ("agent",)
