from __future__ import annotations

import datetime
import json
from urllib.parse import urlencode

from asgiref.sync import async_to_sync
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import IntegrityError
from django.db.models import Count
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views import View

from core.clients.transport import send_message_raw
from core.models import (
    AnswerSuggestionEvent,
    Chat,
    ChatTaskSource,
    DerivedTask,
    Person,
    PersonIdentifier,
    PlatformChatLink,
    TaskCompletionPattern,
    TaskEpic,
    TaskProject,
    TaskProviderConfig,
)
from core.tasks.chat_defaults import (
    SAFE_TASK_FLAGS_DEFAULTS,
    ensure_default_source_for_chat,
    normalize_channel_identifier,
)
from core.tasks.engine import create_task_record_and_sync


def _upsert_task_source(
    *,
    user,
    service: str,
    channel_identifier: str,
    project,
    epic=None,
    enabled: bool = True,
    settings: dict | None = None,
):
    service_key = str(service or "").strip().lower()
    normalized_identifier = normalize_channel_identifier(service_key, channel_identifier)
    if not service_key or not normalized_identifier:
        return None, False
    source, created = ChatTaskSource.objects.get_or_create(
        user=user,
        service=service_key,
        channel_identifier=normalized_identifier,
        defaults={
            "project": project,
            "epic": epic,
            "enabled": bool(enabled),
            "settings": dict(settings or {}),
        },
    )
    changed_fields = []
    if source.project_id != getattr(project, "id", None):
        source.project = project
        changed_fields.append("project")
    if source.epic_id != getattr(epic, "id", None):
        source.epic = epic
        changed_fields.append("epic")
    if bool(source.enabled) != bool(enabled):
        source.enabled = bool(enabled)
        changed_fields.append("enabled")
    settings_payload = dict(settings or {})
    if dict(source.settings or {}) != settings_payload:
        source.settings = settings_payload
        changed_fields.append("settings")
    if changed_fields:
        source.save(update_fields=changed_fields + ["updated_at"])
    return source, created


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


def _parse_prefixes(value: str) -> list[str]:
    text = str(value or "").strip()
    if not text:
        return ["task:", "todo:"]
    rows = []
    for row in text.split(","):
        item = str(row or "").strip().lower()
        if item and item not in rows:
            rows.append(item)
    return rows or ["task:", "todo:"]


def _looks_like_old_risky_defaults(raw: dict) -> bool:
    row = dict(raw or {})
    mode = str(row.get("match_mode") or "").strip().lower()
    require_prefix = _to_bool(row.get("require_prefix"), False)
    prefixes = _parse_prefixes(",".join(list(row.get("allowed_prefixes") or [])))
    min_chars = int(row.get("min_chars") or 8)
    return (
        mode in {"", "balanced"}
        and (not require_prefix)
        and prefixes == ["task:", "todo:", "action:"]
        and min_chars >= 8
    )


def _normalized_safe_flags(raw: dict | None) -> dict:
    row = dict(raw or {})
    defaults = dict(SAFE_TASK_FLAGS_DEFAULTS)
    if _looks_like_old_risky_defaults(row):
        return defaults
    merged = dict(defaults)
    merged.update(
        {
            "derive_enabled": _to_bool(
                row.get("derive_enabled"), defaults["derive_enabled"]
            ),
            "match_mode": str(row.get("match_mode") or defaults["match_mode"])
            .strip()
            .lower()
            or defaults["match_mode"],
            "require_prefix": _to_bool(
                row.get("require_prefix"), defaults["require_prefix"]
            ),
            "allowed_prefixes": _parse_prefixes(
                ",".join(
                    list(row.get("allowed_prefixes") or defaults["allowed_prefixes"])
                )
            ),
            "completion_enabled": _to_bool(
                row.get("completion_enabled"), defaults["completion_enabled"]
            ),
            "ai_title_enabled": _to_bool(
                row.get("ai_title_enabled"), defaults["ai_title_enabled"]
            ),
            "announce_task_id": _to_bool(
                row.get("announce_task_id"), defaults["announce_task_id"]
            ),
            "min_chars": max(1, int(row.get("min_chars") or defaults["min_chars"])),
        }
    )
    return merged


def _apply_safe_defaults_for_user(user) -> None:
    projects = list(TaskProject.objects.filter(user=user).only("id", "settings"))
    for row in projects:
        normalized = _normalized_safe_flags(row.settings)
        if dict(row.settings or {}) != normalized:
            row.settings = normalized
            row.save(update_fields=["settings", "updated_at"])
    sources = list(ChatTaskSource.objects.filter(user=user).only("id", "settings"))
    for row in sources:
        normalized = _normalized_safe_flags(row.settings)
        if dict(row.settings or {}) != normalized:
            row.settings = normalized
            row.save(update_fields=["settings", "updated_at"])


def _flags_from_post(request, prefix: str = "") -> dict:
    def key(name: str) -> str:
        return f"{prefix}{name}" if prefix else name

    defaults = dict(SAFE_TASK_FLAGS_DEFAULTS)
    return {
        "derive_enabled": _to_bool(
            request.POST.get(key("derive_enabled")), defaults["derive_enabled"]
        ),
        "match_mode": str(request.POST.get(key("match_mode")) or defaults["match_mode"])
        .strip()
        .lower()
        or defaults["match_mode"],
        "require_prefix": _to_bool(
            request.POST.get(key("require_prefix")), defaults["require_prefix"]
        ),
        "allowed_prefixes": _parse_prefixes(
            str(
                request.POST.get(key("allowed_prefixes"))
                or ",".join(defaults["allowed_prefixes"])
            )
        ),
        "completion_enabled": _to_bool(
            request.POST.get(key("completion_enabled")), defaults["completion_enabled"]
        ),
        "ai_title_enabled": _to_bool(
            request.POST.get(key("ai_title_enabled")), defaults["ai_title_enabled"]
        ),
        "announce_task_id": _to_bool(
            request.POST.get(key("announce_task_id")), defaults["announce_task_id"]
        ),
        "min_chars": max(
            1,
            int(
                str(
                    request.POST.get(key("min_chars")) or str(defaults["min_chars"])
                ).strip()
                or str(defaults["min_chars"])
            ),
        ),
    }


