"""
Ask Your Data — Fully AI-driven Conversational Analytics

Architecture:
1. User asks a natural language question
2. LLM analyzes the question + data schema → returns a structured query spec (JSON)
3. Query executor safely runs the spec against Django ORM
4. LLM generates a narrative answer from the real results
5. Returns: {answer_text, data, chart_type, query_spec}

The LLM touches the actual data to give correct, contextual answers.
"""

import json
import logging
import hashlib
from decimal import Decimal

from django.contrib.auth.decorators import login_required
from django.core.cache import cache
from django.core.exceptions import FieldError
from django.db.models import Avg, Count, F, Q, Sum, ExpressionWrapper, FloatField
from django.db.models.functions import TruncDate, TruncMonth, TruncWeek, Cast
from django.http import JsonResponse
from django.shortcuts import render
from django.utils import timezone
from datetime import timedelta

from apps.core.decorators import block_source_user
from apps.complaints.models import Complaint
from apps.feedback.models import Feedback
from apps.observations.models import Observation
from apps.organizations.models import Department, Hospital, Patient
from apps.accounts.models import User
from apps.physicians.models import PhysicianMonthlyRating
from apps.px_action_center.models import PXAction
from apps.surveys.models import SurveyInstance


logger = logging.getLogger(__name__)


class DecimalEncoder(json.JSONEncoder):
    """JSON encoder that handles Decimal objects by converting them to float."""

    def default(self, obj):
        if isinstance(obj, Decimal):
            return float(obj)
        return super().default(obj)


_db_schema_cache = None


def _get_db_schema():
    """Auto-generate database schema description from Django models for the LLM."""
    global _db_schema_cache
    if _db_schema_cache:
        return _db_schema_cache

    type_map = {
        "CharField": "text",
        "TextField": "text",
        "IntegerField": "integer",
        "PositiveIntegerField": "integer",
        "FloatField": "decimal",
        "DecimalField": "decimal",
        "BooleanField": "boolean",
        "DateField": "date",
        "DateTimeField": "datetime",
        "JSONField": "json",
        "EmailField": "text",
        "GenericIPAddressField": "text",
        "UUIDField": "uuid",
    }

    skip_fields = {
        "id",
        "created_at",
        "updated_at",
        "content_type",
        "object_id",
        "metadata",
        "password",
        "access_token",
        "token_expires_at",
        "comment_analyzed",
        "comment_analysis",
        "patient_contacted",
        "patient_contacted_at",
        "patient_contacted_by",
        "issue_resolved",
        "contact_notes",
        "completed_language",
        "open_count",
        "last_opened_at",
        "time_spent_seconds",
        "is_featured",
        "is_public",
        "is_deleted",
        "deleted_at",
        "deleted_by",
        "client_ip",
        "user_agent",
        "action_id",
        "resolution_survey_sent_at",
        "monthly_follow_up_due_at",
        "monthly_follow_up_completed_at",
        "monthly_follow_up_completed_by",
        "monthly_follow_up_notes",
        "submitter_notified_at",
        "responsible_person_notified_at",
        "resolution_survey",
    }

    model_configs = {
        "complaints": {
            "model": Complaint,
            "label": "COMPLAINTS",
            "fk_traversals": {
                "department": "department__name_en",
                "category": "category__name_en",
                "domain": "domain__name_en",
                "source": "source__name_en",
                "hospital": "hospital__name",
                "location": "location__name_en",
                "main_section": "main_section__name_en",
                "subsection": "subsection__name_en",
            },
            "extra_fields": ["resolution_time (calculated: avg hours between activated_at and resolved_at)"],
            "date_field": "created_at",
            "notes": "Has department, hospital, status fields directly.",
        },
        "surveys": {
            "model": SurveyInstance,
            "label": "SURVEYS",
            "fk_traversals": {
                "survey_template": "survey_template__name",
                "survey_type": "survey_template__survey_type",
                "patient": "patient__name_en",
                "staff": "staff__name_en",
                "journey_instance": "journey_instance__id",
                "hospital": "hospital__name",
            },
            "extra_fields": [],
            "date_field": "completed_at",
            "notes": "NO direct department field. Use survey_template__survey_type for survey type. Hospital auto-filtered.",
        },
        "actions": {
            "model": PXAction,
            "label": "ACTIONS (PX Actions)",
            "fk_traversals": {
                "department": "department__name_en",
                "hospital": "hospital__name",
            },
            "extra_fields": [],
            "date_field": "created_at",
            "notes": "Has department, hospital, status fields directly.",
        },
        "feedback": {
            "model": Feedback,
            "label": "FEEDBACK",
            "fk_traversals": {
                "department": "department__name_en",
                "hospital": "hospital__name",
                "staff": "staff__name_en",
            },
            "extra_fields": [],
            "date_field": "created_at",
            "notes": "Has department, hospital, status fields directly.",
        },
        "physicians": {
            "model": PhysicianMonthlyRating,
            "label": "PHYSICIAN RATINGS",
            "fk_traversals": {
                "staff": "staff__name_en",
                "physician_name": "staff__name_en",
                "department": "staff__department__name_en",
                "hospital": "staff__department__hospital__name",
            },
            "extra_fields": [],
            "date_field": "created_at",
            "notes": "NO direct hospital or department field. Traverse via staff FK. Use staff__name_en for physician name, staff__department__name_en for department. Use average_rating for rating, total_surveys for survey count.",
        },
        "observations": {
            "model": Observation,
            "label": "OBSERVATIONS",
            "fk_traversals": {
                "category": "category__name_en",
                "assigned_department": "assigned_department__name_en",
                "hospital": "hospital__name",
                "staff": "staff__name_en",
            },
            "extra_fields": [],
            "date_field": "created_at",
            "notes": "Uses assigned_department (NOT department). Has hospital FK.",
        },
        "patients": {
            "model": Patient,
            "label": "PATIENTS",
            "fk_traversals": {
                "hospital": "primary_hospital__name",
            },
            "extra_fields": [],
            "date_field": "created_at",
            "notes": "Stores patient demographics. Use nationality for country queries, gender for gender breakdown, city for geographic queries.",
        },
        "users": {
            "model": User,
            "label": "USERS (Staff)",
            "fk_traversals": {
                "department": "department__name_en",
                "hospital": "hospital__name",
            },
            "extra_fields": [],
            "date_field": "date_joined",
            "notes": "User accounts for staff/admin. Has role field for user type filtering.",
        },
    }

    schema_parts = []

    for model_key, config in model_configs.items():
        Model = config["model"]
        lines = [f'{config["label"]} (model: "{model_key}"):']

        field_descriptions = []
        for field in Model._meta.get_fields():
            if field.name in skip_fields:
                continue
            if field.is_relation:
                if field.many_to_many or field.one_to_many:
                    continue
                if field.name in config["fk_traversals"]:
                    traversal = config["fk_traversals"][field.name]
                    if field.name == traversal:
                        continue
                    field_descriptions.append(f"  - {traversal} (FK → {field.related_model._meta.model_name})")
            else:
                field_type = type_map.get(field.__class__.__name__, "unknown")
                desc = f"  - {field.name} ({field_type}"
                if field.choices:
                    choice_vals = [c[0] for c in field.choices]
                    desc += f", choices: {', '.join(choice_vals)}"
                desc += ")"
                field_descriptions.append(desc)

        for extra in config.get("extra_fields", []):
            field_descriptions.append(f"  - {extra}")

        lines.extend(field_descriptions)
        if config.get("notes"):
            lines.append(f"  NOTE: {config['notes']}")

        schema_parts.append("\n".join(lines))

    schema = "\n\n".join(schema_parts)
    _db_schema_cache = schema
    return schema


