"""Activity awareness middleware — logs every successful mutating user action.

Implements Architectural Requirement #13 (AGENT AWARENESS OF MANUAL USER ACTIONS).

Anything a user can do by hand through the REST API (which the UI and CLI both
call) must emit a structured event through ActivityLogger so the agent's world
model stays complete. This middleware catches every successful write request
(POST/PUT/PATCH/DELETE with 2xx response) and records it.

Categories:
    user            — generic manual action (creates, edits, settings changes)
    user_override   — user corrected or reversed an agent decision (rejections,
                      unapprovals, contract edits, emergency stops)

Exempt paths: health checks, polling endpoints, webhooks, and anything already
logged by a dedicated service hook (chat, journal writes, memory generation).
"""

from __future__ import annotations

import logging

from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware

log = logging.getLogger(__name__)


# Path-prefix classification: (method, prefix) → (category, action, summary, importance)
# Longest prefix match wins. First match used.
_CLASSIFICATION: list[tuple[str, str, str, str, str, str]] = [
    # ── Critical identity / safety — always high importance, user_override ──
    ("PUT",    "/api/contract",                        "user_override", "contract_edited",   "User edited CONTRACT.md",            "high"),
    ("PUT",    "/api/vault/file/system/identity/SOUL", "user_override", "soul_edited",       "User edited SOUL.md",                "high"),
    ("PUT",    "/api/vault/file/system/identity/USER", "user_override", "user_profile_edited","User edited USER.md",                "high"),
    ("POST",   "/api/emergency",                       "user_override", "emergency_stop",    "User triggered emergency stop",      "high"),
    ("PUT",    "/api/relationship",                    "user_override", "relationship_edited","User edited RELATIONSHIP.md",       "high"),
    ("POST",   "/api/relationship/insight",            "user_override", "relationship_insight","User added/removed relationship insight","medium"),
    ("POST",   "/api/relationship/pending/approve",   "user_override", "relationship_approved","User approved weekly relationship evolution","high"),
    ("POST",   "/api/relationship/pending/reject",    "user_override", "relationship_rejected","User rejected weekly relationship evolution","medium"),

    # ── Memory / reflection overrides ──
    ("POST",   "/api/memory/reject",                   "user_override", "memory_rejected",   "User rejected a memory entry",       "high"),
    ("POST",   "/api/memory/unapprove",                "user_override", "memory_unapproved", "User unapproved a memory entry",     "high"),
    ("POST",   "/api/memory/approve",                  "user",          "memory_approved",   "User approved a memory entry",       "medium"),
    ("POST",   "/api/memory/correction",               "user_override", "memory_corrected",  "User corrected a memory fact",       "high"),
    ("POST",   "/api/memory/generate",                 "user",          "memory_generated",  "User triggered memory generation",   "medium"),
    ("POST",   "/api/memory/compact",                  "user",          "memory_compacted",  "User triggered memory compaction",   "medium"),
    ("POST",   "/api/memory/pending/batch-approve",     "user",          "memory_batch_approved","User batch approved pending memory",  "medium"),
    ("POST",   "/api/memory/dream-review/batch-confirm","user",          "dream_batch_confirmed","User batch confirmed dream beliefs", "medium"),
    ("POST",   "/api/memory/dream-review/",            "user",          "dream_review",      "User reviewed a dream-extracted belief","medium"),
    ("POST",   "/api/memory/counterfactual/",          "user",          "hypothesis_restored","User restored a rejected hypothesis","medium"),
    ("POST",   "/api/memory/dream",                    "user",          "dream_phase_run",   "User triggered a dream phase",       "medium"),
    ("POST",   "/api/reflection/reject",               "user_override", "reflection_rejected","User rejected a reflection",         "high"),
    ("POST",   "/api/reflection/unapprove",            "user_override", "reflection_unapproved","User unapproved a reflection",     "high"),
    ("POST",   "/api/reflections/batch-approve",        "user",          "reflection_batch_approved","User batch approved reflections","medium"),
    ("POST",   "/api/reflections",                     "user",          "reflection_created","User requested a reflection",        "medium"),

    # ── Action plan / heal proposals ──
    ("POST",   "/api/orchestrator/plan/approve",       "user",          "plan_approved",     "User approved action plan items",    "medium"),
    ("POST",   "/api/orchestrator/plan/reject",        "user_override", "plan_rejected",     "User rejected action plan items",    "high"),
    ("POST",   "/api/orchestrator/proposals/approve",  "user",          "heal_approved",     "User approved a heal proposal",      "medium"),
    ("POST",   "/api/orchestrator/proposals/reject",   "user_override", "heal_rejected",     "User rejected a heal proposal",      "medium"),

    # ── Guardian / security ──
    ("POST",   "/api/guardian",                        "user_override", "guardian_updated",  "User edited Guardian blacklist",     "high"),
    ("DELETE", "/api/guardian",                        "user_override", "guardian_cleared",  "User removed a Guardian entry",      "medium"),

    # ── Pairing / security (user approves/rejects unknown senders) ──
    ("POST",   "/api/security/pairing/approve",        "user",          "pairing_approved",  "User approved a pairing request",    "medium"),
    ("POST",   "/api/security/pairing/reject",         "user_override", "pairing_rejected",  "User rejected a pairing request",    "medium"),
    ("POST",   "/api/security/allowlist",              "user",          "allowlist_added",   "User added to allowlist",            "medium"),
    ("DELETE", "/api/security/allowlist",              "user",          "allowlist_removed", "User removed from allowlist",        "medium"),
    ("PUT",    "/api/security/dm-policy",              "user",          "dm_policy_changed", "User changed DM policy",             "medium"),

    # ── Wiki CRUD (user curation) ──
    # Sub-action POSTs like `/rename`, `/verify`, `/reject`, `/consolidate`,
    # `/repair` are resolved by the suffix-override branch in `_classify()`,
    # so the base `/api/wiki/article` only needs the "create" entry.
    ("POST",   "/api/wiki/article",                    "user",          "wiki_created",      "User created a wiki article",        "medium"),
    ("PUT",    "/api/wiki/article",                    "user",          "wiki_edited",       "User edited a wiki article",         "medium"),
    ("DELETE", "/api/wiki/article",                    "user",          "wiki_deleted",      "User deleted a wiki article",        "medium"),
    ("DELETE", "/api/wiki/reset",                      "user_override", "wiki_reset",        "User reset a wiki",                  "high"),

    # ── Capabilities — optional-feature install surface ──
    # Session 102 item #3: the user clicked "Install model" in
    # Settings → Capabilities, which pulls an embedding model via
    # Ollama. The route also emits a service-level ActivityLogger
    # event from inside the stream (see capabilities.py) — this
    # middleware entry covers the HTTP-boundary ping so the user
    # action shows up in the decisions timeline even if the
    # streaming log path racer'd the response.
    ("POST",   "/api/capabilities/semantic_search/install", "user", "embedding_model_install", "User triggered embedding model install", "medium"),

    # ── Knowledge bases (structural user actions) ──
    # Longest-prefix-match wins, so this specific route beats /api/kb above
    # (would otherwise classify as `kb_created`). Phase 4.75 — user clicked
    # "re-ingest with {tier}" on a Wiki source viewer.
    ("POST",   "/api/kb/extraction/upgrade",           "user",          "kb_extraction_reextracted","User re-extracted a source with a higher tier", "medium"),
    ("POST",   "/api/kb",                              "user",          "kb_created",        "User created a knowledge base",      "medium"),
    ("PUT",    "/api/kb",                              "user",          "kb_renamed",        "User renamed a knowledge base",      "medium"),
    ("DELETE", "/api/kb",                              "user_override", "kb_deleted",        "User deleted a knowledge base",      "high"),
    # Ingest / compile / reflect are user-triggered pipeline runs. They
    # show up as knowledge-category events on the backend side too via
    # KnowledgeBaseService.ingest(), but the middleware entry records
    # that a USER specifically asked for it vs. watcher auto-ingest.
    ("POST",   "/api/kb/inbox/ingest",                 "user",          "kb_inbox_ingested", "User triggered inbox ingest",        "medium"),
    ("POST",   "/api/kb/job/all",                      "user",          "kb_wiki_job_all",   "User ran wiki job across all KBs",   "medium"),

    # ── Projects / tasks / goals ──
    ("POST",   "/api/projects",                        "user",          "project_created",   "User created a project",             "medium"),
    ("PUT",    "/api/projects",                        "user",          "project_updated",   "User updated a project",             "medium"),
    ("PATCH",  "/api/projects",                        "user",          "project_patched",   "User patched a project",             "medium"),
    ("DELETE", "/api/projects",                        "user",          "project_deleted",   "User deleted a project",             "medium"),
    ("POST",   "/api/tasks",                           "user",          "task_created",      "User created a task",                "medium"),
    ("PATCH",  "/api/tasks",                           "user",          "task_updated",      "User updated a task",                "low"),
    ("PUT",    "/api/tasks",                           "user",          "task_reassigned",   "User reassigned a task",             "medium"),
    ("DELETE", "/api/tasks",                           "user",          "task_deleted",      "User deleted a task",                "medium"),
    ("POST",   "/api/goals",                           "user",          "goal_created",      "User created a goal",                "medium"),
    ("PATCH",  "/api/goals",                           "user",          "goal_updated",      "User updated a goal",                "medium"),
    ("DELETE", "/api/goals",                           "user",          "goal_detached",     "User detached a goal from project",  "medium"),

    # ── Journal ──
    ("POST",   "/api/journal",                         "user",          "journal_written",   "User wrote a journal entry",         "medium"),

    # ── Settings / integrations ──
    ("PUT",    "/api/settings/llm",                    "user",          "llm_settings",      "User changed LLM settings",          "medium"),
    ("PUT",    "/api/settings/locale",                 "user",          "locale_changed",    "User changed locale",                "low"),
    ("PUT",    "/api/settings",                        "user",          "settings_updated",  "User updated settings",              "medium"),
    ("POST",   "/api/settings/credentials",            "user",          "credentials_set",   "User set credentials",               "medium"),
    ("DELETE", "/api/settings/credentials",            "user",          "credentials_removed","User removed credentials",          "medium"),
    ("POST",   "/api/settings/ollama/pull",            "user",          "ollama_pull",       "User pulled an Ollama model",        "medium"),
    ("DELETE", "/api/settings/ollama",                 "user",          "ollama_deleted",    "User deleted an Ollama model",       "medium"),
    ("POST",   "/api/integrations",                    "user",          "integration_added", "User added an integration",          "medium"),

    # ── Channels (connect/disconnect is user-initiated) ──
    ("POST",   "/api/channels/whatsapp/connect",       "user",          "whatsapp_connect",  "User connected WhatsApp",            "medium"),
    ("POST",   "/api/channels/whatsapp/disconnect",    "user",          "whatsapp_disconnect","User disconnected WhatsApp",        "medium"),
    ("POST",   "/api/channels/whatsapp/mode",          "user",          "whatsapp_mode",     "User changed WhatsApp mode",         "medium"),
    ("POST",   "/api/channels/telegram/connect",       "user",          "telegram_connect",  "User connected Telegram",            "medium"),
    ("POST",   "/api/channels/telegram/disconnect",    "user",          "telegram_disconnect","User disconnected Telegram",        "medium"),
    ("POST",   "/api/channels/discord/connect",        "user",          "discord_connect",   "User connected Discord",             "medium"),
    ("POST",   "/api/channels/discord/disconnect",     "user",          "discord_disconnect","User disconnected Discord",          "medium"),
    ("POST",   "/api/channels/slack/connect",          "user",          "slack_connect",     "User connected Slack",               "medium"),
    ("POST",   "/api/channels/slack/disconnect",       "user",          "slack_disconnect",  "User disconnected Slack",            "medium"),
    ("POST",   "/api/channels",                        "user",          "channel_connect",   "User connected a channel",           "medium"),

    # ── Email ──
    ("POST",   "/api/email/connect",                   "user",          "email_connect",     "User connected email",               "medium"),
    ("POST",   "/api/email/disconnect",                "user",          "email_disconnect",  "User disconnected email",            "medium"),

    # ── Orchestrator / subagents ──
    ("POST",   "/api/orchestrator/agents/sync/run",    "user",          "agent_sync_run",    "User ran agent sync pass",           "medium"),
    ("POST",   "/api/orchestrator/agents/sync/accept", "user",          "agent_sync_accepted","User accepted a shipped agent upgrade","medium"),
    ("POST",   "/api/orchestrator/agents/sync/keep-mine","user_override","agent_sync_kept_mine","User kept vault edit over shipped agent upgrade","medium"),
    ("POST",   "/api/orchestrator/agents",             "user",          "agent_created",     "User created a subagent",            "medium"),
    ("PUT",    "/api/orchestrator/agents",             "user",          "agent_edited",      "User edited a subagent",             "medium"),
    ("DELETE", "/api/orchestrator/agents",             "user",          "agent_deleted",     "User deleted a subagent",            "medium"),

    # ── Skills ──
    ("POST",   "/api/skills/create",                   "user",          "skill_created",     "User created a skill",               "medium"),
    ("POST",   "/api/skills/import",                   "user",          "skill_imported",    "User imported a skill",              "medium"),
    ("POST",   "/api/skills/sync/run",                 "user",          "skill_sync_run",    "User ran skill sync pass",           "medium"),
    ("POST",   "/api/skills/sync/accept",              "user",          "skill_sync_accepted","User accepted a shipped skill upgrade","medium"),
    ("POST",   "/api/skills/sync/keep-mine",           "user_override", "skill_sync_kept_mine","User kept vault edit over shipped upgrade","medium"),
    ("PUT",    "/api/skills",                          "user",          "skill_edited",      "User edited a skill",                "medium"),
    ("DELETE", "/api/skills",                          "user_override", "skill_deleted",     "User deleted a skill",               "medium"),

    # ── MCP ──
    ("POST",   "/api/mcp/servers/add",                 "user",          "mcp_added",         "User added an MCP server",           "medium"),
    ("POST",   "/api/mcp/servers",                     "user",          "mcp_toggled",       "User connected/disconnected an MCP server","medium"),
    ("DELETE", "/api/mcp/servers",                     "user",          "mcp_removed",       "User removed an MCP server",         "medium"),
    # Minting a token creates a new credential that can reach every tool
    # in the contract from anywhere — security-relevant (high).
    ("POST",   "/api/mcp/tokens",                      "user",          "mcp_token_minted",  "User minted an MCP bearer token",    "high"),
    # Revocation shuts off a live credential — user_override (they are
    # overriding a prior decision to trust this token), high importance.
    ("DELETE", "/api/mcp/tokens",                      "user_override", "mcp_token_revoked", "User revoked an MCP bearer token",   "high"),

    # ── Graph / memory health ──
    ("POST",   "/api/graph/build",                     "user",          "graph_rebuilt",     "User forced a full graph rebuild",   "medium"),
    ("POST",   "/api/graph/refresh",                   "user",          "graph_refreshed",   "User refreshed the knowledge graph", "low"),
    ("POST",   "/api/graph/triples",                   "user",          "triple_edited",     "User edited a triple",               "medium"),
    ("POST",   "/api/graph/decay",                     "user",          "graph_decay_run",   "User ran graph decay",               "low"),
    ("POST",   "/api/memory/health/repair",            "user",          "memory_repaired",   "User ran memory auto-repair",        "medium"),

    # ── Sync / vault location ──
    ("POST",   "/api/sync",                            "user",          "sync_configured",   "User changed vault sync",            "high"),

    # ── Plugins ──
    ("POST",   "/api/plugins",                         "user",          "plugin_toggled",    "User enabled/disabled a plugin",     "medium"),

    # ── Budget ──
    ("PUT",    "/api/costs/budget",                    "user",          "budget_changed",    "User updated cost budget",           "low"),

    # ── Search ──
    ("POST",   "/api/search/reindex",                  "user",          "search_reindexed",  "User triggered search reindex",      "low"),

    # ── Domains ──
    ("PUT",    "/api/domains",                         "user",          "domain_edited",     "User edited a domain module",        "medium"),

    # ── Canvas ──
    ("POST",   "/api/canvas",                          "user",          "canvas_action",     "User took a canvas action",          "low"),
    ("DELETE", "/api/canvas",                          "user",          "canvas_cleared",    "User cleared canvas items",          "low"),

    # ── Video generation ──
    ("POST",   "/api/video",                           "user",          "video_render_queued","User queued a video render",        "medium"),
    ("DELETE", "/api/video/jobs",                      "user_override", "video_render_cancelled","User cancelled a video render",   "medium"),

    # ── Finance subsystem (Phase 1) ──
    # Approve / reject are the user's decisions on agent-drafted transactions.
    # Update / delete on a verified row is correction of a prior approval and
    # tagged user_override accordingly. Ingest + upload are user-triggered
    # extraction runs; merchant upserts are routine bookkeeping.
    ("POST",   "/api/finance/transactions/{txn_id}/approve","user",       "transaction_approved","User approved a draft transaction", "medium"),
    ("POST",   "/api/finance/transactions/{txn_id}/reject", "user_override","transaction_rejected","User rejected a draft transaction","medium"),
    ("PUT",    "/api/finance/transactions",                 "user_override","transaction_updated","User updated a transaction",       "medium"),
    ("DELETE", "/api/finance/transactions",                 "user_override","transaction_deleted","User archived a transaction",      "medium"),
    ("POST",   "/api/finance/ingest/upload",                "user",       "receipt_uploaded",  "User uploaded a receipt",            "medium"),
    ("POST",   "/api/finance/ingest",                       "user",       "receipt_ingested",  "User triggered receipt ingest",      "medium"),
    ("PUT",    "/api/finance/merchants",                    "user",       "merchant_upserted", "User updated a merchant",            "low"),

    # ── Cron / autopilot / daily ──
    ("POST",   "/api/cron",                            "user",          "cron_added",        "User added a cron job",              "medium"),
    ("DELETE", "/api/cron",                            "user",          "cron_removed",      "User removed a cron job",            "medium"),
    ("POST",   "/api/autopilot",                       "user",          "autopilot_ran",     "User triggered autopilot",           "medium"),
    ("POST",   "/api/daily",                           "user",          "daily_cycle_ran",   "User triggered daily cycle",         "medium"),

    # ── Work queue ──
    ("POST",   "/api/work-queue/retry",                "user",          "queue_retried",     "User retried work queue jobs",       "medium"),

    # ── Logs (archive/resolve are user curation) ──
    ("POST",   "/api/logs",                            "user",          "log_resolved",      "User resolved an error log",         "low"),
    ("PATCH",  "/api/logs",                            "user",          "log_archived",      "User archived a report",             "low"),
    ("DELETE", "/api/logs",                            "user",          "log_deleted",       "User deleted a log",                 "low"),

    # ── Bibliography ──
    ("POST",   "/api/bibliography",                    "user",          "bib_added",         "User added a bibliography entry",    "low"),
    ("PUT",    "/api/bibliography",                    "user",          "bib_edited",        "User edited a bibliography entry",   "low"),
    ("DELETE", "/api/bibliography",                    "user",          "bib_removed",       "User removed a bibliography entry",  "low"),

    # ── Trust ──
    ("POST",   "/api/trust",                           "user_override", "trust_changed",     "User changed a trust level",         "high"),
    ("PUT",    "/api/trust",                           "user_override", "trust_changed",     "User changed a trust level",         "high"),
    ("PATCH",  "/api/trust",                           "user_override", "trust_changed",     "User changed a trust level",         "high"),

    # ── Vault generic file operations ──
    ("PUT",    "/api/vault/file",                      "user",          "vault_file_edited", "User edited a vault file",           "medium"),
    ("POST",   "/api/vault/file",                      "user",          "vault_file_created","User created a vault file",          "medium"),
    ("DELETE", "/api/vault/file",                      "user",          "vault_file_deleted","User deleted a vault file",          "medium"),

    # ── Watcher quarantine (user triage of failed ingests) ──
    ("POST",   "/api/watcher/scan",                    "user",          "watcher_scanned",   "User triggered an inbox scan",       "low"),
    ("POST",   "/api/watcher/quarantine/restore",      "user",          "quarantine_restored","User restored a quarantined file",  "medium"),
    ("POST",   "/api/watcher/quarantine/delete",       "user_override", "quarantine_deleted","User deleted a quarantined file",    "medium"),
    ("POST",   "/api/watcher/quarantine",              "user",          "quarantine_action", "User acted on quarantine",           "medium"),

    # ── Pipeline per-file management (user triage from /pipeline page) ──
    # GETs (/summary, /board, /recent) are auto-exempt — middleware skips read methods.
    ("DELETE", "/api/pipeline/inbox",                  "user_override", "pipeline_inbox_deleted", "User removed a file from inbox", "medium"),
    ("POST",   "/api/pipeline/unsupported/restore",    "user",          "pipeline_unsupported_restored", "User restored an unsupported file", "medium"),
    ("DELETE", "/api/pipeline/unsupported",            "user_override", "pipeline_unsupported_deleted", "User deleted an unsupported file", "medium"),
    ("POST",   "/api/pipeline/queued/ingest-now",      "user",          "pipeline_force_ingest", "User triggered immediate ingest of a queued file", "medium"),

    # ── Audit purge ──
    ("POST",   "/api/audit/purge",                     "user_override", "audit_purged",      "User purged the audit log",          "high"),
    ("DELETE", "/api/audit",                           "user_override", "audit_purged",      "User deleted audit entries",         "high"),

    # ── Search reclassify / stale cleanup ──
    ("POST",   "/api/search/reclassify",               "user",          "search_reclassified","User reclassified search index",    "low"),
    ("DELETE", "/api/search/stale",                    "user",          "search_cleanup",    "User cleaned stale search entries",  "low"),

    # ── Onboarding completion ──
    ("POST",   "/api/onboarding/complete",             "user",          "onboarding_done",   "User completed onboarding",          "high"),
    ("POST",   "/api/onboarding",                      "user",          "onboarding_step",   "User advanced onboarding",           "low"),

    # ── Conversation archive/delete (user curation) ──
    # POST entry exists so the static classification-coverage test sees
    # /api/conversations/{cid}/regenerate as classified. The suffix-override
    # branch in _classify() replaces this with chat_regenerated at runtime.
    ("POST",   "/api/conversations",                   "user",          "conversation_action", "User acted on a conversation",      "medium"),
    ("PATCH",  "/api/conversations",                   "user",          "conversation_archived","User archived a conversation",    "low"),
    ("DELETE", "/api/conversations",                   "user",          "conversation_deleted","User deleted a conversation",      "medium"),

    # ── Chat TTS playback (Session 8) ──
    # Low importance: this is a per-reply audio fetch, often fired by an
    # autoplay toggle, so the agent doesn't need a high-signal record. The
    # event is still useful to surface in /activity for "how often does the
    # user actually replay agent answers" learning.
    ("POST",   "/api/speak",                           "user",          "tts_played",        "User played a TTS reply",            "low"),

    # ── Settings model tiers ──
    ("PUT",    "/api/settings/model-tiers",            "user",          "model_tiers_changed","User changed model tier routing",   "medium"),

    # ── Canvas snapshots ──
    ("POST",   "/api/canvas/snapshot",                 "user",          "canvas_snapshot",   "User saved a canvas snapshot",       "low"),
    ("DELETE", "/api/canvas/snapshot",                 "user",          "canvas_snap_removed","User deleted a canvas snapshot",    "low"),

    # ── LLM reconcile + local-stack ──
    ("POST",   "/api/llm/reconcile-tiers",             "user",          "llm_reconciled",    "User reconciled LLM tiers",          "medium"),
    ("POST",   "/api/llm/local-stack/test",            "user",          "llm_local_tested",  "User tested a local LLM model",      "low"),

    # ── Orchestrator plan (generic catch-all, suffix routes above take priority) ──
    ("POST",   "/api/orchestrator/plan",               "user",          "plan_action",       "User acted on the action plan",      "medium"),
    ("DELETE", "/api/orchestrator/plan",               "user",          "plan_cleared",      "User cleared the action plan",       "low"),

    # ── Canvas item sub-actions ──
    ("PATCH",  "/api/canvas/items",                    "user",          "canvas_item_edited","User edited a canvas item",          "low"),
    ("DELETE", "/api/canvas/items",                    "user",          "canvas_item_removed","User removed a canvas item",        "low"),

    # ── Email (user-triggered read/send/triage) ──
    ("POST",   "/api/email/send",                      "user",          "email_sent",        "User sent an email",                 "high"),
    ("POST",   "/api/email/draft",                     "user",          "email_drafted",     "User drafted an email",              "medium"),
    ("POST",   "/api/email/sync",                      "user",          "email_synced",      "User triggered email sync",          "low"),
    ("POST",   "/api/email/message",                   "user",          "email_triaged",     "User triaged an email message",      "low"),

    # ── Emergency resume (after freeze) ──
    ("POST",   "/api/resume",                          "user_override", "emergency_resumed", "User resumed after emergency stop",  "high"),

    # ── Launch (start background services) ──
    ("POST",   "/api/launch",                          "user",          "systems_launched",  "User launched background services",   "high"),

    # ── Goals (PUT missing) ──
    ("PUT",    "/api/goals",                           "user",          "goal_updated",      "User updated a goal",                "medium"),

    # ── Graph admin actions ──
    ("POST",   "/api/graph/rebuild-registry",          "user",          "graph_registry_rebuilt","User rebuilt the entity registry","medium"),
    ("POST",   "/api/graph/ontology/reload",           "user",          "ontology_reloaded", "User reloaded the ontology",         "medium"),
    ("POST",   "/api/graph/registry/merge",            "user",          "entities_merged",   "User merged entities",               "medium"),
    ("POST",   "/api/graph/propagate",                 "user",          "triple_propagated", "User propagated a triple change",    "medium"),
    ("POST",   "/api/graph/embeddings/backfill",       "user",          "embeddings_backfilled","User backfilled entity embeddings","low"),
    ("POST",   "/api/graph/merge-proposals/apply-batch","user",         "merge_proposals_applied_batch","User approved many duplicate-merge proposals at once","medium"),
    ("POST",   "/api/graph/merge-proposals/apply",     "user",          "merge_proposal_applied","User approved a duplicate-merge proposal","medium"),
    ("POST",   "/api/graph/merge-proposals/dismiss",   "user_override", "merge_proposal_dismissed","User rejected a duplicate-merge proposal","low"),
    # Reversible-merge audit retraction — canonical user_override case
    # (user reversing a prior decision). Path-prefix match; the actual
    # path is /api/graph/merge-audit/{audit_id}/retract.
    ("POST",   "/api/graph/merge-audit",               "user_override", "entity_unmerged",   "User retracted a merge",             "medium"),

    # ── Memory pending approve/reject/unapprove (dated paths) ──
    ("POST",   "/api/memory/pending",                  "user",          "memory_pending_action","User acted on pending memory",    "medium"),

    # ── Orchestrator dispatch / workflow (user-initiated subagent runs) ──
    ("POST",   "/api/orchestrator/dispatch",           "user",          "subagent_dispatched","User dispatched a subagent",        "medium"),
    ("POST",   "/api/orchestrator/workflow",           "user",          "workflow_ran",      "User ran a workflow",                "medium"),

    # ── Settings destructive / migration ──
    ("POST",   "/api/settings/migrate-frontmatter",    "user",          "frontmatter_migrated","User migrated frontmatter",        "medium"),
    ("POST",   "/api/update",                           "user_override", "self_update",       "User triggered self-update",         "high"),
    ("POST",   "/api/settings/reset",                  "user_override", "settings_reset",    "User reset settings",                "high"),
    ("POST",   "/api/settings/reset-file",             "user_override", "settings_file_reset","User reset a settings file",        "high"),
    ("POST",   "/api/settings/autostart",              "user",          "autostart_enabled", "User enabled auto-start on boot",    "medium"),
    ("DELETE", "/api/settings/autostart",              "user",          "autostart_disabled","User disabled auto-start on boot",   "medium"),
    # Watcher pause/resume — load-bearing for the agent's world model: when paused,
    # automatic ingestion is OFF, briefings + reflections see no new files, the user
    # has consciously turned off default-on automation. Body-aware classification
    # would require reading {paused: bool}; middleware sees only the route, so we
    # record both flips uniformly and let the response payload distinguish them.
    ("PUT",    "/api/settings/watcher",                "user",          "watcher_toggled",   "User paused or resumed inbox auto-ingestion","medium"),
    # Pipeline freeze is a HIGH-importance user_override — it gates every
    # ingestion path globally. The agent should see it in get_relationship_context()
    # so reflections + briefings know automated work is on hold.
    ("PUT",    "/api/settings/pipeline-freeze",        "user_override", "pipeline_freeze_toggled", "User froze or thawed all ingestion paths", "high"),

    # ── Skills admin ──
    ("POST",   "/api/skills/analyze",                  "user",          "skills_analyzed",   "User analyzed skills",               "low"),

    # ── Trust domain delete ──
    ("DELETE", "/api/trust",                           "user_override", "trust_removed",     "User removed a trust entry",         "high"),

    # ── Wiki reclassify / stale cleanup ──
    ("POST",   "/api/wiki/reclassify",                 "user",          "wiki_reclassified", "User reclassified wiki content",     "medium"),
    ("DELETE", "/api/wiki/stale",                      "user",          "wiki_stale_cleaned","User cleaned stale wiki entries",    "low"),

    # ── Work queue admin ──
    ("DELETE", "/api/work-queue",                      "user",          "queue_job_removed", "User removed a queue job",           "low"),

    # ── Language drift batch re-ingest (spec v1.1 item 3) ──
    ("POST",   "/api/kb/language-drift",               "user",          "drift_reingested",  "User re-ingested drifted sources",   "medium"),

    # ── Privacy (right-to-erasure / portability) ──
    ("POST",   "/api/privacy/forget",                  "user_override", "privacy_forget",    "User invoked right-to-erasure",      "high"),

    # ── Decision log admin ──
    ("POST",   "/api/decisions/prune",                 "user",          "decisions_pruned",  "User ran decision-log retention",    "low"),
    ("PUT",    "/api/decisions/retention",             "user",          "retention_changed", "User changed retention policy",      "medium"),
    # Scope revert — rolls back the mutation chain of a correlation_id.
    # High-importance user_override because it undoes the agent's own
    # completed work at a turn-level checkpoint.
    ("POST",   "/api/decisions",                       "user_override", "decision_scope_reverted",
        "User reverted a correlation-scope mutation chain",             "high"),

    # ── Admin endpoints ──
    ("POST",   "/api/admin/rate-limits/reset",         "user_override", "rate_limit_reset",  "User reset a rate-limit bucket",     "medium"),

    # ── Evolution layer (Phase 4-5 write endpoints) ──
    # Every write reverses or triggers an autonomous decision of the
    # evolution subsystem — classified at high importance so the agent
    # sees every manual intervention in its world model.
    ("POST",   "/api/evolution/promote/run",           "user",          "evolution_promote_ran",
        "User ran the promotion arena",                                  "medium"),
    ("POST",   "/api/evolution/promote",               "user",          "evolution_promote_manual",
        "User manually evaluated a reflex candidate",                    "high"),
    ("POST",   "/api/evolution/rollback",              "user_override", "evolution_rollback",
        "User rolled back a prior promotion",                            "high"),
    ("POST",   "/api/evolution/replay",                "user",          "evolution_replay_requested",
        "User enqueued a replay job",                                    "medium"),
    ("POST",   "/api/evolution/drift/run",             "user",          "evolution_drift_ran",
        "User ran the drift detector",                                   "low"),
    ("POST",   "/api/evolution/mine",                  "user",          "evolution_mine_ran",
        "User ran the reflex miner",                                     "low"),
    ("POST",   "/api/evolution/kill-switch",           "user_override", "evolution_kill_switch",
        "User toggled the evolution selector kill switch",               "high"),
    ("POST",   "/api/evolution/feedback",              "user",          "evolution_feedback_recorded",
        "User recorded feedback on a run",                               "low"),

    # ── Regression capture (2026-04-23) ──
    # A regression test is a user-curated pointer to a correlation chain
    # they want to preserve as expected behavior. Captures land as medium
    # (curation action), replays land as medium (observational), archive
    # toggles + deletes surface so the audit trail stays browseable.
    ("POST",   "/api/regressions/capture",             "user",          "regression_captured",
        "User captured a correlation chain as a regression test",        "medium"),
    ("POST",   "/api/regressions",                     "user",          "regression_replayed",
        "User replayed a regression test",                               "medium"),
    ("PATCH",  "/api/regressions",                     "user",          "regression_archived",
        "User toggled archive on a regression test",                     "low"),
    ("DELETE", "/api/regressions",                     "user_override", "regression_deleted",
        "User deleted a regression test",                                "medium"),

    # ── Turn checkpoint revert (2026-04-23) ──
    # Scope-revert reverses agent or workflow activity on a per-correlation-id
    # basis. Always high importance + user_override — the user is undoing
    # changes the agent or a workflow made. Sub-path /revert-preview is a
    # read-only GET so it's automatically exempt from this classifier.
    ("POST",   "/api/decisions/",                      "user_override", "scope_reverted",
        "User reverted a correlation-scoped batch of writes",            "high"),
]