def _flags_with_defaults(raw: dict | None) -> dict:
    return _normalized_safe_flags(raw)


def _settings_redirect(request):
    service = str(
        request.POST.get("prefill_service") or request.GET.get("service") or ""
    ).strip()
    identifier = str(
        request.POST.get("prefill_identifier") or request.GET.get("identifier") or ""
    ).strip()
    if service and identifier:
        return redirect(
            f"{request.path}?{urlencode({'service': service, 'identifier': identifier})}"
        )
    return redirect("tasks_settings")


def _ensure_default_completion_patterns(user) -> None:
    defaults = ("done", "completed", "fixed")
    existing = set(
        str(row or "").strip().lower()
        for row in TaskCompletionPattern.objects.filter(user=user).values_list(
            "phrase", flat=True
        )
    )
    next_pos = TaskCompletionPattern.objects.filter(user=user).count()
    for phrase in defaults:
        if phrase in existing:
            continue
        TaskCompletionPattern.objects.create(
            user=user,
            phrase=phrase,
            enabled=True,
            position=next_pos,
        )
        next_pos += 1


def _service_label(service: str) -> str:
    key = str(service or "").strip().lower()
    labels = {
        "signal": "Signal",
        "whatsapp": "WhatsApp",
        "instagram": "Instagram",
        "xmpp": "XMPP",
        "web": "Web",
    }
    return labels.get(key, key.title() if key else "Unknown")


def _format_task_event_payload(raw_payload):
    payload = raw_payload
    if payload is None:
        payload = {}
    if isinstance(payload, str):
        text = payload.strip()
        if not text:
            return {
                "summary_items": [],
                "pretty_text": "{}",
                "is_mapping": False,
            }
        try:
            parsed = json.loads(text)
            payload = parsed
        except Exception:
            return {
                "summary_items": [("text", text[:140])],
                "pretty_text": text,
                "is_mapping": False,
            }

    if isinstance(payload, dict):
        summary = []
        preferred = (
            "source",
            "reason",
            "reaction",
            "emoji",
            "presence",
            "last_seen_ts",
        )
        for key in preferred:
            if key in payload:
                summary.append((key, str(payload.get(key))))
        if not summary:
            for key in list(payload.keys())[:4]:
                summary.append((str(key), str(payload.get(key))))
        pretty = json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True)
        return {
            "summary_items": summary,
            "pretty_text": pretty,
            "is_mapping": True,
        }

    if isinstance(payload, (list, tuple)):
        pretty = json.dumps(list(payload), indent=2, ensure_ascii=False)
        return {
            "summary_items": [("items", str(len(payload)))],
            "pretty_text": pretty,
            "is_mapping": False,
        }

    text = str(payload)
    return {
        "summary_items": [("value", text[:140])],
        "pretty_text": text,
        "is_mapping": False,
    }


def _creator_label_for_message(user, service: str, message) -> str:
    msg = message
    if msg is None:
        return "Unknown"
    author_raw = str(getattr(msg, "custom_author", "") or "").strip()
    author_key = author_raw.upper()
    sender_identifier = str(getattr(msg, "sender_uuid", "") or "").strip()

    if author_key == "USER":
        return "You"
    if author_key == "BOT":
        return "System Bot"

    if sender_identifier:
        variants = _person_identifier_scope_variants(service, sender_identifier)
        person_identifier = (
            PersonIdentifier.objects.filter(
                user=user,
                service=str(service or "").strip().lower(),
                identifier__in=variants or [sender_identifier],
            )
            .select_related("person")
            .first()
        )
        if person_identifier is not None:
            person_name = str(
                getattr(person_identifier.person, "name", "") or ""
            ).strip()
            if person_name:
                return person_name
        return sender_identifier

    if author_raw:
        if author_key == "OTHER":
            return "Other Participant"
        return author_raw
    return "Unknown"


def _apply_task_creator_labels(user, task_rows):
    rows = list(task_rows or [])
    person_identifier_cache: dict[tuple[str, str], PersonIdentifier | None] = {}

    def _resolve_person_identifier(service_key: str, sender_identifier: str):
        key = (
            str(service_key or "").strip().lower(),
            str(sender_identifier or "").strip(),
        )
        if key in person_identifier_cache:
            return person_identifier_cache[key]
        variants = _person_identifier_scope_variants(key[0], key[1])
        row = (
            PersonIdentifier.objects.filter(
                user=user,
                service=key[0],
                identifier__in=variants or [key[1]],
            )
            .select_related("person")
            .first()
        )
        person_identifier_cache[key] = row
        return row

    for row in rows:
        origin = getattr(row, "origin_message", None)
        service_key = str(getattr(row, "source_service", "") or "").strip().lower()
        sender_identifier = str(getattr(origin, "sender_uuid", "") or "").strip()
        row.creator_label = _creator_label_for_message(user, service_key, origin)
        row.creator_identifier = sender_identifier
        row.creator_compose_href = ""
        if sender_identifier and service_key:
            person_identifier = _resolve_person_identifier(
                service_key, sender_identifier
            )
            compose_service = service_key
            compose_identifier = sender_identifier
            compose_person_id = ""
            if person_identifier is not None:
                compose_identifier = (
                    str(getattr(person_identifier, "identifier", "") or "").strip()
                    or sender_identifier
                )
                compose_person_id = str(
                    getattr(person_identifier, "person_id", "") or ""
                )
            query = {
                "service": compose_service,
                "identifier": compose_identifier,
            }
            if compose_person_id:
                query["person"] = compose_person_id
            row.creator_compose_href = f"{reverse('compose_page')}?{urlencode(query)}"
    return rows