# =============================================================================
# Step 0: LLM Client
# =============================================================================
# UI View
# =============================================================================


@block_source_user
@login_required
def ask_your_data(request):
    """Conversational Analytics page."""
    user = request.user
    hospital = None
    if user.is_px_admin() and hasattr(request, "tenant_hospital") and request.tenant_hospital:
        hospital = request.tenant_hospital
    elif hasattr(user, "hospital") and user.hospital:
        hospital = user.hospital

    return render(
        request,
        "analytics/ask_your_data.html",
        {
            "selected_hospital": hospital,
        },
    )


# =============================================================================
# Query API — Fully AI-driven
# =============================================================================


@block_source_user
@login_required
def ask_data_query(request):
    """
    POST {"question": "..."} → AI understands, queries data, returns answer + data.
    """
    if request.method != "POST":
        return JsonResponse({"error": "POST required"}, status=405)

    try:
        body = json.loads(request.body)
    except json.JSONDecodeError:
        return JsonResponse({"error": "Invalid JSON"}, status=400)

    question = body.get("question", "").strip()
    if not question:
        return JsonResponse({"error": "Question is required"}, status=400)
    if len(question) > 500:
        return JsonResponse({"error": "Question too long (max 500 chars)"}, status=400)

    conversation_history = body.get("conversation_history", [])
    if not isinstance(conversation_history, list):
        conversation_history = []

    user = request.user
    hospital = None
    if user.is_px_admin() and hasattr(request, "tenant_hospital") and request.tenant_hospital:
        hospital = request.tenant_hospital
    elif hasattr(user, "hospital") and user.hospital:
        hospital = user.hospital

    # Cache by question + hospital (use hash to avoid special chars in cache keys)
    q_hash = hashlib.md5(question.lower().strip().encode()).hexdigest()[:16]
    h_id = hospital.id if hospital else "all"
    cache_key = f"ask_v2_{q_hash}_{h_id}"
    cached = cache.get(cache_key)
    if cached:
        return JsonResponse(cached)

    result = _process_question(question, user, hospital, conversation_history)
    cache.set(cache_key, result, 1800)  # 30 min cache
    return JsonResponse(result)


# =============================================================================
# Core: AI processes question, executes query, generates answer
# =============================================================================


