from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.conf import settings
import json

# Default filters for exam search widget. Can be overridden in Django settings
# as `GENERIC_EXAM_SEARCH_DEFAULT_FILTERS` mapping, e.g.:
# GENERIC_EXAM_SEARCH_DEFAULT_FILTERS = { 'archive': False, 'exam_mode': True }
DEFAULT_EXAM_SEARCH_FILTERS = getattr(
    settings, "GENERIC_EXAM_SEARCH_DEFAULT_FILTERS", {"archive": False, "exam_mode": True}
)


class UserSearchSelectMultipleWidget:
    """Reusable lightweight widget renderer for a searchable multi-user selector.

    Usage: instantiate with (name, value) where value is an iterable of user ids
    and call .render() to get the HTML fragment.
    """

    def __init__(self, name, value):
        self.name = name
        if value is None:
            self.value = []
        elif hasattr(value, "values_list"):
            self.value = list(value.values_list("pk", flat=True))
        elif isinstance(value, (list, tuple, set)):
            self.value = [v.pk if hasattr(v, "pk") else v for v in value]
        elif hasattr(value, "pk"):
            self.value = [value.pk]
        elif isinstance(value, (int, str)):
            self.value = [value]
        elif hasattr(value, "__iter__") and not isinstance(value, (str, bytes)):
            self.value = [v.pk if hasattr(v, "pk") else v for v in value]
        else:
            self.value = [value]

    def render(self):
        field_id = f"id_{self.name}"
        search_id = f"user_search_{self.name}"
        results_id = f"user_search_results_{self.name}"
        selected_list_id = f"selected_users_{self.name}"

        users = User.objects.filter(pk__in=self.value) if self.value else []
        options_html = ""
        selected_html = ""
        for u in users:
            # determine grade_text first, then include data-user-grade attribute on option
            grade_text = ""
            try:
                up = getattr(u, "userprofile", None)
                if up and getattr(up, "grade", None):
                    g = up.grade
                    grade_text = getattr(g, "name", str(g)) if g is not None else ""
            except Exception:
                grade_text = ""
            safe_grade_attr = (grade_text.replace('"', '&quot;') if grade_text else '')
            options_html += f'<option value="{u.pk}" selected data-user-grade="{safe_grade_attr}">{u.get_full_name() or u.username} - {u.email}</option>'

            # Build the selected item HTML incrementally to avoid accidental
            # mixing of boolean values into string concatenation (was causing
            # a TypeError in some environments).
            selected_html += (
                f'<li id="selected_user_{self.name}_{u.pk}" class="list-group-item d-flex justify-content-between align-items-center">'
                f'<span>{u.get_full_name() or u.username} <small class="text-muted">{u.email}</small>'
            )
            if grade_text:
                selected_html += f' <span class="badge bg-light text-dark text-muted ms-2 small border">{grade_text}</span>'
            selected_html += (
                '</span>'
                f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeUserFromField(\'{self.name}\', {u.pk})">Remove</button>'
                '</li>'
            )

        url = reverse("generic:user_search_widget")

        grades_options = '<option value="">All grades</option>'
        try:
            from generic.models import UserGrades

            grades_qs = UserGrades.objects.all()
            for g in grades_qs:
                grades_options += f'<option value="{g.pk}">{g.name}</option>'
        except Exception:
            grades_options = '<option value="">All grades</option>'

        html = f"""
        <div class="user-search-widget">
            <select name="{self.name}" id="{field_id}" multiple class="d-none">
                {options_html}
            </select>

            <div class="mb-2">
                <div class="d-flex gap-2 align-items-start">
                    <input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
                    <select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
                        {grades_options}
                    </select>
                    <!-- Help button for advanced search features -->
                    <button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
                </div>

                <div id="{results_id}" class="mt-2"></div>

                <!-- Collapsible help panel (hidden by default) -->
                <div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
                    <strong>Advanced search tips</strong>
                    <ul class="mb-0 mt-1">
                        <li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
                        <li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
                        <li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
                            <ul class="mb-0 mt-1">
                                <li><code>name:</code> or <code>full_name:</code> — matches first OR last name (e.g. <code>name:smith</code>).</li>
                                <li><code>first:</code> or <code>first_name:</code> — matches first name.</li>
                                <li><code>last:</code> or <code>last_name:</code> — matches last name.</li>
                                <li><code>email:</code> — matches email address.</li>
                                <li><code>username:</code> — matches username.</li>
                                <li><code>id:</code> or <code>pk:</code> — match by numeric user id (e.g. <code>id:123</code>).</li>
                                <li><code>grade:</code> — match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
                            </ul>
                        </li>
                        <li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
                        <li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
                        <li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
                    </ul>
                </div>
            </div>

            <div>
                <label class="form-label small">Selected users</label>
                <ul id="{selected_list_id}" class="list-group list-group-flush">
                    {selected_html}
                </ul>
            </div>

            <script>
                (function() {{
                    const search = document.getElementById('{search_id}');
                    const results = document.getElementById('{results_id}');
                    const select = document.getElementById('{field_id}');
                    const selectedList = document.getElementById('{selected_list_id}');
                    const gradeSelect = document.getElementById('grade_filter_{self.name}');
                    const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
                    const helpPanel = document.getElementById('user_search_help_panel_{self.name}');

                    // Expose helper functions immediately so clicks won't fail if
                    // earlier listeners throw an exception during setup.
                    window.addUserToField = function(fieldName, id, text, grade) {{
                        const sel = document.getElementById('id_' + fieldName);
                        if (!sel) return;
                        for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
                        const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
                        sel.appendChild(opt);
                        const listEl = document.getElementById('selected_users_' + fieldName);
                        if (!listEl) return;
                        const li = document.createElement('li');
                        li.className = 'list-group-item d-flex justify-content-between align-items-center';
                        li.id = 'selected_user_' + fieldName + '_' + id;
                        const span = document.createElement('span');
                        span.innerHTML = text + (grade ? ' <span class="badge bg-secondary ms-2">' + grade + '</span>' : '');
                        const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
                        btn.addEventListener('click', function() {{ removeUserFromField(fieldName, id); }});
                        li.appendChild(span); li.appendChild(btn);
                        listEl.appendChild(li);
                    }}

                    window.removeUserFromField = function(fieldName, id) {{
                        const sel = document.getElementById('id_' + fieldName);
                        if (!sel) return;
                        for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
                        const li = document.getElementById('selected_user_' + fieldName + '_' + id);
                        if (li && li.parentNode) li.parentNode.removeChild(li);
                    }}

                    window.addAllFromResults = function(fieldName) {{
                        const list = document.getElementById('user_search_results_list_' + fieldName);
                        if (!list) return;
                        const items = list.querySelectorAll('li[data-user-id]');
                        items.forEach(function(it) {{
                            const id = it.getAttribute('data-user-id');
                            const text = it.getAttribute('data-user-text') || it.textContent.trim();
                            const grade = it.getAttribute('data-user-grade') || '';
                            addUserToField(fieldName, id, text, grade);
                        }});
                    }}

                    let timeout = null;
                    function fetchResults(q) {{
                        const grade = gradeSelect ? gradeSelect.value : '';
                        if (!q || q.trim().length === 0) {{
                            results.innerHTML = '';
                            return;
                        }}
                        const url = '{url}?field=' + encodeURIComponent('{self.name}') + '&q=' + encodeURIComponent(q) + (grade ? '&grade=' + encodeURIComponent(grade) : '');
                        fetch(url)
                            .then(r => r.text())
                            .then(html => {{ results.innerHTML = html; }});
                    }}

                    search.addEventListener('keyup', function(e) {{
                        clearTimeout(timeout);
                        timeout = setTimeout(() => fetchResults(search.value), 300);
                    }});

                    // Prevent Enter from submitting the surrounding form; run the search instead
                    search.addEventListener('keydown', function(e) {{
                        if (e.key === 'Enter' || e.keyCode === 13) {{
                            e.preventDefault();
                            e.stopPropagation();
                            clearTimeout(timeout);
                            fetchResults(search.value);
                        }}
                    }});

                    gradeSelect && gradeSelect.addEventListener('change', function(e) {{
                        // re-run search with same query when grade changes
                        clearTimeout(timeout);
                        timeout = setTimeout(() => fetchResults(search.value), 50);
                    }});

                    // Toggle help panel visibility
                    if (helpBtn && helpPanel) {{
                        helpBtn.addEventListener('click', function(e) {{
                            e.preventDefault();
                            helpPanel.classList.toggle('d-none');
                            const expanded = !helpPanel.classList.contains('d-none');
                            helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
                        }});
                    }}

                    // fieldName for this widget instance
                    const widgetFieldName = '{self.name}';

                    // Expose helper functions early so they're available even if
                    // a later part of this IIFE throws (avoids ReferenceError on click).


                    // Delegate clicks from results: add single or add-all (works for dynamically inserted content)
                    results.addEventListener('click', function(e) {{
                        const addBtn = e.target.closest('.user-add-btn');
                        if (addBtn) {{
                            const id = addBtn.getAttribute('data-user-id');
                            const text = addBtn.getAttribute('data-user-text') || addBtn.textContent.trim();
                            // Try button attribute, then list-item dataset, then visible badge text
                            let grade = addBtn.getAttribute('data-user-grade') || '';
                            if (!grade) {{
                                const li = addBtn.closest('li[data-user-id]');
                                if (li && li.dataset && li.dataset.userGrade) grade = li.dataset.userGrade;
                                else {{
                                    const badge = addBtn.closest('li') ? addBtn.closest('li').querySelector('.badge') : null;
                                    if (badge) grade = badge.textContent.trim();
                                }}
                            }}
                            addUserToField(widgetFieldName, id, text, grade);
                            return;
                        }}
                        const addAllBtnDynamic = e.target.closest('.user-add-all-btn');
                        if (addAllBtnDynamic) {{
                            e.preventDefault();
                            addAllFromResults(widgetFieldName);
                            return;
                        }}
                    }});

                    // Wire up the "Add all results" button if present
                    const addAllBtn = results.parentElement.querySelector('.user-add-all-btn');
                    if (addAllBtn) {{
                        addAllBtn.addEventListener('click', function(e) {{
                            e.preventDefault();
                            addAllFromResults(widgetFieldName);
                        }});
                    }}

                    

                    // Initialize visible selected-list from any pre-existing <option selected> entries
                    (function initSelectedListFromOptions() {{
                        try {{
                            const sel = document.getElementById('{field_id}');
                            const listEl = document.getElementById('{selected_list_id}');
                            if (!sel || !listEl) return;
                            for (let i=0;i<sel.options.length;i++) {{
                                const opt = sel.options[i];
                                const id = opt.value;
                                if (!id) continue;
                                const liId = 'selected_user_' + '{self.name}' + '_' + id;
                                const existingLi = document.getElementById(liId);
                                const grade = opt.getAttribute('data-user-grade') || '';

                                if (existingLi) {{
                                    // If server-rendered li exists but has no badge, add one from the option attribute
                                    const hasBadge = existingLi.querySelector('.badge');
                                    if (!hasBadge && grade) {{
                                        const span = existingLi.querySelector('span');
                                        if (span) {{
                                            const badge = document.createElement('span');
                                            badge.className = 'badge bg-secondary ms-2';
                                            badge.textContent = grade;
                                            span.appendChild(badge);
                                        }}
                                    }}
                                    continue;
                                }}

                                // build li from option when not rendered server-side
                                const text = opt.textContent || opt.innerText || opt.text;
                                const li = document.createElement('li');
                                li.className = 'list-group-item d-flex justify-content-between align-items-center';
                                li.id = liId;
                                const span = document.createElement('span');
                                span.innerHTML = text + (grade ? ' <span class="badge bg-secondary ms-2">' + grade + '</span>' : '');
                                const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
                                btn.addEventListener('click', function() {{ removeUserFromField('{self.name}', id); }});
                                li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
                            }}
                        }} catch (e) {{ /* ignore init errors */ }}
                    }})();
                }})();
            </script>
        </div>
        """

        return mark_safe(html)


class UserSearchWidget:
    """Django-widget-compatible lightweight wrapper around
    `UserSearchSelectMultipleWidget` so it can be reused as a field widget.

    Implements the small subset of the Widget API that Django's forms
    and templates expect: `render`, `value_from_datadict`, `id_for_label`,
    `use_required_attribute`, and a couple of simple attributes.
    """

    is_hidden = False
    needs_multipart_form = False
    use_fieldset = False

    def __init__(self, field=None):
        # keep a reference to the original field if available (used for
        # delegation such as `use_required_attribute`).
        self.field = field
        self.attrs = {}

    def render(self, name, value, attrs=None, renderer=None):
        self.attrs = attrs or {}
        value = value or []
        widget = UserSearchSelectMultipleWidget(name, value)
        return widget.render()

    def value_from_datadict(self, data, files, name):
        # Most form backends provide getlist
        if hasattr(data, "getlist"):
            return data.getlist(name)
        val = data.get(name)
        if val is None:
            return []
        return [val]

    def id_for_label(self, id_):
        try:
            if hasattr(self, "attrs") and self.attrs and self.attrs.get("id"):
                return self.attrs.get("id")
        except Exception:
            pass
        return id_

    def use_required_attribute(self, initial):
        try:
            if hasattr(self, "field") and getattr(self, "field") is not None:
                orig_widget = getattr(self.field, "widget", None)
                if orig_widget and hasattr(orig_widget, "use_required_attribute"):
                    return orig_widget.use_required_attribute(initial)
        except Exception:
            pass
        return False


class ExamSearchSelectMultipleWidget:
    """Lightweight renderer for selecting exams across types.

    Parameters
    - name, value: same as form widget usage
    - exam_model: optional Django model class for an exam type (used to
      adapt display and search behaviour)
    - display_fields: optional list of extra fields to show in results
    """

    def __init__(self, name, value, exam_model=None, display_fields=None, default_filters=None):
        self.name = name
        self.value = value or []
        self.exam_model = exam_model
        self.display_fields = display_fields or []
        # default filters for this widget instance; fall back to module defaults
        self.default_filters = default_filters if default_filters is not None else DEFAULT_EXAM_SEARCH_FILTERS

    def _archive_select_options(self):
        # Render a three-state select for archive filtering: Any / Not archived / Archived only
        df = self.default_filters or {}
        sel = None
        if isinstance(df, dict) and 'archive' in df:
            if df['archive'] is True:
                sel = '1'
            elif df['archive'] is False:
                sel = '0'
        parts = []
        parts.append(f"<option value=\"\"{' selected' if sel is None else ''}>Any</option>")
        parts.append(f"<option value=\"0\"{' selected' if sel == '0' else ''}>Not archived</option>")
        parts.append(f"<option value=\"1\"{' selected' if sel == '1' else ''}>Archived only</option>")
        return ''.join(parts)

    def _binary_select_options(self, key, true_label, false_label):
        df = self.default_filters or {}
        sel = None
        if isinstance(df, dict) and key in df:
            sel = '1' if df[key] else '0'
        parts = []
        parts.append(f"<option value=\"\"{' selected' if sel is None else ''}>Any</option>")
        parts.append(f"<option value=\"0\"{' selected' if sel == '0' else ''}>{false_label}</option>")
        parts.append(f"<option value=\"1\"{' selected' if sel == '1' else ''}>{true_label}</option>")
        return ''.join(parts)

    def render(self):
        field_id = f"id_{self.name}"
        search_id = f"exam_search_{self.name}"
        results_id = f"exam_search_results_{self.name}"
        selected_list_id = f"selected_exams_{self.name}"

        # Render currently selected exams as <option>s
        options_html = ""
        selected_html = ""
        try:
            if self.value:
                from django.apps import apps

                # Accept either pks or model instances
                instances = []
                for v in self.value:
                    if hasattr(v, "pk"):
                        instances.append(v)
                    else:
                        # try to resolve by pk across possible models
                        # when exam_model is provided prefer that
                        if self.exam_model is not None:
                            try:
                                inst = self.exam_model.objects.filter(pk=v).first()
                                if inst:
                                    instances.append(inst)
                                    continue
                            except Exception:
                                pass
                        # fallback: try generic lookup across known exam models is expensive;
                        # just attempt to treat v as a pk for display-less option
                        instances.append(type("_", (), {"pk": v, "__str__": lambda s: str(v)}))

                for ex in instances:
                    label = str(ex)
                    pk = getattr(ex, "pk", "")
                    options_html += f'<option value="{pk}" selected>{label}</option>'
                    # badges for metadata where available
                    # use smaller, less vibrant badges: low-opacity background + matching text color
                    archive_badge = '<span class="badge small border bg-secondary bg-opacity-10 text-secondary ms-2">Archived</span>' if getattr(ex, 'archive', False) else ''
                    open_badge = '<span class="badge small border bg-success bg-opacity-10 text-success ms-2">Open</span>' if getattr(ex, 'open_access', False) else ''
                    mode_badge = '<span class="badge small border bg-info bg-opacity-10 text-info ms-2">Exam mode</span>' if getattr(ex, 'exam_mode', False) else ''
                    cand_badge = '<span class="badge small border bg-warning bg-opacity-10 text-warning ms-2">Candidates only</span>' if getattr(ex, 'candidates_only', False) else ''
                    selected_html += (
                        f'<li id="selected_exam_{self.name}_{pk}" class="list-group-item d-flex justify-content-between align-items-center">'
                        f'<span>{label}{archive_badge}{open_badge}{mode_badge}{cand_badge}</span>'
                        f'<button type="button" class="btn btn-sm btn-outline-danger ms-2" onclick="removeExamFromField(\'{self.name}\', {pk})">Remove</button>'
                        f'</li>'
                    )
        except Exception:
            options_html = ""
            selected_html = ""

        url = reverse("generic:exam_search_widget") if reverse else ""

        html = f"""
        <div class="exam-search-widget">
            <select name="{self.name}" id="{field_id}" multiple class="d-none">
                {options_html}
            </select>

            <div class="mb-2">
                <div class="d-flex gap-2 align-items-start">
                    <input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search exams by name or code" />
                    <!-- optional type indicator -->
                    {f'<div class="ms-2"><span class="badge bg-primary text-white small fw-semibold" aria-hidden="true">{self.exam_model._meta.verbose_name.title()}</span><span class="visually-hidden">Exam type: {self.exam_model._meta.verbose_name.title()}</span></div>' if self.exam_model is not None else ''}
                    <button type="button" id="exam_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="exam_search_help_panel_{self.name}" title="Advanced search help">?</button>
                </div>
                <div id="exam_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
                    <strong>Advanced search tips</strong>
                    <div class="mb-2 mt-2">
                        <strong class="small">Filters</strong>
                            <div class="d-flex gap-2 mt-1 align-items-center">
                            <div>
                                <label class="form-label small mb-0 me-2" for="filter_archive_{self.name}">Archive</label>
                                <select id="filter_archive_{self.name}" class="form-select form-select-sm" style="max-width:160px">
                                    {self._archive_select_options() if hasattr(self, '_archive_select_options') else ''}
                                </select>
                            </div>
                            <div>
                                <label class="form-label small mb-0 me-2" for="filter_open_access_{self.name}">Open access</label>
                                <select id="filter_open_access_{self.name}" class="form-select form-select-sm" style="max-width:160px">
                                    {self._binary_select_options('open_access', 'Open access only', 'Not open access') if hasattr(self, '_binary_select_options') else ''}
                                </select>
                            </div>
                            <div>
                                <label class="form-label small mb-0 me-2" for="filter_exam_mode_{self.name}">Exam mode</label>
                                <select id="filter_exam_mode_{self.name}" class="form-select form-select-sm" style="max-width:160px">
                                    {self._binary_select_options('exam_mode', 'Exam mode only', 'Not exam mode') if hasattr(self, '_binary_select_options') else ''}
                                </select>
                            </div>
                            <div>
                                <label class="form-label small mb-0 me-2" for="filter_candidates_only_{self.name}">Candidates only</label>
                                <select id="filter_candidates_only_{self.name}" class="form-select form-select-sm" style="max-width:160px">
                                    {self._binary_select_options('candidates_only', 'Candidates only', 'Not candidates only') if hasattr(self, '_binary_select_options') else ''}
                                </select>
                            </div>
                        </div>
                    </div>
                    <ul class="mb-0 mt-1">
                        <li>Basic: type any part of an exam's name, code or identifier (case-insensitive substring match).</li>
                        <li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
                            <ul class="mb-0 mt-1">
                                <li><code>name:</code> — matches the exam name.</li>
                                <li><code>id:</code> or <code>pk:</code> — match by numeric id.</li>
                                <li><code>date:</code> — match by date (YYYY-MM-DD) where available.</li>
                            </ul>
                        </li>
                        <li>To add exams, click the <em>Add</em> button beside a result. Use <em>Add all results</em> to import visible matches.</li>
                        <li>Wildcard: use <code>*</code> to broaden results; wildcards may return more results and are rate-limited.</li>
                    </ul>
                </div>

                <div id="{results_id}" class="mt-2"></div>
            </div>

            <div>
                <label class="form-label small">Selected exams</label>
                <ul id="{selected_list_id}" class="list-group list-group-flush">
                    {selected_html}
                </ul>
                <div class="mt-2 text-end">
                    <button type="button" id="exam_remove_all_{self.name}" class="btn btn-sm btn-outline-danger exam-remove-all-btn d-none py-0 px-1 small" data-field="{self.name}">Remove all</button>
                </div>
            </div>

            <script>
                (function() {{
                    const search = document.getElementById('{search_id}');
                    const results = document.getElementById('{results_id}');
                    const select = document.getElementById('{field_id}');
                    const selectedList = document.getElementById('{selected_list_id}');
                    const helpBtn = document.getElementById('exam_search_help_btn_{self.name}');
                    const helpPanel = document.getElementById('exam_search_help_panel_{self.name}');

                    const defaultModel = '{self.exam_model._meta.label if self.exam_model is not None else ''}';
                    const defaultFilters = {json.dumps(self.default_filters)};
                    const widgetFieldName = '{self.name}';

                    let timeout = null;

                    // Parse filter tokens embedded in the search box, e.g. "archive:1 open_access:0"
                    const FILTER_NAMES = ['archive','open_access','exam_mode','candidates_only'];
                    const tokenRe = new RegExp('(?:^|\\s)(' + FILTER_NAMES.join('|') + '):\\s*(1|0|true|false|yes|no)\\b','gi');

                    function parseFilterTokens(q) {{
                        const filters = {{}};
                        if (!q) return {{ cleaned: '', filters: filters }};
                        let cleaned = q.replace(tokenRe, function(m, name, val) {{
                            filters[name] = /^(1|true|yes)$/i.test(val);
                            return ' ';
                        }});
                        cleaned = cleaned.replace(/\s+/g,' ').trim();
                        return {{ cleaned: cleaned, filters: filters }};
                    }}

                    function fetchResults(rawQ) {{
                        const parsed = parseFilterTokens(rawQ || '');
                        const q = parsed.cleaned;

                        const modelParam = defaultModel ? '&model=' + encodeURIComponent(defaultModel) : '';
                        const f_archive = document.getElementById('filter_archive_{self.name}');
                        const f_open = document.getElementById('filter_open_access_{self.name}');
                        const f_exam_mode = document.getElementById('filter_exam_mode_{self.name}');
                        const f_candidates = document.getElementById('filter_candidates_only_{self.name}');
                        let extra = '';

                        // still respect filters even when query is empty: show results for wildcard if filters present
                        // but don't send empty q param; use '*' to trigger wildcard behaviour if any filter present
                        if (!q || q.trim().length === 0) {{
                            let anyFilter = Object.keys(parsed.filters).length > 0;
                            if (!anyFilter) {{
                                try {{
                                    if (f_archive) {{
                                        const tag = f_archive.tagName ? f_archive.tagName.toLowerCase() : '';
                                        if (tag === 'select') {{
                                            if (f_archive.value === '1' || f_archive.value === '0') anyFilter = true;
                                        }} else if (f_archive.checked) anyFilter = true;
                                    }}
                                    if (f_open && f_open.checked) anyFilter = true;
                                    if (f_exam_mode && f_exam_mode.checked) anyFilter = true;
                                    if (f_candidates && f_candidates.checked) anyFilter = true;
                                }} catch(e) {{ /* ignore */ }}
                            }}
                            if (!anyFilter) {{ results.innerHTML = ''; return; }}
                        }}

                        // Only send filter params when their controls are present.
                        // - `archive`: explicit when present (1 = archived only, 0 = not archived)
                        // - other filters: only active when checked (send name=1)
                        function appendFilterParam(name, el) {{
                            if (!el) return;
                            const tag = el.tagName ? el.tagName.toLowerCase() : '';
                            if (tag === 'select') {{
                                const v = el.value;
                                if (v === '1' || v === '0') extra += '&' + encodeURIComponent(name) + '=' + v;
                            }} else {{
                                // checkbox fallback: send explicit 1/0 when present
                                extra += '&' + encodeURIComponent(name) + '=' + (el.checked ? '1' : '0');
                            }}
                        }}

                        appendFilterParam('archive', f_archive);
                        appendFilterParam('open_access', f_open);
                        appendFilterParam('exam_mode', f_exam_mode);
                        appendFilterParam('candidates_only', f_candidates);

                        const sendQ = (q && q.length) ? q : '*';
                        const url = '{url}?field=' + encodeURIComponent(widgetFieldName) + '&q=' + encodeURIComponent(sendQ) + modelParam + extra;
                        fetch(url)
                            .then(r => r.text())
                            .then(html => {{ results.innerHTML = html; }});
                    }}

                    // wire up input events
                    search.addEventListener('keyup', function(e) {{
                        clearTimeout(timeout);
                        timeout = setTimeout(() => {{
                            fetchResults(search.value);
                        }}, 300);
                    }});

                    // Prevent Enter from submitting the surrounding form; run the search instead
                    search.addEventListener('keydown', function(e) {{
                        if (e.key === 'Enter' || e.keyCode === 13) {{
                            e.preventDefault();
                            e.stopPropagation();
                            clearTimeout(timeout);
                            fetchResults(search.value);
                        }}
                    }});

                    // update search input when filters change
                    ['filter_archive_{self.name}','filter_open_access_{self.name}','filter_exam_mode_{self.name}','filter_candidates_only_{self.name}'].forEach(function(id) {{
                        const el = document.getElementById(id);
                        if (el) el.addEventListener('change', function() {{ clearTimeout(timeout); timeout = setTimeout(() => fetchResults(search.value), 50); }});
                    }});

                    // Toggle help panel visibility
                    if (helpBtn && helpPanel) {{
                        helpBtn.addEventListener('click', function(e) {{
                            e.preventDefault();
                            helpPanel.classList.toggle('d-none');
                            const expanded = !helpPanel.classList.contains('d-none');
                            helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
                        }});
                    }}

                    // Delegate clicks from results: add single or add-all (works for dynamically inserted content)
                    results.addEventListener('click', function(e) {{
                        const addBtn = e.target.closest('.exam-add-btn');
                        if (addBtn) {{
                            const id = addBtn.getAttribute('data-exam-id');
                            const text = addBtn.getAttribute('data-exam-text') || addBtn.textContent.trim();
                            addExamToField(widgetFieldName, id, text);
                            return;
                        }}
                        const addAllBtnDynamic = e.target.closest('.exam-add-all-btn');
                        if (addAllBtnDynamic) {{
                            e.preventDefault();
                            addAllExamsFromResults(widgetFieldName);
                            return;
                        }}
                    }});


                    // Wire up "Add all results" static button if present (fallback)
                    const addAllBtn = results.parentElement.querySelector('.exam-add-all-btn');
                    if (addAllBtn) {{
                        addAllBtn.addEventListener('click', function(e) {{
                            e.preventDefault();
                            addAllExamsFromResults(widgetFieldName);
                        }});
                    }}

                    // Initialize remove-all visibility based on any pre-populated selections
                    try {{
                        var initList = document.getElementById('selected_exams_' + widgetFieldName);
                        var remBtnInit = document.getElementById('exam_remove_all_' + widgetFieldName);
                        if (remBtnInit && initList) {{
                            remBtnInit.classList.toggle('d-none', initList.querySelectorAll('li').length < 2);
                        }}
                    }} catch (e) {{ /* ignore in older browsers */ }}

                    // Wire up "Remove all" button for this widget instance
                    const removeAllBtn = document.getElementById('exam_remove_all_' + widgetFieldName);
                    if (removeAllBtn) {{
                        removeAllBtn.addEventListener('click', function(e) {{
                            e.preventDefault();
                            removeAllExamsFromField(widgetFieldName);
                        }});
                    }}

                    window.addExamToField = function(fieldName, id, text) {{
                        const sel = document.getElementById('id_' + fieldName);
                        if (!sel) return;
                        for (let i=0;i<sel.options.length;i++) {{ if (sel.options[i].value == id) return; }}
                        const opt = document.createElement('option'); opt.value = id; opt.selected = true; opt.text = text;
                        sel.appendChild(opt);
                        const listEl = document.getElementById('selected_exams_' + fieldName);
                        if (!listEl) return;
                        const li = document.createElement('li');
                        li.className = 'list-group-item d-flex justify-content-between align-items-center';
                        li.id = 'selected_exam_' + fieldName + '_' + id;
                        const span = document.createElement('span'); span.innerHTML = text;
                        const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-sm btn-outline-danger ms-2'; btn.innerText = 'Remove';
                        btn.addEventListener('click', function() {{ removeExamFromField(fieldName, id); }});
                        li.appendChild(span); li.appendChild(btn); listEl.appendChild(li);
                        // update the remove-all button visibility
                        var remBtn = document.getElementById('exam_remove_all_' + fieldName);
                        if (remBtn) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
                    }}

                    window.removeExamFromField = function(fieldName, id) {{
                        const sel = document.getElementById('id_' + fieldName);
                        if (!sel) return;
                        for (let i=sel.options.length-1;i>=0;i--) {{ if (sel.options[i].value == id) sel.remove(i); }}
                        const li = document.getElementById('selected_exam_' + fieldName + '_' + id);
                        if (li && li.parentNode) li.parentNode.removeChild(li);
                        // update the remove-all button visibility
                        var remBtn = document.getElementById('exam_remove_all_' + fieldName);
                        var listEl = document.getElementById('selected_exams_' + fieldName);
                        if (remBtn && listEl) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
                    }}

                    window.addAllExamsFromResults = function(fieldName) {{
                        const list = document.getElementById('exam_search_results_list_' + fieldName);
                        if (!list) return;
                        const items = list.querySelectorAll('li[data-exam-id]');
                        items.forEach(function(it) {{
                            const id = it.getAttribute('data-exam-id');
                            const text = it.getAttribute('data-exam-text') || it.textContent.trim();
                            addExamToField(fieldName, id, text);
                        }});
                        // ensure remove-all button is visible if more than one
                        var remBtn = document.getElementById('exam_remove_all_' + fieldName);
                        var listEl = document.getElementById('selected_exams_' + fieldName);
                        if (remBtn && listEl) {{ remBtn.classList.toggle('d-none', listEl.querySelectorAll('li').length < 2); }}
                    }}

                    window.removeAllExamsFromField = function(fieldName) {{
                        const sel = document.getElementById('id_' + fieldName);
                        if (!sel) return;
                        // remove all options
                        for (let i=sel.options.length-1;i>=0;i--) {{ sel.remove(i); }}
                        // clear visible list
                        const listEl = document.getElementById('selected_exams_' + fieldName);
                        if (listEl) {{ listEl.innerHTML = ''; }}
                        // hide remove-all button
                        const remBtn = document.getElementById('exam_remove_all_' + fieldName);
                        if (remBtn) {{ remBtn.classList.add('d-none'); }}
                    }}
                }})();
            </script>
        </div>
        """

        return mark_safe(html)


class ExamSearchWidget:
    """Widget wrapper that is compatible with Django form machinery.

    Accepts `exam_model` to specialise behaviour/display.
    """

    is_hidden = False
    needs_multipart_form = False
    use_fieldset = False

    def __init__(self, field=None, exam_model=None, display_fields=None, default_filters=None):
        self.field = field
        self.attrs = {}
        self.exam_model = exam_model
        self.display_fields = display_fields or []
        # allow overriding defaults per widget instance
        self.default_filters = default_filters if default_filters is not None else DEFAULT_EXAM_SEARCH_FILTERS

    def render(self, name, value, attrs=None, renderer=None):
        self.attrs = attrs or {}
        value = value or []
        widget = ExamSearchSelectMultipleWidget(
            name,
            value,
            exam_model=self.exam_model,
            display_fields=self.display_fields,
            default_filters=self.default_filters,
        )
        return widget.render()

    def value_from_datadict(self, data, files, name):
        if hasattr(data, 'getlist'):
            return data.getlist(name)
        val = data.get(name)
        if val is None:
            return []
        return [val]

    def id_for_label(self, id_):
        try:
            if hasattr(self, 'attrs') and self.attrs and self.attrs.get('id'):
                return self.attrs.get('id')
        except Exception:
            pass
        return id_

    def use_required_attribute(self, initial):
        try:
            if hasattr(self, 'field') and getattr(self, 'field') is not None:
                orig_widget = getattr(self.field, 'widget', None)
                if orig_widget and hasattr(orig_widget, 'use_required_attribute'):
                    return orig_widget.use_required_attribute(initial)
        except Exception:
            pass
        return False