# Exempt: noisy, automated, or webhook paths that should never log activity.
_EXEMPT_PREFIXES: tuple[str, ...] = (  # arch:deterministic — routes that self-log via dedicated service hooks; protected by tests/e2e/test_classification_coverage.py which enforces justification per entry
    "/api/health",
    "/api/chat",              # has its own logging via Agent
    "/api/transcribe",        # speech-to-text utility, no persistent state
    "/api/onboarding/chat",
    "/api/costs/track",       # cost telemetry — noisy
    "/api/heartbeat",         # background pulse
    "/api/agent/insights",    # read-only awareness (no persistent state)
    "/api/simulate",          # simulation reports are agent-initiated
    "/api/orchestrator/simulate",
    "/api/sync/pick-folder",  # opens the OS folder dialog; no persistent state
)


def _classify(method: str, path: str) -> tuple[str, str, str, str] | None:
    """Match method+path to (category, action, summary, importance).

    Longest prefix match wins so more-specific sub-routes (e.g. /wiki/article/{slug}/rename)
    can override the generic /wiki/article entry by ordering in the classification list.
    """
    # Sub-action match first: inspect trailing path segment for known verbs
    suffix_overrides = {
        "/rename": ("user", "wiki_renamed", "User renamed a wiki article", "medium"),
        "/verify": ("user", "wiki_verified", "User verified a wiki article", "low"),
        "/reject": ("user", "wiki_rejected", "User rejected a wiki article", "medium"),
        "/consolidate": ("user", "wiki_consolidated", "User consolidated a wiki article", "medium"),
        "/repair": ("user", "wiki_repaired", "User ran wiki auto-repair", "low"),
    }
    if method == "POST" and path.startswith("/api/wiki/article"):
        for suffix, override in suffix_overrides.items():
            if path.endswith(suffix):
                return override

    # KB pipeline sub-actions: /api/kb/{slug}/{verb}. We want these to
    # register as specific knowledge events, not inherit the generic
    # "kb_created" classification from the /api/kb prefix row.
    kb_verb_overrides = {
        "/ingest":      ("knowledge", "kb_ingest_requested",  "User triggered ingest",       "medium"),
        "/compile":     ("knowledge", "kb_compile_requested", "User triggered compile",      "medium"),
        "/reflect":     ("knowledge", "kb_reflect_requested", "User triggered reflect",      "medium"),
        "/lint":        ("knowledge", "kb_lint_requested",    "User triggered lint",         "low"),
        "/job":         ("knowledge", "kb_wiki_job",          "User ran wiki job",           "medium"),
        "/snapshot":    ("knowledge", "kb_snapshot",          "User snapshotted KB",         "low"),
        "/repair":      ("knowledge", "kb_repaired",          "User ran KB repair",          "low"),
        "/repair-citations": ("knowledge", "kb_citations_repaired", "User ran citation repair", "medium"),
        "/scrape":      ("knowledge", "kb_scrape_requested",  "User triggered scrape",       "medium"),
        "/synthesize":  ("knowledge", "kb_synthesize",        "User triggered synthesis",    "medium"),
        "/sync-projects": ("knowledge", "kb_sync_projects",   "User synced KB with projects","low"),
        "/library/reorganize": ("knowledge", "kb_library_reorganized", "User reorganized KB library", "medium"),
    }
    if method == "POST" and path.startswith("/api/kb/") and not path.startswith("/api/kb/inbox"):
        for suffix, override in kb_verb_overrides.items():
            if path.endswith(suffix):
                return override

    # PUT overrides under /api/kb/{slug}/library/ — config changes, not renames.
    if method == "PUT" and path.endswith("/library/layout") and path.startswith("/api/kb/"):
        return ("knowledge", "kb_library_layout_changed",
                "User changed KB library layout", "medium")

    # §9.4 MCP review flow — security-relevant trust decisions. These need
    # specific classifiers so they don't inherit the generic `mcp_toggled`
    # from the `/api/mcp/servers` prefix row. Approve is a user decision;
    # reject is a user_override (the user said no to something previously
    # connected or pending).
    if method == "POST" and path.startswith("/api/mcp/servers/"):
        if path.endswith("/review/approve"):
            return ("user", "mcp_server_reviewed",
                    "User approved MCP server tools after review", "high")
        if path.endswith("/review/reject"):
            return ("user_override", "mcp_server_review_rejected",
                    "User rejected MCP server at review gate", "high")
        # Layer 4 — manual circuit-breaker reset. The user has decided the
        # upstream issue is resolved and is overriding the auto-recovery
        # wait. Classed as user_override so it appears in the agent's
        # world model alongside other "user reversed an automated decision"
        # events.
        if path.endswith("/reset-circuit"):
            return ("user_override", "mcp_circuit_reset",
                    "User manually reset MCP circuit breaker", "medium")

    # Chat sub-actions: edit a user message OR regenerate the assistant
    # reply. Both are user mutations of agent output that need to be
    # visible in /activity AND /decisions — Rule #13. Without these the
    # generic PATCH /api/conversations prefix would mis-classify the
    # message edit as an archive event, and POST /regenerate would fall
    # through entirely.
    if (
        method == "PATCH"
        and path.startswith("/api/conversations/")
        and "/messages/" in path
    ):
        return ("user", "chat_message_edited",
                "User edited a chat message", "medium")
    if (
        method == "POST"
        and path.startswith("/api/conversations/")
        and path.endswith("/regenerate")
    ):
        return ("user", "chat_regenerated",
                "User regenerated assistant reply", "medium")

    # Goal pursuit sub-actions: /api/goals/{slug}/pursuit/{verb}. The route
    # handlers log their own richer events via ActivityLogger; these entries
    # exist so the symmetry test doesn't flag the paths as inheriting the
    # generic /api/goals `goal_created` classification.
    goal_pursuit_overrides = {
        "/pursuit/raise-trust": ("user_override", "goal_trust_raised",    "User raised trust for a goal",       "high"),
        "/pursuit/override":    ("user_override", "goal_trust_override",  "User toggled goal trust override",   "high"),
        "/pursuit/reset":       ("user_override", "goal_pursuit_reset",   "User reset goal pursuit",            "medium"),
        "/pursuit/start":       ("user",          "goal_pursuit_started", "User started goal pursuit",          "medium"),
        "/pursuit/pause":       ("user",          "goal_pursuit_paused",  "User paused goal pursuit",           "medium"),
        "/pursuit/dispatch-now":("user",          "goal_pursuit_dispatched","User dispatched goal pursuit immediately","medium"),
        "/shape":               ("user",          "goal_shape_requested", "User requested shape proposals",     "low"),
    }
    if method == "POST" and path.startswith("/api/goals/"):
        for suffix, override in goal_pursuit_overrides.items():
            if path.endswith(suffix):
                return override
        # Pending-questions routes (Ship 3). Answer is a user action;
        # POST /questions is the manual/debug path that also covers
        # the agent's own writes (though those are inside a skill
        # correlation, not an API request).
        if path.endswith("/answer"):
            return ("user", "goal_question_answered", "User answered agent's question", "medium")
        if path.endswith("/questions"):
            return ("system", "goal_question_asked", "Question queued on goal", "medium")
        # Ship 4 PDCA routes
        if path.endswith("/metric/observation"):
            return ("user", "metric_observation_recorded", "Metric observation recorded", "low")
        if path.endswith("/retrospectives"):
            return ("system", "retrospective_submitted", "Retrospective submitted", "medium")
        if path.endswith("/apply"):
            return ("user", "retrospective_applied", "Owner applied retrospective decision", "medium")
        if path.endswith("/milestones"):
            return ("user", "milestone_added", "Milestone added to goal", "low")
        if path.endswith("/complete"):
            return ("user", "milestone_completed", "Milestone completed", "medium")
    if method == "DELETE" and path.startswith("/api/goals/") and "/milestones/" in path:
        return ("user", "milestone_deleted", "Milestone deleted", "low")
    if method == "PATCH" and path.startswith("/api/goals/"):
        if path.endswith("/pursuit/domain"):
            return ("user", "goal_domain_changed", "User changed goal trust domain", "medium")
        if path.endswith("/pursuit"):
            return ("user", "goal_pursuit_config", "User updated goal pursuit config", "medium")

    best: tuple[int, tuple[str, str, str, str]] | None = None
    for m, prefix, category, action, summary, importance in _CLASSIFICATION:
        if m != method or not path.startswith(prefix):
            continue
        score = len(prefix)
        if best is None or score > best[0]:
            best = (score, (category, action, summary, importance))
    return best[1] if best else None