def _process_question(question, user, hospital, conversation_history=None):
    """
    Full AI pipeline with conversation memory:
    1. LLM generates query spec with context
    2. Execute query spec against Django ORM
    3. LLM generates narrative answer from real results
    """
    if conversation_history is None:
        conversation_history = []

    from apps.analytics.services.ai_analytics import _client

    hospital_ctx = f"Hospital: {hospital.name}" if hospital else "All hospitals (no filter)"

    # Step 1: LLM generates query spec (with rule-based fallback)
    query_spec = _generate_query_spec(question, hospital_ctx, _client, conversation_history)

    # If LLM failed, fall back to rule-based parser WITH conversation context
    if not query_spec or "error" in query_spec:
        query_spec = _rule_based_parse(question, hospital_ctx, conversation_history)
        if not query_spec or "error" in query_spec:
            return {
                "answer": query_spec.get("error", "I couldn't understand this question. Please try rephrasing it."),
                "data": {},
                "chart_type": "none",
            }

    # Step 2: Execute the query
    try:
        data = _execute_query(query_spec, user, hospital)
    except Exception as e:
        logger.exception(f"Query execution error for: {question}")
        return {
            "answer": "I couldn't retrieve the data for that question. Try simplifying or asking about a specific metric.",
            "data": {},
            "chart_type": "none",
        }

    if not data or data.get("error"):
        return {
            "answer": "No data found matching your question. Try a different time period, department, or metric.",
            "data": {},
            "chart_type": "none",
        }

    # Step 3: LLM generates narrative answer AND Arrow.js component from real data
    answer_text, arrow_component = _generate_answer_and_component(question, query_spec, data, _client)

    # Determine chart type
    chart_type = _determine_chart_type(query_spec, data)

    return {
        "answer": answer_text,
        "data": data,
        "chart_type": chart_type,
        "component": arrow_component,
    }


# =============================================================================
# Step 1: LLM generates query spec (with rule-based fallback)
# =============================================================================


def _rule_based_parse(question, hospital_ctx, conversation_history=None):
    """Fallback rule-based parser when LLM is unavailable — uses conversation context."""
    if conversation_history is None:
        conversation_history = []

    q = question.lower().strip()

    # Try to detect model from conversation history first
    model = _rb_detect_model_from_history(conversation_history, q)
    if not model:
        model = _rb_detect_model(q)
    if not model:
        # If still no model, check if it's a follow-up like "from pakistan", "by department"
        if q.startswith(("and ", "but ", "what about ", "how about ", "show ", "list ", "of ", "from ", "by ", "in ", "how many")):
            # Likely a follow-up — try to use the last model from history
            for msg in reversed(conversation_history):
                if msg.get("role") == "user" and any(m in msg.get("content", "").lower() for m in ["complaint", "survey", "action", "feedback", "patient", "observation"]):
                    model = _rb_detect_model(msg["content"])
                    break
        if not model:
            return {
                "error": "I couldn't determine what data you're asking about. Try mentioning complaints, surveys, actions, feedback, patients, or observations."
            }

    operation, group_by, limit = _rb_detect_operation(q, model)
    filters = _rb_extract_filters(q, model)

    spec = {
        "model": model,
        "operation": operation,
        "filters": filters,
    }
    if group_by:
        spec["group_by"] = group_by
    if limit:
        spec["limit"] = limit

    return spec


def _rb_detect_model_from_history(conversation_history, current_question):
    """Detect model from conversation history for follow-up questions."""
    q = current_question.lower().strip()

    # If it looks like a follow-up (short, starts with conjunction, or references previous context)
    if len(q.split()) <= 8 or q.startswith(("and ", "but ", "what ", "how ", "show ", "list ", "of ", "from ", "by ", "in ")):
        # Scan backwards for the last user question with a known model keyword
        for msg in reversed(conversation_history):
            if msg.get("role") != "user":
                continue
            prev_q = msg.get("content", "").lower()
            model = _rb_detect_model(prev_q)
            if model:
                return model

    return None


def _rb_detect_model(q):
    if "complaint" in q:
        return "complaints"
    elif "survey" in q or "nps" in q or "satisfaction" in q:
        return "surveys"
    elif "action" in q:
        return "actions"
    elif "feedback" in q:
        return "feedback"
    elif "physician" in q or "doctor" in q:
        return "physicians"
    elif "observation" in q:
        return "observations"
    elif "patient" in q:
        return "patients"
    elif "user" in q or "staff" in q or "employee" in q:
        return "users"
    return None


def _rb_detect_operation(q, model):
    """Detect the operation type from the question."""
    # Follow-up questions with specific values should be count operations
    # e.g., "what about india?", "and from pakistan?", "how about egypt?"
    if any(q.startswith(prefix) for prefix in ["what about", "how about", "and from", "and how many", "from "]):
        return "count", None, None

    if any(w in q for w in ["how many", "total number", "count of"]):
        if any(w in q for w in ["by ", "per ", "breakdown", "distribution"]):
            return "grouped_count", None, None
        return "count", None, None
    if any(w in q for w in ["average", "avg", "mean score"]):
        return "average", None, None
    if any(w in q for w in ["trend", "over time", "over the", "monthly", "daily"]):
        return "trend", None, None
    if any(w in q for w in ["leaderboard", "top physicians", "best rated"]):
        return "leaderboard", None, 10
    if any(w in q for w in ["list", "show me all", "show all"]):
        return "list", None, 20
    # Default to grouped count
    return "grouped_count", "department__name_en", None