def _provider_row_map(user):
    return {
        str(row.provider or "").strip().lower(): row
        for row in TaskProviderConfig.objects.filter(user=user).order_by("provider")
    }


def _upsert_group_source(
    *, user, service: str, channel_identifier: str, project, epic=None
):
    normalized_service = str(service or "").strip().lower()
    normalized_identifier = normalize_channel_identifier(service, channel_identifier)
    if not normalized_service or not normalized_identifier:
        return None
    source, created = ChatTaskSource.objects.get_or_create(
        user=user,
        service=normalized_service,
        channel_identifier=normalized_identifier,
        project=project,
        defaults={
            "epic": epic,
            "enabled": True,
            "settings": _flags_with_defaults({}),
        },
    )
    if not created:
        next_fields = []
        if source.epic_id != getattr(epic, "id", None):
            source.epic = epic
            next_fields.append("epic")
        if not source.enabled:
            source.enabled = True
            next_fields.append("enabled")
        if next_fields:
            next_fields.append("updated_at")
            source.save(update_fields=next_fields)
    return source


def _notify_epic_created_in_project_chats(
    *, project: TaskProject, epic: TaskEpic
) -> None:
    rows = (
        ChatTaskSource.objects.filter(project=project, enabled=True)
        .order_by("service", "channel_identifier")
        .values_list("service", "channel_identifier")
    )
    seen: set[tuple[str, str]] = set()
    for service, channel_identifier in rows:
        svc = str(service or "").strip().lower()
        chan = str(channel_identifier or "").strip()
        if not svc or not chan:
            continue
        key = (svc, chan)
        if key in seen:
            continue
        seen.add(key)
        try:
            async_to_sync(send_message_raw)(
                svc,
                chan,
                text=(
                    f"[epic] Created '{epic.name}' in project '{project.name}'.\n"
                    "WhatsApp usage:\n"
                    "- create epic: epic: <Epic name> (or .epic <Epic name>)\n"
                    "- add task to epic: task: <description> [epic:<Epic name>]\n"
                    "- list tasks: .l list tasks\n"
                    "- undo latest task: .undo"
                ),
                attachments=[],
                metadata={"origin": "task_epic_announce"},
            )
        except Exception:
            continue


def _reseed_chat_sources_for_deleted_project(
    user, service_channel_rows: list[tuple[str, str]]
) -> int:
    restored = 0
    seen: set[tuple[str, str]] = set()
    for service, channel_identifier in service_channel_rows:
        service_key = str(service or "").strip().lower()
        channel = str(channel_identifier or "").strip()
        if not service_key or not channel:
            continue
        pair = (service_key, channel)
        if pair in seen:
            continue
        seen.add(pair)
        source = ensure_default_source_for_chat(
            user=user,
            service=service_key,
            channel_identifier=channel,
        )
        if source is not None:
            restored += 1
    return restored


def _person_identifier_scope_variants(service: str, identifier: str) -> list[str]:
    service_key = str(service or "").strip().lower()
    raw_identifier = str(identifier or "").strip()
    if not service_key or not raw_identifier:
        return []
    variants: list[str] = [raw_identifier]
    bare_identifier = raw_identifier.split("@", 1)[0].strip()
    if bare_identifier and bare_identifier not in variants:
        variants.append(bare_identifier)
    if service_key == "whatsapp" and bare_identifier:
        group_identifier = f"{bare_identifier}@g.us"
        direct_identifier = f"{bare_identifier}@s.whatsapp.net"
        if group_identifier not in variants:
            variants.append(group_identifier)
        if direct_identifier not in variants:
            variants.append(direct_identifier)
    if service_key == "signal":
        digits = "".join(ch for ch in raw_identifier if ch.isdigit())
        if digits and digits not in variants:
            variants.append(digits)
        if digits:
            plus_variant = f"+{digits}"
            if plus_variant not in variants:
                variants.append(plus_variant)
    return [row for row in variants if row]