class ActivityMiddleware(BaseHTTPMiddleware):
    """Log every successful mutating user action to ActivityLogger.

    Runs AFTER the request handler so we only log successful (2xx) outcomes.
    Non-mutating methods (GET/HEAD/OPTIONS) are bypassed immediately. Webhooks,
    exempt paths, and unclassified routes pass through without logging.
    """

    async def dispatch(self, request: Request, call_next) -> Response:  # noqa: ANN001
        method = request.method

        # Only mutating methods
        if method not in ("POST", "PUT", "PATCH", "DELETE"):
            return await call_next(request)

        path = request.url.path

        # Exempt prefixes
        if any(path.startswith(p) for p in _EXEMPT_PREFIXES):
            return await call_next(request)

        # Webhooks / incoming channel traffic — not user actions
        if "/webhook" in path or "/incoming" in path:
            return await call_next(request)

        # Run the handler first — we only log successful outcomes
        response = await call_next(request)

        if response.status_code >= 300:
            return response

        activity = getattr(request.app.state, "activity_logger", None)
        if activity is None:
            return response

        classification = _classify(method, path)
        if classification is None:
            return response

        category, action, summary, importance = classification
        try:
            activity.log(
                category=category,
                action=action,
                summary=summary,
                details={"method": method, "path": path, "status": response.status_code},
                importance=importance,
            )
            activity.record_user_activity("api")
        except Exception:
            log.debug("Failed to record activity for %s %s", method, path, exc_info=True)

        return response