def _rb_extract_filters(q, model):
    filters = []

    # Time filters
    if "last month" in q:
        filters.append({"field": "date", "op": "=", "value": "last_month"})
    elif "this month" in q:
        filters.append({"field": "date", "op": "=", "value": "this_month"})
    elif "last week" in q:
        filters.append({"field": "date", "op": "=", "value": "last_week"})
    elif "this week" in q:
        filters.append({"field": "date", "op": "=", "value": "this_week"})
    elif "last quarter" in q or "90 day" in q or "3 month" in q:
        filters.append({"field": "date", "op": "=", "value": "last_quarter"})
    elif "last year" in q or "12 month" in q:
        filters.append({"field": "date", "op": "=", "value": "last_year"})
    elif "30 day" in q or "1 month" in q:
        filters.append({"field": "date", "op": "=", "value": "30d"})
    elif "7 day" in q or "1 week" in q:
        filters.append({"field": "date", "op": "=", "value": "7d"})
    elif "6 month" in q:
        filters.append({"field": "date", "op": "=", "value": "6m"})

    # Status/type filters
    if "open" in q and model == "complaints":
        filters.append({"field": "status", "op": "=", "value": "open"})
    if "overdue" in q:
        filters.append({"field": "is_overdue", "op": "=", "value": True})
    if "resolved" in q or "closed" in q:
        filters.append({"field": "status", "op": "in", "value": ["resolved", "closed"]})
    if "negative" in q and model == "surveys":
        filters.append({"field": "is_negative", "op": "=", "value": True})
    if "high" in q and "severity" in q:
        filters.append({"field": "severity", "op": "=", "value": "high"})
    if "critical" in q:
        filters.append({"field": "severity", "op": "=", "value": "critical"})
    if "medium" in q and "severity" in q:
        filters.append({"field": "severity", "op": "=", "value": "medium"})
    if "low" in q and "severity" in q:
        filters.append({"field": "severity", "op": "=", "value": "low"})

    # Department mentions
    depts = [
        "cardiology",
        "emergency",
        "er",
        "icu",
        "surgery",
        "pediatric",
        "orthopedic",
        "radiology",
        "lab",
        "pharmacy",
        "nursing",
    ]
    for dept in depts:
        if dept in q:
            field = "department__name_en" if model != "physicians" else "department__name_en"
            filters.append({"field": field, "op": "icontains", "value": dept})
            break

    # Nationality/country/city queries — use iexact
    if model == "patients" and ("from " in q or "nationality" in q or "country" in q or "what about" in q or "how about" in q or "and from" in q):
        # Extract the country name from the question
        # Try various patterns
        for phrase in ["from ", "about ", "in "]:
            idx = q.find(phrase)
            if idx >= 0:
                rest = q[idx + len(phrase):].strip()
                word = rest.split()[0] if rest.split() else None
                if word:
                    country = word.strip("?.,!;:")
                    if country and country.lower() not in ("the", "are", "we", "have", "do", "does", "what", "how", "many", "there"):
                        filters.append({"field": "nationality", "op": "iexact", "value": country.capitalize()})
                        break

    return filters


def _generate_query_spec(question, hospital_ctx, client, conversation_history=None):
    """Ask the LLM to generate a structured query spec with conversation context."""
    if conversation_history is None:
        conversation_history = []

    if not client.is_configured():
        return {"error": "AI service is currently unavailable. Please try again later."}

    db_schema = _get_db_schema()

    system_prompt = f"""You are a healthcare data analyst that converts natural language questions into structured Django ORM query specs for a Patient Experience (PX360) system.

CONVERSATION CONTEXT:
- The user may ask follow-up questions that reference previous queries.
- If the question contains pronouns ("those", "them", "it") or comparative terms ("same", "above"), use the CONVERSATION HISTORY to understand the context.
- For follow-up questions like "what about X?", "and from Y?", "how about Z?" — use the SAME model as the previous question and return a "count" operation with a filter for X/Y/Z.
- "of those" or "from those" means filter the previous result set further.
- "show by department" means group the previous result by department.

DATABASE SCHEMA — use ONLY these exact field names and FK traversals:
{db_schema}

CRITICAL RULES:
- Hospital filtering is handled automatically. DO NOT include "hospital" in filters.
- Use the EXACT field names shown above. If a field doesn't exist on a model, do NOT reference it.
- For FK fields, use __ notation to traverse (e.g., department__name_en, category__name_en).
- Surveys do NOT have a direct department field — do NOT filter surveys by department.
- Observations use assigned_department (NOT department).
- Physician ratings: use staff__name_en for name, staff__department__name_en for department.

TIME EXPRESSIONS (return as relative):
- "last month" → {{"type": "relative", "value": "last_month"}}
- "this month" → {{"type": "relative", "value": "this_month"}}
- "last week" → {{"type": "relative", "value": "last_week"}}
- "last quarter" / "last 90 days" → {{"type": "relative", "value": "last_quarter"}}
- "last year" / "last 12 months" → {{"type": "relative", "value": "last_year"}}
- "last 30 days" → {{"type": "relative", "value": "30d"}}
- "last 7 days" → {{"type": "relative", "value": "7d"}}
- "this year" → {{"type": "relative", "value": "this_year"}}
- "last 6 months" → {{"type": "relative", "value": "6m"}}

QUERY OPERATIONS:
- "count" → returns single number
- "grouped_count" → returns [{{group, count}}, ...] — use group_by field
- "average" → returns single number — use aggregate_field
- "sum" → returns single number — use aggregate_field
- "trend" → returns [{{date, count}}, ...] — use truncate: month|week|day
- "list" → returns [{{...}}, ...] — use fields array
- "top_n" → returns [{{group, count}}, ...] — use group_by + limit
- "comparison" → returns [{{group, metric}}, ...] — use group_by
- "percentage" → returns percentage — use numerator_filters for subset
- "leaderboard" → returns physician ranking table ONLY (model must be "physicians")

FILTER OPERATORS:
- equals: {{"field": "status", "op": "=", "value": "open"}}
- in: {{"field": "status", "op": "in", "value": ["resolved", "closed"]}}
- contains (fuzzy): {{"field": "department__name_en", "op": "icontains", "value": "cardiology"}}
- iexact (case-insensitive exact): {{"field": "nationality", "op": "iexact", "value": "Sudan"}} — use for nationality, gender, city
- gte/lte/gt/lt for dates and numbers
- is_true: {{"field": "is_overdue", "op": "=", "value": true}}

IMPORTANT: For nationality, country, gender, city — ALWAYS use "iexact", NOT "icontains".
Example: {{"field": "nationality", "op": "iexact", "value": "Sudan"}} — NOT icontains.

RESPOND WITH ONLY VALID JSON. No markdown. No explanation.
{{
  "model": "complaints|surveys|actions|feedback|physicians|observations|patients|users",
  "operation": "count|grouped_count|average|trend|list|top_n|percentage|comparison|leaderboard",
  "filters": [{{"field": "...", "op": "=", "value": "..."}}],
  "group_by": "field_name",
  "fields": ["field1", "field2"],
  "truncate": "month|week|day",
  "limit": 10,
  "order_by": "-count",
  "aggregate_field": "total_score"
}}"""

    # Build conversation context for the prompt
    history_context = ""
    if conversation_history:
        history_context = "\nCONVERSATION HISTORY (most recent first):\n"
        # Only include last 6 messages (3 exchanges)
        for msg in conversation_history[-6:]:
            role_label = "USER" if msg.get("role") == "user" else "ASSISTANT"
            content = msg.get("content", "")
            data = msg.get("data", {})
            history_context += f"{role_label}: {content}\n"
            if data and data.get("headers") and data.get("rows"):
                # Show a summary of the data
                history_context += f"  Data: {len(data['rows'])} rows, columns: {data['headers']}\n"

    user_prompt = f"""{hospital_ctx}{history_context}

Question: {question}

Generate the query spec."""

    raw = client.chat(system_prompt, user_prompt, temperature=0.05, max_tokens=512)

    if not raw:
        return {"error": "AI service unavailable"}

    try:
        # Try to parse JSON (strip markdown if present)
        raw = raw.strip()
        if raw.startswith("```json"):
            raw = raw[7:]
        elif raw.startswith("```"):
            raw = raw[3:]
        if raw.endswith("```"):
            raw = raw[:-3]
        raw = raw.strip()

        spec = json.loads(raw)

        # Validate required fields
        if "model" not in spec or "operation" not in spec:
            return {"error": "I couldn't parse a valid query from your question. Could you rephrase?"}

        # Validate model
        valid_models = ["complaints", "surveys", "actions", "feedback", "physicians", "observations"]
        if spec["model"] not in valid_models:
            return {"error": f"I don't have data for that. Available: {', '.join(valid_models)}"}

        return spec

    except json.JSONDecodeError:
        return {"error": "I couldn't understand the question format. Please try rephrasing it."}