def _resolve_channel_display(user, service: str, identifier: str) -> dict:
    service_key = str(service or "").strip().lower()
    raw_identifier = str(identifier or "").strip()
    bare_identifier = raw_identifier.split("@", 1)[0].strip()
    variants = [raw_identifier]
    if bare_identifier and bare_identifier not in variants:
        variants.append(bare_identifier)
    if service_key == "whatsapp":
        direct_identifier = (
            raw_identifier if raw_identifier.endswith("@s.whatsapp.net") else ""
        )
        if direct_identifier and direct_identifier not in variants:
            variants.append(direct_identifier)
        if bare_identifier:
            direct_bare = f"{bare_identifier}@s.whatsapp.net"
            if direct_bare not in variants:
                variants.append(direct_bare)
        group_identifier = f"{bare_identifier}@g.us" if bare_identifier else ""
        if group_identifier and group_identifier not in variants:
            variants.append(group_identifier)
    if service_key == "signal":
        digits = "".join(ch for ch in raw_identifier if ch.isdigit())
        if digits and digits not in variants:
            variants.append(digits)
        if digits:
            plus = f"+{digits}"
            if plus not in variants:
                variants.append(plus)
        if raw_identifier:
            companion_numbers = list(
                Chat.objects.filter(source_uuid=raw_identifier)
                .exclude(source_number__isnull=True)
                .exclude(source_number="")
                .values_list("source_number", flat=True)[:200]
            )
            companion_uuids = list(
                Chat.objects.filter(source_number=raw_identifier)
                .exclude(source_uuid__isnull=True)
                .exclude(source_uuid="")
                .values_list("source_uuid", flat=True)[:200]
            )
            for candidate in companion_numbers + companion_uuids:
                candidate_str = str(candidate or "").strip()
                if not candidate_str:
                    continue
                if candidate_str not in variants:
                    variants.append(candidate_str)
                candidate_digits = "".join(ch for ch in candidate_str if ch.isdigit())
                if candidate_digits and candidate_digits not in variants:
                    variants.append(candidate_digits)
                if candidate_digits:
                    plus_variant = f"+{candidate_digits}"
                    if plus_variant not in variants:
                        variants.append(plus_variant)

    group_link = None
    if bare_identifier:
        group_link = (
            PlatformChatLink.objects.filter(
                user=user,
                service=service_key,
                chat_identifier=bare_identifier,
                is_group=True,
            )
            .order_by("-id")
            .first()
        )

    person_identifier = (
        PersonIdentifier.objects.filter(
            user=user,
            service=service_key,
            identifier__in=variants,
        )
        .select_related("person")
        .order_by("-id")
        .first()
    )

    display_name = ""
    if group_link and str(group_link.chat_name or "").strip():
        display_name = str(group_link.chat_name or "").strip()
    elif person_identifier and person_identifier.person_id:
        display_name = str(person_identifier.person.name or "").strip()
    if not display_name:
        display_name = raw_identifier or bare_identifier or "Unknown chat"

    display_identifier = raw_identifier
    if group_link:
        display_identifier = str(group_link.chat_jid or "").strip() or (
            f"{bare_identifier}@g.us" if bare_identifier else raw_identifier
        )
    return {
        "service_key": service_key,
        "service_label": _service_label(service_key),
        "display_name": display_name,
        "display_identifier": display_identifier or raw_identifier,
        "variants": [row for row in variants if row],
    }