# =============================================================================
# Step 2: Execute query spec
# =============================================================================


def _execute_query(spec, user, hospital):
    """Safely execute a query spec against Django ORM."""
    model = spec["model"]
    operation = spec["operation"]
    filters = spec.get("filters", [])
    group_by = spec.get("group_by")
    fields = spec.get("fields")
    truncate = spec.get("truncate")
    limit = spec.get("limit", 50)
    order_by = spec.get("order_by")
    agg_field = spec.get("aggregate_field")

    qs = _get_queryset(model, user, hospital)
    qs = _apply_filters(qs, model, filters)

    if operation == "count":
        return {"type": "metric", "value": qs.count()}

    elif operation == "grouped_count" or operation == "top_n":
        if not group_by:
            group_by = "department__name_en"
        results = list(qs.values(group_by).annotate(count=Count("id")).order_by("-count")[:limit])
        label = _get_field_label(group_by)
        return {
            "type": "bar",
            "headers": [label, "Count"],
            "rows": [{label: r[group_by] or "Unknown", "Count": r["count"]} for r in results],
        }

    elif operation == "average":
        field = agg_field or "total_score"

        # Special handling for resolution_time calculation
        if field == "resolution_time" and model == "complaints":
            # Calculate average resolution time in hours
            # Use database-agnostic approach: cast duration to microseconds then divide
            result = (
                qs.filter(resolved_at__isnull=False)
                .annotate(
                    resolution_hours=ExpressionWrapper(
                        Cast(F("resolved_at") - F("activated_at"), FloatField()) / 3600000000.0,  # microseconds to hours
                        output_field=FloatField(),
                    )
                )
                .aggregate(avg=Avg("resolution_hours"))
            )

            avg_hours = result["avg"] or 0
            return {"type": "metric", "value": round(avg_hours, 1), "unit": "hours"}

    elif operation == "sum":
        field = agg_field
        result = qs.aggregate(total=Sum(field))
        return {"type": "metric", "value": result["total"] or 0}

    elif operation == "trend":
        trunc_func = {"month": TruncMonth, "week": TruncWeek, "day": TruncDate}.get(truncate or "month", TruncMonth)
        date_field = _get_date_field(model)
        results = list(
            qs.annotate(date=trunc_func(date_field)).values("date").annotate(count=Count("id")).order_by("date")
        )
        return {
            "type": "line",
            "headers": ["Date", "Count"],
            "rows": [
                {"Date": r["date"].strftime("%b %Y") if truncate == "month" else str(r["date"]), "Count": r["count"]}
                for r in results
            ],
        }

    elif operation == "list":
        if not fields:
            fields = _get_default_fields(model)
        if not order_by:
            order_by = f"-{_get_date_field(model)}"
        results = list(qs.values(*fields).order_by(order_by)[:limit])
        headers = [_get_field_label(f) for f in fields]
        rows = []
        for r in results:
            row = {}
            for f in fields:
                val = r.get(f)
                if hasattr(val, "isoformat"):
                    val = val.strftime("%Y-%m-%d")
                elif hasattr(val, "hex"):
                    val = str(val)[:8]
                elif val is None:
                    val = "—"
                row[_get_field_label(f)] = val
            rows.append(row)
        return {"type": "table", "headers": headers, "rows": rows}

    elif operation == "percentage":
        total = qs.count()
        if total == 0:
            return {"type": "metric", "value": 0}

        numerator_filters = spec.get("numerator_filters", [])
        if numerator_filters:
            num_qs = _get_queryset(model, user, hospital)
            num_qs = _apply_filters(num_qs, model, filters + numerator_filters)
            numerator = num_qs.count()
        else:
            numerator = total

        pct = round((numerator / total * 100), 1) if total > 0 else 0
        return {"type": "metric", "value": pct, "numerator": numerator, "denominator": total}

    elif operation == "leaderboard":
        if model != "physicians":
            if not group_by:
                group_by = "resolved_by__name" if model == "complaints" else "department__name_en"
            results = list(qs.values(group_by).annotate(count=Count("id")).order_by("-count")[:limit])
            label = _get_field_label(group_by)
            return {
                "type": "bar",
                "headers": [label, "Count"],
                "rows": [{label: r[group_by] or "Unknown", "Count": r["count"]} for r in results],
            }

        qs = qs.order_by("-average_rating")[:limit]
        return {
            "type": "table",
            "headers": ["Rank", "Name", "Rating", "Surveys", "Positive", "Negative"],
            "rows": [
                {
                    "Rank": i + 1,
                    "Name": p.staff.name_en if p.staff else "—",
                    "Rating": round(p.average_rating, 2) if p.average_rating else 0,
                    "Surveys": p.total_surveys or 0,
                    "Positive": p.positive_count or 0,
                    "Negative": p.negative_count or 0,
                }
                for i, p in enumerate(qs)
            ],
        }

    elif operation == "comparison":
        if not group_by:
            group_by = "department__name_en"
        results = list(qs.values(group_by).annotate(count=Count("id")).order_by("-count"))
        label = _get_field_label(group_by)
        return {
            "type": "bar",
            "headers": [label, "Count"],
            "rows": [{label: r[group_by] or "Unknown", "Count": r["count"]} for r in results],
        }

    return {"error": "Unknown operation"}


def _get_queryset(model_name, user, hospital):
    """Get base queryset with role/hospital filtering."""
    model_map = {
        "complaints": Complaint,
        "surveys": SurveyInstance,
        "actions": PXAction,
        "feedback": Feedback,
        "physicians": PhysicianMonthlyRating,
        "observations": Observation,
        "patients": Patient,
        "users": User,
    }

    Model = model_map.get(model_name, Complaint)
    qs = Model.objects.all()

    # Hospital filter
    if hospital:
        if model_name == "surveys":
            qs = qs.filter(survey_template__hospital=hospital)
        elif model_name == "physicians":
            qs = qs.filter(staff__department__hospital=hospital)
        elif model_name == "patients":
            qs = qs.filter(primary_hospital=hospital)
        elif model_name == "users":
            qs = qs.filter(hospital=hospital)
        elif hasattr(Model, "hospital"):
            qs = qs.filter(hospital=hospital)

    return qs


def _apply_filters(qs, model_name, filters):
    """Apply filters from spec to queryset."""
    date_field = _get_date_field(model_name)

    # Field mappings for related model lookups
    field_mappings = {
        "surveys": {
            "survey_type": "survey_template__survey_type",
            "hospital": "survey_template__hospital",
        },
        "observations": {
            "department": "assigned_department__name_en",
        },
        "physicians": {
            "physician_name": "staff__name_en",
            "department": "staff__department__name_en",
            "hospital": "staff__department__hospital",
            "rating": "average_rating",
            "survey_count": "total_surveys",
        },
    }

    for f in filters:
        field = f["field"]
        op = f["op"]
        value = f["value"]

        # Skip hospital filter - already handled by _get_queryset
        if field == "hospital":
            continue

        # Map field to actual ORM path if there's a mapping
        model_mappings = field_mappings.get(model_name, {})
        field = model_mappings.get(field, field)

        # Normalize dict-style relative dates to plain strings
        if isinstance(value, dict) and value.get("type") == "relative":
            value = value.get("value", value)

        # Resolve "date" alias to the model's actual date field
        if field == "date":
            field = date_field

        # Handle relative time filters
        if field.endswith("_at") or field == "created_at" or field == "completed_at" or field == "month":
            if isinstance(value, str):
                start, end = _parse_relative_date(value)
                if start and end:
                    qs = qs.filter(**{f"{field}__gte": start, f"{field}__lt": end})
                    continue

        # Standard filter operators
        if op == "=":
            try:
                qs = qs.filter(**{field: value})
            except FieldError:
                logger.warning(f"Skipping invalid filter: {field}={value} for model: {model_name}")
                continue
        elif op == "in":
            try:
                qs = qs.filter(**{f"{field}__in": value})
            except FieldError:
                logger.warning(f"Skipping invalid filter: {field} in {value} for model: {model_name}")
                continue
        elif op == "icontains":
            try:
                qs = qs.filter(**{f"{field}__icontains": value})
            except FieldError:
                logger.warning(f"Skipping invalid filter: {field} icontains {value} for model: {model_name}")
                continue
        elif op == "iexact":
            try:
                qs = qs.filter(**{f"{field}__iexact": value})
            except FieldError:
                logger.warning(f"Skipping invalid filter: {field} iexact {value} for model: {model_name}")
                continue
        elif op == "contains":
            try:
                qs = qs.filter(**{f"{field}__icontains": value})
            except FieldError:
                logger.warning(f"Skipping invalid filter: {field} contains {value} for model: {model_name}")
                continue
        elif op in ("gt", "gte", "lt", "lte"):
            # Check if value is a relative date string
            if isinstance(value, str):
                start, end = _parse_relative_date(value)
                if start and end:
                    # gte/gt → start boundary, lte/lt → end boundary
                    boundary = start if op in ("gte", "gt") else end
                    try:
                        qs = qs.filter(**{f"{field}__{op}": boundary})
                    except FieldError:
                        logger.warning(f"Skipping invalid filter: {field} {op} {boundary} for model: {model_name}")
                    continue
                # If not a known relative date, skip this filter to avoid crash
                logger.warning(f"Unknown date value for {field}: {value}")
                continue
            try:
                qs = qs.filter(**{f"{field}__{op}": value})
            except FieldError:
                logger.warning(f"Skipping invalid filter: {field} {op} {value} for model: {model_name}")
                continue
        elif op == "!=":
            try:
                qs = qs.exclude(**{field: value})
            except FieldError:
                logger.warning(f"Skipping invalid filter: {field} != {value} for model: {model_name}")
                continue

    return qs