class TasksHub(LoginRequiredMixin, View):
    template_name = "pages/tasks-hub.html"

    def _scope(self, request):
        person_id = str(
            request.GET.get("person") or request.POST.get("person") or ""
        ).strip()
        person = None
        if person_id:
            person = Person.objects.filter(user=request.user, id=person_id).first()
        return {
            "person": person,
            "person_id": str(getattr(person, "id", "") or ""),
            "service": str(
                request.GET.get("service") or request.POST.get("service") or ""
            )
            .strip()
            .lower(),
            "identifier": str(
                request.GET.get("identifier") or request.POST.get("identifier") or ""
            ).strip(),
            "selected_project_id": str(
                request.GET.get("project") or request.POST.get("project_id") or ""
            ).strip(),
        }

    def _context(self, request):
        scope = self._scope(request)
        show_empty = bool(
            str(request.GET.get("show_empty") or "").strip()
            in {"1", "true", "yes", "on"}
        )
        all_projects = (
            TaskProject.objects.filter(user=request.user)
            .annotate(
                task_count=Count("derived_tasks"),
                epic_count=Count("epics", distinct=True),
                source_count=Count("chat_sources", distinct=True),
            )
            .order_by("name")
        )
        projects = all_projects if show_empty else all_projects.filter(task_count__gt=0)
        tasks = (
            DerivedTask.objects.filter(user=request.user)
            .select_related("project", "epic", "origin_message")
            .order_by("-created_at")[:200]
        )
        tasks = _apply_task_creator_labels(request.user, tasks)
        selected_project = None
        if scope["selected_project_id"]:
            selected_project = all_projects.filter(
                id=scope["selected_project_id"]
            ).first()
        person_identifiers = []
        person_identifier_rows = []
        if scope["person"] is not None:
            person_identifiers = list(
                PersonIdentifier.objects.filter(
                    user=request.user,
                    person=scope["person"],
                ).order_by("service", "identifier")
            )
        mapping_pairs = set(
            ChatTaskSource.objects.filter(user=request.user).values_list(
                "project_id", "service", "channel_identifier"
            )
        )
        for row in person_identifiers:
            mapped = False
            if selected_project is not None:
                mapped = (
                    selected_project.id,
                    str(row.service or "").strip(),
                    str(row.identifier or "").strip(),
                ) in mapping_pairs
            person_identifier_rows.append(
                {
                    "id": row.id,
                    "identifier": str(row.identifier or "").strip(),
                    "service": str(row.service or "").strip(),
                    "mapped": mapped,
                }
            )
        return {
            "projects": projects,
            "project_choices": all_projects,
            "epic_choices": TaskEpic.objects.filter(
                project__user=request.user
            ).select_related("project").order_by("project__name", "name"),
            "tasks": tasks,
            "scope": scope,
            "person_identifier_rows": person_identifier_rows,
            "selected_project": selected_project,
            "show_empty_projects": show_empty,
        }

    def get(self, request):
        return render(request, self.template_name, self._context(request))

    def post(self, request):
        action = str(request.POST.get("action") or "").strip().lower()
        if action == "project_create":
            name = str(request.POST.get("name") or "").strip()
            if not name:
                messages.error(request, "Project name is required.")
                return redirect("tasks_hub")
            scope = self._scope(request)
            external_key = str(request.POST.get("external_key") or "").strip()
            try:
                project, created = TaskProject.objects.get_or_create(
                    user=request.user,
                    name=name,
                    defaults={"external_key": external_key},
                )
            except IntegrityError:
                messages.error(
                    request, "Could not create project due to duplicate name."
                )
                return redirect("tasks_hub")
            if created:
                messages.success(request, f"Created project '{project.name}'.")
            else:
                messages.info(request, f"Project '{project.name}' already exists.")
            query = {}
            if scope["person_id"]:
                query["person"] = scope["person_id"]
            if scope["service"]:
                query["service"] = scope["service"]
            if scope["identifier"]:
                query["identifier"] = scope["identifier"]
            query["project"] = str(project.id)
            if query:
                return redirect(f"{reverse('tasks_hub')}?{urlencode(query)}")
            return redirect("tasks_hub")

        if action == "task_create":
            project = get_object_or_404(
                TaskProject,
                user=request.user,
                id=request.POST.get("project_id"),
            )
            epic = None
            epic_id = str(request.POST.get("epic_id") or "").strip()
            if epic_id:
                epic = get_object_or_404(TaskEpic, id=epic_id, project=project)
            title = str(request.POST.get("title") or "").strip()
            if not title:
                messages.error(request, "Task title is required.")
                return redirect("tasks_hub")
            scope = self._scope(request)
            source_service = str(scope.get("service") or "").strip().lower() or "web"
            source_channel = str(scope.get("identifier") or "").strip()
            due_raw = str(request.POST.get("due_date") or "").strip()
            due_date = None
            if due_raw:
                try:
                    due_date = datetime.date.fromisoformat(due_raw)
                except Exception:
                    messages.error(request, "Due date must be YYYY-MM-DD.")
                    return redirect("tasks_hub")
            task, _event = async_to_sync(create_task_record_and_sync)(
                user=request.user,
                project=project,
                epic=epic,
                title=title,
                source_service=source_service,
                source_channel=source_channel,
                actor_identifier=str(request.user.username or request.user.id),
                due_date=due_date,
                assignee_identifier=str(
                    request.POST.get("assignee_identifier") or ""
                ).strip(),
                immutable_payload={
                    "source": "tasks_hub_manual_create",
                    "person_id": scope["person_id"],
                    "service": source_service,
                    "identifier": source_channel,
                },
                event_payload={
                    "source": "tasks_hub_manual_create",
                    "via": "web_ui",
                },
            )
            messages.success(
                request,
                f"Created task #{task.reference_code} in '{project.name}'.",
            )
            return redirect("tasks_task", task_id=str(task.id))

        if action == "project_map_identifier":
            project = get_object_or_404(
                TaskProject,
                user=request.user,
                id=request.POST.get("project_id"),
            )
            identifier_row = get_object_or_404(
                PersonIdentifier,
                user=request.user,
                id=request.POST.get("person_identifier_id"),
            )
            _upsert_group_source(
                user=request.user,
                service=identifier_row.service,
                channel_identifier=identifier_row.identifier,
                project=project,
                epic=None,
            )
            messages.success(
                request,
                f"Mapped {identifier_row.service} · {identifier_row.identifier} to '{project.name}'.",
            )
            scope = self._scope(request)
            query = {
                "project": str(project.id),
            }
            if scope["person_id"]:
                query["person"] = scope["person_id"]
            if scope["service"]:
                query["service"] = scope["service"]
            if scope["identifier"]:
                query["identifier"] = scope["identifier"]
            return redirect(f"{reverse('tasks_hub')}?{urlencode(query)}")

        if action == "project_delete":
            project = get_object_or_404(
                TaskProject,
                id=request.POST.get("project_id"),
                user=request.user,
            )
            confirm_name = str(request.POST.get("confirm_name") or "").strip()
            expected = str(project.name or "").strip()
            if confirm_name != expected:
                messages.error(
                    request,
                    f"Delete cancelled. Type the project name exactly to confirm deletion: {expected}",
                )
                return redirect("tasks_hub")
            mapped_channels = list(
                project.chat_sources.values_list("service", "channel_identifier")
            )
            deleted_name = str(project.name or "").strip() or "Project"
            project.delete()
            restored = _reseed_chat_sources_for_deleted_project(
                request.user, mapped_channels
            )
            if restored > 0:
                messages.success(
                    request,
                    f"Deleted project '{deleted_name}'. Restored {restored} chat mapping(s) with default projects.",
                )
            else:
                messages.success(request, f"Deleted project '{deleted_name}'.")
            return redirect("tasks_hub")

        return redirect("tasks_hub")