def _parse_relative_date(value):
    """Parse relative date string into (start, end) datetimes."""
    if not isinstance(value, str) or len(value) < 2:
        return None, None

    now = timezone.now()

    date_map = {
        "last_month": (
            (now.replace(day=1) - timedelta(days=1)).replace(day=1),
            now.replace(day=1),
        ),
        "this_month": (now.replace(day=1, hour=0, minute=0, second=0, microsecond=0), now),
        "last_week": (now - timedelta(days=7), now),
        "this_week": (now - timedelta(days=now.weekday()), now),
        "last_quarter": (now - timedelta(days=90), now),
        "this_year": (now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0), now),
        "last_year": (now - timedelta(days=365), now),
        "30d": (now - timedelta(days=30), now),
        "7d": (now - timedelta(days=7), now),
        "90d": (now - timedelta(days=90), now),
        "6m": (now - timedelta(days=180), now),
        "1y": (now - timedelta(days=365), now),
    }

    return date_map.get(value, (None, None))


def _get_date_field(model_name):
    fields = {
        "complaints": "created_at",
        "surveys": "completed_at",
        "actions": "created_at",
        "feedback": "created_at",
        "physicians": "month",
        "observations": "created_at",
        "patients": "created_at",
        "users": "date_joined",
    }
    return fields.get(model_name, "created_at")


def _get_default_fields(model_name):
    fields = {
        "complaints": ["title", "severity", "status", "department__name_en", "created_at"],
        "surveys": ["total_score", "is_negative", "survey_template__survey_type", "completed_at"],
        "actions": ["title", "status", "category", "department__name_en", "created_at"],
        "feedback": ["feedback_type", "rating", "sentiment", "category", "created_at"],
        "observations": ["title", "severity", "status", "category__name_en", "created_at"],
        "physicians": ["physician_name", "department__name_en", "rating", "survey_count"],
        "patients": ["mrn", "first_name", "last_name", "nationality", "gender", "city", "created_at"],
        "users": ["username", "first_name", "last_name", "email", "role", "date_joined"],
    }
    return fields.get(model_name, ["id", "created_at"])


def _get_field_label(field):
    labels = {
        "department__name_en": "Department",
        "category": "Category",
        "severity": "Severity",
        "status": "Status",
        "source__name_en": "Source",
        "complaint_source_type": "Source Type",
        "feedback_type": "Type",
        "sentiment": "Sentiment",
        "survey_type": "Survey Type",
        "survey_template__survey_type": "Survey Type",
        "physician_name": "Name",
        "specialization": "Specialization",
        "rating": "Rating",
        "survey_count": "Surveys",
        "positive_count": "Positive",
        "neutral_count": "Neutral",
        "negative_count": "Negative",
        "month": "Month",
        "title": "Title",
        "total_score": "Score",
        "is_negative": "Negative",
        "priority": "Priority",
        "is_overdue": "Overdue",
        "created_at": "Created",
        "completed_at": "Completed",
        "resolved_at": "Resolved",
        "closed_at": "Closed",
        "resolution_time": "Resolution Time",
        "source_type": "Source",
        "category__name_en": "Category",
        "mrn": "MRN",
        "first_name": "First Name",
        "last_name": "Last Name",
        "nationality": "Nationality",
        "gender": "Gender",
        "city": "City",
        "date_of_birth": "Date of Birth",
        "email": "Email",
        "username": "Username",
        "role": "Role",
        "date_joined": "Joined",
    }
    return labels.get(field, field.replace("__name_en", "").replace("__", " ").replace("_", " ").title())


# =============================================================================
# Step 3: LLM generates narrative answer + Arrow.js interactive component
# =============================================================================


def _generate_answer_and_component(question, query_spec, data, client):
    """Generate both a narrative answer AND an interactive Arrow.js component."""
    # For metric queries, always generate a simple factual answer from the data
    if data.get("type") == "metric":
        value = data.get("value", 0)
        if value is None:
            value = 0
        return f"{value}", None

    if not client.is_configured():
        return _simple_answer(data), None

    system_prompt = """You are a frontend developer that generates interactive Arrow.js components for a healthcare analytics dashboard.

ARROW.JS API (available as globals):
- html`...` — Tagged template literal for DOM. ${() => expr} for reactive bindings, @click for events
- reactive(state) — Creates reactive state object. Changes auto-update DOM
- ApexCharts — Available for charts (https://apexcharts.com/docs/chart-types/)

DATA AVAILABLE:
- window.askData — {headers: [...], rows: [...]} or {type: "metric", value: N}
- ApexCharts — Available if typeof ApexCharts !== 'undefined'

REQUIRED: Your component MUST include:
1. View toggle buttons (Chart / Table / Cards) using reactive state
2. Chart view using ApexCharts (bar for grouped, area/line for trends)
3. Table view with collapsible rows
4. Cards view with individual metric cards
5. Smooth loading/error states
6. Click-to-filter on bar charts (clicking a department filters the table)

COMPONENT PATTERN:
```javascript
const data = window.askData || {};
const state = reactive({ view: 'chart', loading: false, filter: null });

function onBarClick(label) {
    state.filter = state.filter === label ? null : label;
}

export default html`
    <div class="p-3">
        <!-- View Toggle -->
        <div class="flex gap-2 mb-3">
            ${['chart', 'table', 'cards'].map(v => html`
                <button @click="${() => state.view = v}"
                    class="px-3 py-1 text-sm rounded transition ${() => state.view === v ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">
                    ${v.charAt(0).toUpperCase() + v.slice(1)}
                </button>
            `)}
        </div>
        ${() => {
            if (state.view === 'chart') return renderChart(data, state);
            if (state.view === 'table') return renderTable(data, state);
            if (state.view === 'cards') return renderCards(data, state);
            return html`<p>No data</p>`;
        }}
    </div>
`;
```

APEXCHARTS PATTERN (inside renderChart):
```javascript
function renderChart(data, state) {
    if (typeof ApexCharts === 'undefined') return html`<p class="text-gray-400 text-sm p-4">Chart unavailable</p>`;
    const container = html`<div class="apex-chart-container" style="height:280px;"></div>`;
    setTimeout(() => {
        const el = container.querySelector('.apex-chart-container');
        if (el && !el._chart) {
            el._chart = new ApexCharts(el, {
                series: [{ name: data.headers[1], data: data.rows.map(r => r[data.headers[1]]) }],
                chart: { type: 'bar', height: 280, toolbar: { show: false } },
                colors: ['#7c3aed', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899'],
                plotOptions: { bar: { distributed: true, borderRadius: 4, columnWidth: '60%' } },
                xaxis: { categories: data.rows.map(r => r[data.headers[0]]) },
                tooltip: { y: { formatter: v => v } },
            });
            el._chart.render();
        }
    }, 0);
    return container;
}
```

RULES:
- Use ONLY: html`...`, reactive(...), ApexCharts
- DO NOT use fetch, window.alert, or console.log
- Export with: export default html`...`
- Keep component under 80 lines
- Use Tailwind-like classes for styling

RESPOND WITH ONLY VALID JSON:
{
    "answer": "1-2 sentence insight",
    "component": "Arrow.js component code string"
}"""

    user_prompt = f"""Question: {question}

Data headers: {data.get('headers', [])}
Data rows ({len(data.get('rows', []))} items): {json.dumps(data.get('rows', [])[:3], indent=2, cls=DecimalEncoder)}
Data type: {data.get('type', 'unknown')}

Generate the Arrow.js component with view toggle (chart/table/cards), ApexCharts, and click-to-filter."""

    raw = client.chat(system_prompt, user_prompt, temperature=0.1, max_tokens=1024)

    if raw:
        try:
            result = json.loads(raw)
            answer = result.get("answer", _simple_answer(data))
            component = result.get("component")
            # Validate component has required exports
            if component and "export default" in component:
                return answer, component
            return answer, None
        except (json.JSONDecodeError, KeyError):
            pass

    return _simple_answer(data), None


def _simple_answer(data):
    """Fallback answer when LLM is unavailable."""
    if data.get("type") == "metric":
        value = data.get("value", 0)
        if "numerator" in data and "denominator" in data:
            return f"{data['numerator']} out of {data['denominator']} ({value}%)"
        return str(value)
    elif data.get("type") == "bar" and data.get("rows"):
        top = data["rows"][0]
        return f"Top: {list(top.values())[0]} with {list(top.values())[1]}"
    elif data.get("rows"):
        return f"Found {len(data['rows'])} records."
    return "Data retrieved."


# =============================================================================
# Chart type detection
# =============================================================================


def _determine_chart_type(spec, data):
    """Determine the best chart type for the data."""
    data_type = data.get("type")
    operation = spec.get("operation")

    if data_type == "metric":
        return "metric"
    elif data_type == "bar" or operation in ("grouped_count", "top_n", "comparison"):
        return "bar"
    elif data_type == "line" or operation == "trend":
        return "line"
    elif data_type == "table" or operation in ("list", "leaderboard"):
        return "table"
    return "bar"