class TaskProjectDetail(LoginRequiredMixin, View):
    template_name = "pages/tasks-project.html"

    def _context(self, request, project):
        tasks = (
            DerivedTask.objects.filter(user=request.user, project=project)
            .select_related("epic", "origin_message")
            .order_by("-created_at")
        )
        tasks = _apply_task_creator_labels(request.user, tasks)
        epics = (
            TaskEpic.objects.filter(project=project)
            .annotate(task_count=Count("derived_tasks"))
            .order_by("name")
        )
        return {
            "project": project,
            "tasks": tasks,
            "epics": epics,
        }

    def get(self, request, project_id):
        project = get_object_or_404(TaskProject, id=project_id, user=request.user)
        return render(request, self.template_name, self._context(request, project))

    def post(self, request, project_id):
        project = get_object_or_404(TaskProject, id=project_id, user=request.user)
        action = str(request.POST.get("action") or "").strip().lower()

        if action == "epic_create":
            name = str(request.POST.get("name") or "").strip()
            if not name:
                messages.error(request, "Epic name is required.")
                return redirect("tasks_project", project_id=str(project.id))
            external_key = str(request.POST.get("external_key") or "").strip()
            try:
                epic, created = TaskEpic.objects.get_or_create(
                    project=project,
                    name=name,
                    defaults={"external_key": external_key},
                )
            except IntegrityError:
                messages.error(request, "Could not create epic due to duplicate name.")
                return redirect("tasks_project", project_id=str(project.id))
            if created:
                messages.success(request, f"Created epic '{epic.name}'.")
                _notify_epic_created_in_project_chats(project=project, epic=epic)
            else:
                messages.info(request, f"Epic '{epic.name}' already exists.")
            return redirect("tasks_project", project_id=str(project.id))

        if action == "epic_delete":
            epic = get_object_or_404(
                TaskEpic, id=request.POST.get("epic_id"), project=project
            )
            deleted_name = str(epic.name or "").strip() or "Epic"
            epic.delete()
            messages.success(request, f"Deleted epic '{deleted_name}'.")
            return redirect("tasks_project", project_id=str(project.id))

        if action == "task_set_epic":
            task = get_object_or_404(
                DerivedTask,
                id=request.POST.get("task_id"),
                user=request.user,
                project=project,
            )
            epic_id = str(request.POST.get("epic_id") or "").strip()
            epic = None
            if epic_id:
                epic = get_object_or_404(TaskEpic, id=epic_id, project=project)
            task.epic = epic
            task.save(update_fields=["epic"])
            if epic is None:
                messages.success(
                    request, f"Cleared epic for task #{task.reference_code}."
                )
            else:
                messages.success(
                    request,
                    f"Assigned task #{task.reference_code} to epic '{epic.name}'.",
                )
            return redirect("tasks_project", project_id=str(project.id))

        if action == "project_delete":
            confirm_name = str(request.POST.get("confirm_name") or "").strip()
            expected = str(project.name or "").strip()
            if confirm_name != expected:
                messages.error(
                    request,
                    f"Delete cancelled. Type the project name exactly to confirm deletion: {expected}",
                )
                return redirect("tasks_project", project_id=str(project.id))
            mapped_channels = list(
                project.chat_sources.values_list("service", "channel_identifier")
            )
            deleted_name = str(project.name or "").strip() or "Project"
            project.delete()
            restored = _reseed_chat_sources_for_deleted_project(
                request.user, mapped_channels
            )
            if restored > 0:
                messages.success(
                    request,
                    f"Deleted project '{deleted_name}'. Restored {restored} chat mapping(s) with default projects.",
                )
            else:
                messages.success(request, f"Deleted project '{deleted_name}'.")
            return redirect("tasks_hub")

        return redirect("tasks_project", project_id=str(project.id))


class TaskEpicDetail(LoginRequiredMixin, View):
    template_name = "pages/tasks-epic.html"

    def get(self, request, epic_id):
        epic = get_object_or_404(TaskEpic, id=epic_id, project__user=request.user)
        tasks = (
            DerivedTask.objects.filter(user=request.user, epic=epic)
            .select_related("project", "origin_message")
            .order_by("-created_at")
        )
        tasks = _apply_task_creator_labels(request.user, tasks)
        return render(request, self.template_name, {"epic": epic, "tasks": tasks})


class TaskGroupDetail(LoginRequiredMixin, View):
    template_name = "pages/tasks-group.html"

    def get(self, request, service, identifier):
        channel = _resolve_channel_display(request.user, service, identifier)
        variants = list(channel.get("variants") or [str(identifier or "").strip()])
        service_keys = [channel["service_key"]]
        if channel["service_key"] != "web":
            service_keys.append("web")
        mappings = ChatTaskSource.objects.filter(
            user=request.user,
            service__in=service_keys,
            channel_identifier__in=variants,
        ).select_related("project", "epic")
        mappings = list(mappings)
        if not mappings:
            seeded = ensure_default_source_for_chat(
                user=request.user,
                service=channel["service_key"],
                channel_identifier=channel["display_identifier"],
            )
            if seeded is not None:
                mappings = list(
                    ChatTaskSource.objects.filter(id=seeded.id).select_related(
                        "project", "epic"
                    )
                )
        for row in mappings:
            row_channel = _resolve_channel_display(
                request.user,
                str(getattr(row, "service", "") or ""),
                str(getattr(row, "channel_identifier", "") or ""),
            )
            row.display_service_label = row_channel.get(
                "service_label"
            ) or _service_label(str(getattr(row, "service", "") or ""))
            row.display_channel_name = (
                str(row_channel.get("display_name") or "").strip()
                or str(channel.get("display_name") or "").strip()
                or "Unknown chat"
            )
        tasks = (
            DerivedTask.objects.filter(
                user=request.user,
                source_service__in=service_keys,
                source_channel__in=variants,
            )
            .select_related("project", "epic", "origin_message")
            .order_by("-created_at")
        )
        tasks = _apply_task_creator_labels(request.user, tasks)
        return render(
            request,
            self.template_name,
            {
                "service": channel["service_key"],
                "service_label": channel["service_label"],
                "identifier": channel["display_identifier"],
                "channel_display_name": channel["display_name"],
                "projects": TaskProject.objects.filter(user=request.user).order_by(
                    "name"
                ),
                "mappings": mappings,
                "primary_project": mappings[0].project if mappings else None,
                "tasks": tasks,
            },
        )

    def post(self, request, service, identifier):
        channel = _resolve_channel_display(request.user, service, identifier)
        action = str(request.POST.get("action") or "").strip().lower()
        if action == "group_project_create":
            project_name = str(request.POST.get("project_name") or "").strip()
            if not project_name:
                messages.error(request, "Project name is required.")
                return redirect(
                    "tasks_group",
                    service=channel["service_key"],
                    identifier=channel["display_identifier"],
                )
            epic_name = str(request.POST.get("epic_name") or "").strip()
            project, _ = TaskProject.objects.get_or_create(
                user=request.user,
                name=project_name,
            )
            epic = None
            if epic_name:
                epic, _ = TaskEpic.objects.get_or_create(
                    project=project, name=epic_name
                )
            _upsert_group_source(
                user=request.user,
                service=channel["service_key"],
                channel_identifier=channel["display_identifier"],
                project=project,
                epic=epic,
            )
            messages.success(
                request,
                f"Project '{project.name}' mapped to this group.",
            )
        elif action == "group_map_existing_project":
            project = get_object_or_404(
                TaskProject,
                user=request.user,
                id=request.POST.get("project_id"),
            )
            epic = None
            epic_id = str(request.POST.get("epic_id") or "").strip()
            if epic_id:
                epic = get_object_or_404(TaskEpic, project=project, id=epic_id)
            _upsert_group_source(
                user=request.user,
                service=channel["service_key"],
                channel_identifier=channel["display_identifier"],
                project=project,
                epic=epic,
            )
            messages.success(request, f"Mapped '{project.name}' to this group.")
        elif action == "group_project_rename":
            current = (
                ChatTaskSource.objects.filter(
                    user=request.user,
                    service=channel["service_key"],
                    channel_identifier=channel["display_identifier"],
                    enabled=True,
                )
                .select_related("project")
                .order_by("-updated_at")
                .first()
            )
            if current is None:
                current = ensure_default_source_for_chat(
                    user=request.user,
                    service=channel["service_key"],
                    channel_identifier=channel["display_identifier"],
                )
            new_name = str(request.POST.get("project_name") or "").strip()
            if current is None or current.project is None:
                messages.error(request, "No mapped project found for this chat.")
            elif not new_name:
                messages.error(request, "Project name is required.")
            elif (
                TaskProject.objects.filter(user=request.user, name=new_name)
                .exclude(id=current.project_id)
                .exists()
            ):
                messages.error(request, f"Project '{new_name}' already exists.")
            else:
                current.project.name = new_name
                current.project.save(update_fields=["name", "updated_at"])
                messages.success(request, f"Renamed project to '{new_name}'.")
        return redirect(
            "tasks_group",
            service=channel["service_key"],
            identifier=channel["display_identifier"],
        )


class TaskDetail(LoginRequiredMixin, View):
    template_name = "pages/tasks-detail.html"

    def get(self, request, task_id):
        task = get_object_or_404(
            DerivedTask.objects.select_related("project", "epic", "origin_message"),
            id=task_id,
            user=request.user,
        )
        events = task.events.select_related("source_message").order_by("-created_at")
        for row in events:
            service_hint = (
                str(getattr(task, "source_service", "") or "").strip().lower()
            )
            event_source_message = getattr(row, "source_message", None)
            row.actor_display = _creator_label_for_message(
                request.user,
                service_hint,
                event_source_message,
            )
            if row.actor_display == "Unknown":
                raw_actor = str(getattr(row, "actor_identifier", "") or "").strip()
                if raw_actor:
                    row.actor_display = raw_actor
            row.payload_view = _format_task_event_payload(getattr(row, "payload", None))
        task.creator_label = _creator_label_for_message(
            request.user,
            str(getattr(task, "source_service", "") or "").strip().lower(),
            getattr(task, "origin_message", None),
        )
        return render(
            request,
            self.template_name,
            {
                "task": task,
                "events": events,
            },
        )


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

    def _context(self, request):
        _apply_safe_defaults_for_user(request.user)
        _ensure_default_completion_patterns(request.user)
        prefill_service = str(request.GET.get("service") or "").strip().lower()
        prefill_identifier = str(request.GET.get("identifier") or "").strip()
        projects = list(TaskProject.objects.filter(user=request.user).order_by("name"))
        for row in projects:
            row.settings_effective = _flags_with_defaults(row.settings)
            row.allowed_prefixes_csv = ",".join(
                row.settings_effective["allowed_prefixes"]
            )
        sources = list(
            ChatTaskSource.objects.filter(user=request.user)
            .select_related("project", "epic")
            .order_by("service", "channel_identifier")
        )
        for row in sources:
            row.settings_effective = _flags_with_defaults(row.settings)
            row.allowed_prefixes_csv = ",".join(
                row.settings_effective["allowed_prefixes"]
            )
        provider_map = _provider_row_map(request.user)
        mock_cfg = provider_map.get("mock")

        return {
            "projects": projects,
            "epics": TaskEpic.objects.filter(project__user=request.user)
            .select_related("project")
            .order_by("project__name", "name"),
            "sources": sources,
            "patterns": TaskCompletionPattern.objects.filter(
                user=request.user
            ).order_by("position", "created_at"),
            "mock_provider_config": mock_cfg,
            "prefill_service": prefill_service,
            "prefill_identifier": prefill_identifier,
        }

    def get(self, request):
        return render(request, self.template_name, self._context(request))

    def post(self, request):
        action = str(request.POST.get("action") or "").strip()

        if action == "project_create":
            TaskProject.objects.create(
                user=request.user,
                name=str(request.POST.get("name") or "Project").strip() or "Project",
                external_key=str(request.POST.get("external_key") or "").strip(),
                active=bool(request.POST.get("active") or "1"),
                settings=_flags_from_post(request),
            )
            return _settings_redirect(request)

        if action == "epic_create":
            project = get_object_or_404(
                TaskProject, id=request.POST.get("project_id"), user=request.user
            )
            epic = TaskEpic.objects.create(
                project=project,
                name=str(request.POST.get("name") or "Epic").strip() or "Epic",
                external_key=str(request.POST.get("external_key") or "").strip(),
                active=bool(request.POST.get("active") or "1"),
            )
            _notify_epic_created_in_project_chats(project=project, epic=epic)
            return _settings_redirect(request)

        if action == "source_create":
            project = get_object_or_404(
                TaskProject, id=request.POST.get("project_id"), user=request.user
            )
            epic = None
            epic_id = str(request.POST.get("epic_id") or "").strip()
            if epic_id:
                epic = get_object_or_404(
                    TaskEpic, id=epic_id, project__user=request.user
                )
            source, _ = _upsert_task_source(
                user=request.user,
                service=str(request.POST.get("service") or "web").strip(),
                channel_identifier=str(
                    request.POST.get("channel_identifier") or ""
                ).strip(),
                project=project,
                epic=epic,
                enabled=bool(request.POST.get("enabled") or "1"),
                settings=_flags_from_post(request, prefix="source_"),
            )
            if source is None:
                messages.error(request, "Invalid channel identifier.")
            return _settings_redirect(request)

        if action == "quick_setup":
            service = str(request.POST.get("service") or "web").strip().lower() or "web"
            channel_identifier = str(
                request.POST.get("channel_identifier") or ""
            ).strip()
            project_name = (
                str(request.POST.get("project_name") or "").strip() or "General"
            )
            epic_name = str(request.POST.get("epic_name") or "").strip()
            project, _ = TaskProject.objects.get_or_create(
                user=request.user,
                name=project_name,
                defaults={"settings": _flags_from_post(request)},
            )
            if not project.settings:
                project.settings = _flags_from_post(request)
                project.save(update_fields=["settings", "updated_at"])
            epic = None
            if epic_name:
                epic, _ = TaskEpic.objects.get_or_create(
                    project=project, name=epic_name
                )
            if channel_identifier:
                source, created = _upsert_task_source(
                    user=request.user,
                    service=service,
                    channel_identifier=channel_identifier,
                    project=project,
                    epic=epic,
                    enabled=True,
                    settings=_flags_from_post(request, prefix="source_"),
                )
                if source is None:
                    messages.error(request, "Invalid channel identifier.")
            return _settings_redirect(request)

        if action == "project_flags_update":
            project = get_object_or_404(
                TaskProject, id=request.POST.get("project_id"), user=request.user
            )
            project.settings = _flags_from_post(request)
            project.save(update_fields=["settings", "updated_at"])
            return _settings_redirect(request)

        if action == "source_flags_update":
            source = get_object_or_404(
                ChatTaskSource, id=request.POST.get("source_id"), user=request.user
            )
            source.settings = _flags_from_post(request, prefix="source_")
            source.save(update_fields=["settings", "updated_at"])
            return _settings_redirect(request)

        if action == "source_delete":
            source = get_object_or_404(
                ChatTaskSource,
                id=request.POST.get("source_id"),
                user=request.user,
            )
            source.delete()
            return _settings_redirect(request)

        if action == "pattern_create":
            phrase = str(request.POST.get("phrase") or "").strip()
            if phrase:
                TaskCompletionPattern.objects.get_or_create(
                    user=request.user,
                    phrase=phrase,
                    defaults={
                        "enabled": True,
                        "position": TaskCompletionPattern.objects.filter(
                            user=request.user
                        ).count(),
                    },
                )
            return _settings_redirect(request)

        if action == "provider_update":
            provider = str(request.POST.get("provider") or "mock").strip() or "mock"
            if provider != "mock":
                messages.error(request, "Only the mock task provider is available.")
                return _settings_redirect(request)
            row, _ = TaskProviderConfig.objects.get_or_create(
                user=request.user,
                provider=provider,
                defaults={"enabled": False, "settings": {}},
            )
            row.enabled = bool(request.POST.get("enabled"))
            row.settings = dict(row.settings or {})
            row.save(update_fields=["enabled", "settings", "updated_at"])
            return _settings_redirect(request)

        return _settings_redirect(request)


class AnswerSuggestionSend(LoginRequiredMixin, View):
    def post(self, request):
        event = get_object_or_404(
            AnswerSuggestionEvent.objects.select_related("candidate_answer", "message"),
            id=request.POST.get("suggestion_id"),
            user=request.user,
            status="suggested",
        )
        decision = str(request.POST.get("decision") or "accept").strip().lower()
        if decision == "dismiss":
            event.status = "dismissed"
            event.save(update_fields=["status", "updated_at"])
            return JsonResponse({"ok": True, "status": "dismissed"})

        text = str(getattr(event.candidate_answer, "answer_text", "") or "").strip()
        msg = event.message
        if not text:
            return JsonResponse(
                {"ok": False, "error": "empty_candidate_answer"}, status=400
            )
        ok = async_to_sync(send_message_raw)(
            msg.source_service or "web",
            msg.source_chat_id or "",
            text=text,
            attachments=[],
            metadata={"origin": "repeat_answer_suggestion"},
        )
        event.status = "accepted" if ok else "suggested"
        event.save(update_fields=["status", "updated_at"])
        if not ok:
            messages.error(request, "Failed to send suggestion message.")
            return JsonResponse({"ok": False, "error": "send_failed"}, status=502)
        return JsonResponse({"ok": True, "status": "accepted"})
