from __future__ import annotations

from collections import deque
from datetime import datetime, timezone
from types import SimpleNamespace

from fastapi.testclient import TestClient

import eightmem.ui.app as ui_app
import eightmem.services.telegram_service as telegram_service
from eightmem.core.governance import create_approval_request
from eightmem.core.paths import memory_dir
from eightmem.services.telegram_service import process_telegram_message
from eightmem.ui.app import create_app


def test_dashboard_and_files_routes(fake_home):
    client = TestClient(create_app())

    dashboard = client.get("/")
    assert dashboard.status_code == 200
    assert "Your AI Memory" in dashboard.text
    assert "Memory health" in dashboard.text
    assert "Memory timeline / diff" in dashboard.text
    assert "Structured memory view" in dashboard.text
    assert "Why trust looks like this" in dashboard.text

    files = client.get("/files")
    assert files.status_code == 200
    assert "IDENTITY.md" in files.text


def test_user_scoped_routes_show_active_scope_and_preserve_links(fake_home):
    client = TestClient(create_app())

    dashboard = client.get("/?user_id=12345")
    assert dashboard.status_code == 200
    assert "Active scope:" in dashboard.text
    assert "Telegram / user 12345" in dashboard.text
    assert '/mirror?user_id=12345' in dashboard.text
    assert '/files?user_id=12345' in dashboard.text

    files = client.get("/files?user_id=12345")
    assert files.status_code == 200
    assert '/files/IDENTITY.md?user_id=12345' in files.text

    editor = client.get("/files/IDENTITY.md?user_id=12345")
    assert editor.status_code == 200
    assert 'action="/files/IDENTITY.md?user_id=12345"' in editor.text
    assert 'href="/files?user_id=12345"' in editor.text

    export_page = client.get("/export?user_id=12345")
    assert export_page.status_code == 200
    assert '/export/download?user_id=12345' in export_page.text


def test_full_navigation_pages(fake_home):
    client = TestClient(create_app())
    pages = ["/overview", "/chat", "/import", "/mirror", "/inbox", "/export", "/everywhere"]
    for path in pages:
        res = client.get(path)
        assert res.status_code == 200

    mirror = client.get("/mirror")
    assert "Memory timeline / diff" in mirror.text
    assert "Memory health" in mirror.text
    assert "Structured memory view" in mirror.text
    assert "Why trust looks like this" in mirror.text

    download = client.get("/export/download")
    assert download.status_code == 200
    assert "8mem Context Export" in download.text
    assert "attachment; filename=\"8mem_context.txt\"" in download.headers["content-disposition"]


def test_chat_page_answers_with_memory_context(fake_home, monkeypatch):
    captured: dict[str, object] = {}

    def _fake_generate_text(**kwargs):
        captured.update(kwargs)
        return SimpleNamespace(text="Short and direct test answer.")

    monkeypatch.setattr(ui_app, "generate_text", _fake_generate_text)
    process_telegram_message(user_id=None, text="Remember: keep answers concise")
    client = TestClient(create_app())

    page = client.get("/chat")
    head = client.head("/chat")
    response = client.post("/chat", data={"question": "How should you answer me?"})

    assert page.status_code == 200
    assert head.status_code == 200
    assert "Chat with 8mem memory, without Telegram." in page.text
    assert response.status_code == 200
    assert "Short and direct test answer." in response.text
    assert "Keep answers concise" in str(captured["prompt"])


def test_v1_context_requires_local_bearer_key(fake_home):
    client = TestClient(create_app())

    missing = client.get("/v1/context")
    wrong = client.get("/v1/context", headers={"Authorization": "Bearer wrong"})

    assert missing.status_code == 401
    assert wrong.status_code == 401


def test_v1_runtime_write_endpoints_require_local_bearer_key(fake_home):
    client = TestClient(create_app())

    changes = client.get("/v1/changes")
    memory = client.post("/v1/memory", json={"value": "8mem is a memory layer."})
    correction = client.post("/v1/correction", json={"value": "do not use emojis"})
    approval = client.post("/v1/approval", json={"action_type": "email_send", "summary": "Send email"})

    assert changes.status_code == 401
    assert memory.status_code == 401
    assert correction.status_code == 401
    assert approval.status_code == 401


def test_v1_context_returns_engram_payload_for_openclaw(fake_home):
    process_telegram_message(user_id=None, text="Remember: keep answers concise")
    process_telegram_message(user_id=None, text="Correct: do not use emojis")
    client = TestClient(create_app())

    response = client.get("/v1/context", headers={"Authorization": "Bearer local_key"})

    assert response.status_code == 200
    payload = response.json()
    assert payload["engram_version"] == "0.1"
    assert payload["subject"] == "local-user"
    assert payload["scope"] == "full"
    assert payload["identity"]["display_name"] == "Local user"
    assert payload["identity"]["timezone"] == "UTC"
    assert payload["system_prompt_injection"]
    assert "User is Local user" in payload["system_prompt_injection"]
    assert "No emoji." in payload["system_prompt_injection"]
    assert payload["signature"]["value"] == "unsigned-v1"
    assert isinstance(payload["beliefs"], list)
    assert isinstance(payload["corrections"], list)


def test_engram_discovery_endpoints_advertise_unsigned_reference_mode(fake_home):
    client = TestClient(create_app())

    discovery = client.get("/.well-known/engram", headers={"host": "8mem.test"})
    keys = client.get("/.well-known/engram-keys", headers={"host": "8mem.test"})

    assert discovery.status_code == 200
    discovery_payload = discovery.json()
    assert discovery_payload["engram_version"] == "0.1"
    assert discovery_payload["issuer"] == "http://8mem.test"
    assert discovery_payload["context_endpoint"] == "http://8mem.test/v1/context"
    assert discovery_payload["correction_endpoint"] == "http://8mem.test/v1/context/correct"
    assert discovery_payload["keys_endpoint"] == "http://8mem.test/.well-known/engram-keys"
    assert discovery_payload["signature"]["mode"] == "unsigned-v1"
    assert discovery_payload["capabilities"]["correction_alias"] is True

    assert keys.status_code == 200
    key = keys.json()["keys"][0]
    assert key["kid"] == "key-1"
    assert key["alg"] == "Ed25519"
    assert key["status"] == "unsigned-v1"
    assert key["public_key"] is None


def test_v1_context_correct_alias_matches_correction_write(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    response = client.post(
        "/v1/context/correct",
        headers=headers,
        json={
            "user_id": "engram-correct-user",
            "text": "do not assume Engram means vector database",
            "old_text": "Engram is a vector database",
        },
    )
    context = client.get("/v1/context?user_id=engram-correct-user", headers=headers)

    assert response.status_code == 201
    assert response.json()["status"] == "corrected"
    assert "Do not infer Engram is a vector database" in context.json()["system_prompt_injection"]


def test_v1_context_filters_noop_corrections_from_engram_payload(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    value_without_period = "When explaining architecture, separate confirmed facts, planned work, and unknowns"
    value_with_period = f"{value_without_period}."

    memory = client.post(
        "/v1/memory",
        headers=headers,
        json={
            "user_id": "noop-correction-user",
            "text": value_without_period,
            "source": "openclaw_viri",
            "confidence": "explicit_user_statement",
        },
    )
    correction = client.post(
        "/v1/correction",
        headers=headers,
        json={
            "user_id": "noop-correction-user",
            "text": value_with_period,
            "old_text": value_without_period,
            "source": "openclaw_viri",
        },
    )
    context = client.get("/v1/context?user_id=noop-correction-user", headers=headers)

    assert memory.status_code == 201
    assert correction.status_code == 201
    assert context.status_code == 200
    corrections = context.json()["corrections"]
    assert all(item["new_value"] != value_with_period for item in corrections)


def test_v1_context_resolves_user_from_runtime_key_owner(fake_home, monkeypatch):
    monkeypatch.setenv("EIGHTMEM_CONTEXT_USER_ID", "5989413668")
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    write = client.post(
        "/v1/memory",
        headers=headers,
        json={
            "text": "I prefer key-resolved memory tests in plain English",
            "source": "openclaw_viri",
            "confidence": "explicit_user_statement",
        },
    )
    context = client.get("/v1/context", headers=headers)

    assert write.status_code == 201
    write_payload = write.json()
    assert write_payload["user_id"] == "5989413668"
    assert write_payload["memory"]["file"] == "PREFERENCES.md"
    assert context.status_code == 200
    context_payload = context.json()
    assert context_payload["subject"] == "5989413668"
    assert context_payload["identity"]["display_name"] == "User"
    assert "5989413668" not in context_payload["system_prompt_injection"]
    assert "Prefers key-resolved memory tests in plain English" in context_payload["system_prompt_injection"]


def test_v1_runtime_key_owner_blocks_unallowed_explicit_user(fake_home, monkeypatch):
    monkeypatch.setenv("EIGHTMEM_CONTEXT_USER_ID", "5989413668")
    client = TestClient(create_app())

    response = client.post(
        "/v1/memory",
        headers={"Authorization": "Bearer local_key"},
        json={"user_id": "someone-else", "text": "I prefer unsafe cross-user writes"},
    )

    assert response.status_code == 403
    assert response.json()["error"] == "Forbidden for requested user_id"


def test_v1_context_timestamps_use_identity_timezone(fake_home):
    client = TestClient(create_app())

    response = client.get("/v1/context", headers={"Authorization": "Bearer local_key"})

    assert response.status_code == 200
    payload = response.json()
    assert payload["identity"]["timezone"] == "UTC"
    assert payload["issued_at"].endswith("+00:00")
    assert payload["expires_at"].endswith("+00:00")


def test_v1_memory_writes_audited_runtime_memory(fake_home):
    client = TestClient(create_app())

    response = client.post(
        "/v1/memory",
        headers={"Authorization": "Bearer local_key"},
        json={
            "user_id": "runtime-user-1",
            "category": "preference",
            "value": "I prefer short and direct replies",
            "source": "openclaw",
        },
    )

    assert response.status_code == 201
    payload = response.json()
    assert payload["ok"] is True
    assert payload["memory"]["file"] == "PREFERENCES.md"
    assert payload["memory"]["value"] == "Prefers short and direct replies."

    context = client.get("/v1/context?user_id=runtime-user-1", headers={"Authorization": "Bearer local_key"})
    assert context.status_code == 200
    assert "Prefers short and direct replies" in context.json()["system_prompt_injection"]


def test_v1_connectors_register_list_and_delete(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    registered = client.post(
        "/v1/connectors/register",
        headers=headers,
        json={
            "id": "openclaw-viri",
            "url": "http://127.0.0.1:18789/8mem/sync",
            "description": "OpenClaw Viri",
        },
    )
    listed = client.get("/v1/connectors", headers=headers)
    deleted = client.delete("/v1/connectors/openclaw-viri", headers=headers)
    listed_after = client.get("/v1/connectors", headers=headers)

    assert registered.status_code == 201
    payload = registered.json()
    assert payload["connector"]["id"] == "openclaw-viri"
    assert payload["connector"]["url"] == "http://127.0.0.1:18789/8mem/sync"
    assert "secret" not in payload["connector"]
    assert len(payload["secret"]) == 64
    assert listed.status_code == 200
    assert listed.json()["connectors"][0]["id"] == "openclaw-viri"
    assert "secret" not in listed.json()["connectors"][0]
    assert deleted.status_code == 204
    assert listed_after.json()["connectors"] == []


def test_v1_connectors_alias_accepts_label_and_secret(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    registered = client.post(
        "/v1/connectors",
        headers=headers,
        json={
            "label": "local-agents",
            "url": "http://127.0.0.1:18792/8mem/sync",
            "secret": "installer-secret",
        },
    )

    assert registered.status_code == 201
    payload = registered.json()
    assert payload["connector"]["id"] == "local-agents"
    assert payload["secret"] == "installer-secret"


def test_v1_passport_card_returns_png(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    write = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "card-user", "category": "preference", "value": "I prefer calm plain English"},
    )

    response = client.get("/v1/passport/card?user_id=card-user", headers=headers)

    assert write.status_code == 201
    assert response.status_code == 200
    assert response.headers["content-type"] == "image/png"
    assert response.content.startswith(b"\x89PNG")


def test_v1_compare_card_renders_caller_supplied_answers_without_llm(fake_home, monkeypatch):
    def _fail_if_llm_called(*args, **kwargs):
        raise AssertionError("card endpoint must be renderer-only")

    monkeypatch.setattr(ui_app, "generate_text", _fail_if_llm_called)
    client = TestClient(create_app())
    response = client.post(
        "/v1/compare/card",
        headers={"Authorization": "Bearer local_key"},
        json={
            "topic": "investor offer",
            "generic_answer": "Review valuation, terms, and runway.",
            "memory_answer": "Evaluate control, dilution, and long-term ownership.",
            "used_beliefs": ["Prefers investor offers evaluated against control and dilution"],
        },
    )

    assert response.status_code == 200
    assert response.headers["content-type"] == "image/png"
    assert response.content.startswith(b"\x89PNG")


def test_v1_compare_card_validates_required_fields(fake_home):
    client = TestClient(create_app())

    response = client.post(
        "/v1/compare/card",
        headers={"Authorization": "Bearer local_key"},
        json={"topic": "investor offer", "generic_answer": "Generic only"},
    )

    assert response.status_code == 400
    assert response.json()["error"] == "Missing required string field: memory_answer"


def test_v1_connectors_validate_input(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    missing_id = client.post("/v1/connectors/register", headers=headers, json={"url": "http://127.0.0.1:1/8mem/sync"})
    bad_url = client.post("/v1/connectors/register", headers=headers, json={"id": "agent", "url": "file:///tmp/sync"})

    assert missing_id.status_code == 400
    assert bad_url.status_code == 400


def test_v1_memory_write_notifies_connectors(fake_home, monkeypatch):
    notifications: list[tuple[str, str | None]] = []

    def _fake_notify(event: str, *, user_id: str | None = None) -> int:
        notifications.append((event, user_id))
        return 2

    monkeypatch.setattr(ui_app, "notify_connectors", _fake_notify)
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    response = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-webhook", "value": "8mem syncs connected agents"},
    )

    assert response.status_code == 201
    assert response.json()["connector_notifications"] == 2
    assert notifications == [("memory.created", "runtime-user-webhook")]


def test_v1_memory_skips_injection_artifacts(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    response = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "artifact-user", "value": "Timezone: Asia/Singapore"},
    )
    context = client.get("/v1/context?user_id=artifact-user", headers=headers)

    assert response.status_code == 200
    assert response.json()["status"] == "skipped"
    assert response.json()["reason"] == "injection_artifact"
    assert "Timezone: Asia/Singapore" not in context.json()["system_prompt_injection"]


def test_v1_memory_rejects_near_duplicate(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    first = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "duplicate-user", "category": "preference", "value": "I prefer calm plain English"},
    )
    duplicate = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "duplicate-user", "category": "preference", "value": "Prefers calm plain English"},
    )

    assert first.status_code == 201
    assert duplicate.status_code == 409
    assert duplicate.json()["error"] == "Near-duplicate memory detected"


def test_v1_memory_rejects_semantic_favourite_drink_duplicate(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    user_id = "semantic-drink-duplicate"
    first = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": user_id, "text": "my favourite drink is black tea no sugar"},
    )
    duplicate = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": user_id, "text": "my favourite drinks is black tea"},
    )

    assert first.status_code == 201
    assert duplicate.status_code == 409
    payload = duplicate.json()
    assert payload["status"] == "duplicate"
    assert payload["conflict"]["value"].rstrip(".").lower() == "my favourite drink is black tea no sugar"


def test_v1_memory_candidate_duplicate_and_forget_resave_sequence(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    user_id = "candidate-duplicate-forget-resave"
    first_payload = {"user_id": user_id, "message": "my favourite drink is black tea"}

    first = client.post("/v1/memory/candidate", headers=headers, json=first_payload)
    exact_duplicate = client.post("/v1/memory/candidate", headers=headers, json=first_payload)
    semantic_duplicate = client.post(
        "/v1/memory/candidate",
        headers=headers,
        json={"user_id": user_id, "message": "my favourite drinks is black tea"},
    )
    deleted = client.post(
        "/v1/forget",
        headers=headers,
        json={"user_id": user_id, "query": "black tea", "confirm": True},
    )
    resaved = client.post("/v1/memory/candidate", headers=headers, json=first_payload)
    duplicate_after_resave = client.post("/v1/memory/candidate", headers=headers, json=first_payload)

    assert first.status_code in {200, 201}
    assert first.json()["status"] == "written"
    assert exact_duplicate.status_code == 409
    assert exact_duplicate.json()["status"] == "duplicate"
    assert semantic_duplicate.status_code == 409
    assert semantic_duplicate.json()["status"] == "duplicate"
    assert deleted.status_code == 200
    assert deleted.json()["status"] == "deleted"
    assert resaved.status_code in {200, 201}
    assert resaved.json()["status"] == "written"
    assert duplicate_after_resave.status_code == 409
    assert duplicate_after_resave.json()["status"] == "duplicate"


def test_v1_memory_blocks_semantic_refinement_until_confirmed(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    user_id = "semantic-drink-refinement"
    first = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": user_id, "text": "my favourite drinks is black tea"},
    )
    refinement = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": user_id, "text": "my favourite drink is black tea no sugar"},
    )

    assert first.status_code == 201
    assert refinement.status_code == 409
    payload = refinement.json()
    assert payload["status"] == "refinement_pending"
    assert payload["existing"]["value"].rstrip(".").lower() == "my favourite drinks is black tea"
    assert payload["proposed"]["value"].rstrip(".").lower() == "my favourite drink is black tea no sugar"
    assert payload["conflicting_belief"]["value"].rstrip(".").lower() == "my favourite drinks is black tea"


def test_v1_memory_proposal_blocks_semantic_refinement_until_confirmed(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    user_id = "semantic-drink-proposal-refinement"
    first = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": user_id, "text": "my favourite drinks is black tea"},
    )
    refinement = client.post(
        "/v1/memory/propose",
        headers=headers,
        json={
            "user_id": user_id,
            "text": "my favourite drink is black tea no sugar",
            "source_message": "I usually take black tea without sugar",
        },
    )

    assert first.status_code == 201
    assert refinement.status_code == 409
    payload = refinement.json()
    assert payload["status"] == "refinement_pending"
    assert payload["existing"]["value"].rstrip(".").lower() == "my favourite drinks is black tea"


def test_v1_memory_proposal_does_not_write_until_committed(fake_home, monkeypatch):
    notifications: list[tuple[str, str | None]] = []

    def _fake_notify(event: str, *, user_id: str | None = None) -> int:
        notifications.append((event, user_id))
        return 1

    monkeypatch.setattr(ui_app, "notify_connectors", _fake_notify)
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    proposed = client.post(
        "/v1/memory/propose",
        headers=headers,
        json={
            "user_id": "proposal-user",
            "category": "preference",
            "value": "I usually avoid calls before 9am",
            "source": "openclaw_viri",
        },
    )
    context_before = client.get("/v1/context?user_id=proposal-user", headers=headers)

    assert proposed.status_code == 201
    proposal = proposed.json()["proposal"]
    assert proposed.json()["status"] == "proposed"
    assert "Prefers usually avoid calls before 9am" not in context_before.json()["system_prompt_injection"]
    assert notifications == []

    committed = client.post(
        "/v1/memory/commit",
        headers=headers,
        json={"user_id": "proposal-user", "proposal_id": proposal["id"], "decision": "approved"},
    )
    context_after = client.get("/v1/context?user_id=proposal-user", headers=headers)

    assert committed.status_code == 201
    assert committed.json()["status"] == "written"
    assert committed.json()["proposal"]["status"] == "committed"
    assert committed.json()["connector_notifications"] == 1
    assert "Prefers I usually avoid calls before 9am" in context_after.json()["system_prompt_injection"]
    assert notifications == [("memory.created", "proposal-user")]


def test_v1_memory_proposal_can_be_denied_without_write(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    proposed = client.post(
        "/v1/memory/propose",
        headers=headers,
        json={"user_id": "proposal-deny-user", "value": "Investor offers should protect control"},
    )
    proposal_id = proposed.json()["proposal"]["id"]
    denied = client.post(
        "/v1/memory/commit",
        headers=headers,
        json={"user_id": "proposal-deny-user", "proposal_id": proposal_id, "decision": "denied"},
    )
    context = client.get("/v1/context?user_id=proposal-deny-user", headers=headers)

    assert proposed.status_code == 201
    assert denied.status_code == 200
    assert denied.json()["status"] == "denied"
    assert "Investor offers should protect control" not in context.json()["system_prompt_injection"]


def test_v1_memory_candidate_puts_high_trust_natural_preference_in_inbox(fake_home, monkeypatch):
    notifications: list[tuple[str, str | None]] = []

    def _fake_notify(event: str, *, user_id: str | None = None) -> int:
        notifications.append((event, user_id))
        return 1

    monkeypatch.setattr(ui_app, "notify_connectors", _fake_notify)
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    # High-trust natural candidates now write directly (no proposal/inbox round-trip)
    written = client.post(
        "/v1/memory/candidate",
        headers=headers,
        json={
            "user_id": "candidate-user",
            "message": "I usually want launch readiness updates grouped as risk, decision, and next step",
            "source": "hermes_gateway_candidate",
        },
    )
    context_after = client.get("/v1/context?user_id=candidate-user", headers=headers)

    assert written.status_code in {200, 201}
    assert written.json()["status"] == "written"
    assert "Prefers launch readiness updates grouped as risk, decision, and next step." in context_after.json()[
        "system_prompt_injection"
    ]


def test_v1_memory_candidate_skips_weak_or_sensitive_message(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    weak = client.post(
        "/v1/memory/candidate",
        headers=headers,
        json={"user_id": "candidate-skip-user", "message": "Can you draft a launch update for today?"},
    )
    sensitive = client.post(
        "/v1/memory/candidate",
        headers=headers,
        json={"user_id": "candidate-skip-user", "message": "I usually want my API key copied into debug logs"},
    )
    inbox = client.get("/inbox?user_id=candidate-skip-user")

    assert weak.status_code == 200
    assert weak.json()["status"] == "skipped"
    assert sensitive.status_code == 200
    assert sensitive.json()["status"] == "skipped"
    assert "Possible memory" not in inbox.text


def test_v1_memory_candidate_reuses_existing_pending_proposal(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    payload = {
        "user_id": "candidate-repeat-user",
        "message": "I usually avoid calls before the first deep work block",
    }

    first = client.post("/v1/memory/candidate", headers=headers, json=payload)
    repeated = client.post("/v1/memory/candidate", headers=headers, json=payload)

    # High-trust candidates now write directly; repeat is caught as duplicate (409)
    assert first.status_code in {200, 201}
    assert first.json()["status"] == "written"
    assert repeated.status_code == 409
    assert repeated.json()["status"] == "duplicate"


def test_v1_memory_candidate_resends_telegram_card_for_existing_pending(fake_home, monkeypatch):
    sent_messages: list[object] = []

    def _fake_send(config, outgoing):
        sent_messages.append(outgoing)
        return {"ok": True, "result": {"chat_id": outgoing.chat_id, "text": outgoing.text}}

    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123:candidate")
    monkeypatch.setattr(ui_app, "send_telegram_message", _fake_send)
    client = TestClient(create_app())
    payload = {
        "user_id": "5989413668",
        "platform": "telegram",
        "telegram_chat_id": "5989413668",
        "message": "I usually want launch readiness updates grouped as risk, decision, and next step",
    }

    first = client.post("/v1/memory/candidate", headers={"Authorization": "Bearer local_key"}, json=payload)
    repeated = client.post("/v1/memory/candidate", headers={"Authorization": "Bearer local_key"}, json=payload)

    # High-trust candidates now write directly — no telegram card, repeat is duplicate
    assert first.status_code in {200, 201}
    assert first.json()["status"] == "written"
    assert repeated.status_code == 409
    assert repeated.json()["status"] == "duplicate"
    assert len(sent_messages) == 0  # no card sent for direct writes


def test_v1_memory_candidate_sends_telegram_save_skip_card_for_telegram_source(fake_home, monkeypatch):
    sent_messages: list[object] = []

    def _fake_send(config, outgoing):
        sent_messages.append(outgoing)
        return {"ok": True, "result": {"chat_id": outgoing.chat_id, "text": outgoing.text}}

    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123:candidate")
    monkeypatch.setattr(ui_app, "send_telegram_message", _fake_send)
    client = TestClient(create_app())
    written = client.post(
        "/v1/memory/candidate",
        headers={"Authorization": "Bearer local_key"},
        json={
            "user_id": "5989413668",
            "platform": "telegram",
            "telegram_chat_id": "5989413668",
            "message": "I usually want launch readiness updates grouped as risk, decision, and next step",
        },
    )

    # High-trust candidates now write directly — no telegram card sent
    assert written.status_code in {200, 201}
    assert written.json()["status"] == "written"
    assert len(sent_messages) == 0


def test_telegram_memory_write_notifies_connectors(fake_home, monkeypatch):
    notifications: list[tuple[str, str | None]] = []

    def _fake_notify(event: str, *, user_id: str | None = None) -> int:
        notifications.append((event, user_id))
        return 1

    monkeypatch.setattr(telegram_service, "notify_connectors", _fake_notify)

    reply = process_telegram_message(user_id="telegram-user-webhook", text="remember I prefer webhook sync")

    assert reply.memory_updated is True
    assert notifications == [("memory.created", "telegram-user-webhook")]


def test_v1_memory_write_succeeds_when_connector_push_fails(fake_home, monkeypatch):
    def _failing_notify(event: str, *, user_id: str | None = None) -> int:
        raise RuntimeError("connector down")

    monkeypatch.setattr(ui_app, "notify_connectors", _failing_notify)
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    response = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-webhook-failure", "value": "8mem writes survive webhook failure"},
    )
    context = client.get("/v1/context?user_id=runtime-user-webhook-failure", headers=headers)

    assert response.status_code == 201
    assert response.json()["status"] == "written"
    assert response.json()["connector_notifications"] == 0
    assert "8mem writes survive webhook failure" in context.json()["system_prompt_injection"]


def test_v1_memory_write_rate_limit_blocks_runaway_loop(fake_home, monkeypatch):
    monkeypatch.setattr(ui_app, "MEMORY_WRITE_RATE_LIMIT_MAX_REQUESTS", 1)
    monkeypatch.setattr(ui_app, "MEMORY_WRITE_RATE_LIMIT_WINDOW_SECONDS", 60.0)
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    first = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-rate-limit", "value": "rate limit first write"},
    )
    second = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-rate-limit", "value": "rate limit second write"},
    )

    assert first.status_code == 201
    assert second.status_code == 429
    assert second.json()["status"] == "rate_limited"
    assert "Too many memory write requests" in second.json()["error"]


def test_v1_correction_write_notifies_connectors(fake_home, monkeypatch):
    notifications: list[tuple[str, str | None]] = []

    def _fake_notify(event: str, *, user_id: str | None = None) -> int:
        notifications.append((event, user_id))
        return 1

    monkeypatch.setattr(ui_app, "notify_connectors", _fake_notify)
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    response = client.post(
        "/v1/correction",
        headers=headers,
        json={"user_id": "runtime-user-webhook", "value": "Prefer short launch updates"},
    )

    assert response.status_code == 201
    assert response.json()["connector_notifications"] == 1
    assert notifications == [("memory.corrected", "runtime-user-webhook")]


def test_v1_forget_only_notifies_connectors_after_confirmed_delete(fake_home, monkeypatch):
    notifications: list[tuple[str, str | None]] = []

    def _fake_notify(event: str, *, user_id: str | None = None) -> int:
        notifications.append((event, user_id))
        return 1

    monkeypatch.setattr(ui_app, "notify_connectors", _fake_notify)
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-webhook-forget", "category": "preference", "text": "prefer webhook tests"},
    )
    notifications.clear()

    preview = client.post(
        "/v1/forget",
        headers=headers,
        json={"user_id": "runtime-user-webhook-forget", "query": "webhook tests"},
    )
    confirmed = client.post(
        "/v1/forget",
        headers=headers,
        json={"user_id": "runtime-user-webhook-forget", "query": "webhook tests", "confirm": True},
    )

    assert preview.status_code == 200
    assert preview.json()["status"] == "needs_confirmation"
    assert "connector_notifications" not in preview.json()
    assert confirmed.status_code == 200
    assert confirmed.json()["connector_notifications"] == 1
    assert notifications == [("memory.forgotten", "runtime-user-webhook-forget")]


def test_v1_memory_accepts_openclaw_text_shape(fake_home):
    client = TestClient(create_app())

    response = client.post(
        "/v1/memory",
        headers={"Authorization": "Bearer local_key"},
        json={
            "user_id": "runtime-user-text-shape",
            "text": "Prefers Progress / Risk / Next updates",
            "source": "openclaw_viri",
            "confidence": "explicit_user_statement",
        },
    )

    assert response.status_code == 201
    payload = response.json()
    assert payload["ok"] is True
    assert payload["memory"]["id"].startswith("mem_")
    assert payload["memory"]["file"] == "PREFERENCES.md"
    assert payload["memory"]["value"] == "Prefers Progress / Risk / Next updates."
    assert payload["memory"]["source"] == "openclaw_viri"
    assert payload["memory"]["confidence"] == "explicit_user_statement"


def test_v1_memory_routes_preference_text_to_user_scoped_injection(fake_home):
    client = TestClient(create_app())

    response = client.post(
        "/v1/memory",
        headers={"Authorization": "Bearer local_key"},
        json={
            "user_id": "5989413668",
            "text": "I prefer launch updates in short bullet points",
            "source": "openclaw_viri",
            "confidence": "explicit_user_statement",
        },
    )

    assert response.status_code == 201
    payload = response.json()
    assert payload["memory"]["file"] == "PREFERENCES.md"

    context = client.get("/v1/context?user_id=5989413668", headers={"Authorization": "Bearer local_key"})
    assert context.status_code == 200
    context_payload = context.json()
    assert context_payload["identity"]["display_name"] == "User"
    assert "5989413668" not in context_payload["system_prompt_injection"]
    assert "Prefers launch updates in short bullet points" in context_payload["system_prompt_injection"]


def test_v1_memory_injects_explicit_user_facts_without_style_keywords(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    response = client.post(
        "/v1/memory",
        headers=headers,
        json={
            "user_id": "5989413668",
            "text": "I prefer restart durability proof in launch QA",
            "source": "openclaw_viri",
            "confidence": "explicit_user_statement",
        },
    )

    assert response.status_code == 201
    payload = response.json()
    assert payload["memory"]["file"] == "PREFERENCES.md"

    context = client.get("/v1/context?user_id=5989413668", headers=headers)
    assert context.status_code == 200
    injection = context.json()["system_prompt_injection"]
    assert "Saved memory:" in injection
    assert "Prefers restart durability proof in launch QA" in injection
    assert "5989413668" not in injection


def test_v1_context_injects_all_active_user_facing_beliefs(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    facts = [
        "I prefer no calls before 9am on Mondays",
        "I prefer launch QA summaries in calm plain English",
        "I work best in the mornings",
        "I prefer investor offers evaluated against control, dilution, and long-term ownership",
        "I prefer async updates before meetings",
        "I prefer customer proof before broad launch claims",
    ]

    for fact in facts:
        response = client.post(
            "/v1/memory",
            headers=headers,
            json={
                "user_id": "5989413668",
                "text": fact,
                "source": "openclaw_viri",
                "confidence": "explicit_user_statement",
            },
        )
        assert response.status_code == 201

    context = client.get("/v1/context?user_id=5989413668", headers=headers)

    assert context.status_code == 200
    payload = context.json()
    injection = payload["system_prompt_injection"]
    belief_values = {belief["value"] for belief in payload["beliefs"] if belief["status"] == "active"}
    expected_values = [
        "Prefers no calls before 9am on Mondays",
        "Prefers launch QA summaries in calm plain English",
        "I work best in the mornings",
        "Prefers investor offers evaluated against control, dilution, and long-term ownership",
        "Prefers async updates before meetings",
        "Prefers customer proof before broad launch claims",
    ]
    for value in expected_values:
        assert value in belief_values
        assert value in injection


def test_v1_memory_rejects_conflicting_runtime_write(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    first = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-conflict", "category": "preference", "value": "keep answers concise"},
    )
    second = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-conflict", "category": "preference", "value": "give detailed explanations"},
    )

    assert first.status_code == 201
    assert second.status_code == 409
    payload = second.json()
    assert payload["error"] == "Memory conflict detected"
    assert payload["conflict"]["value"] == "Keep answers concise."


def test_v1_memory_rejects_conflicting_live_beliefs_not_evolution_history(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    first = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-belief-conflict", "text": "Prefers short answers"},
    )
    second = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-belief-conflict", "text": "Prefers long detailed answers"},
    )

    assert first.status_code == 201
    assert second.status_code == 409
    payload = second.json()
    assert payload["conflict"]["file_name"] == "PREFERENCES.md"
    assert payload["conflict"]["value"].rstrip(".") == "Prefers short answers"


def test_v1_memory_rejects_conflicting_favourite_colour_write(fake_home):
    client = TestClient(create_app())
    created = client.post(
        "/v1/memory",
        headers={"Authorization": "Bearer local_key"},
        json={"user_id": "runtime-user-colour-conflict", "text": "favourite colour is white"},
    )
    conflict = client.post(
        "/v1/memory",
        headers={"Authorization": "Bearer local_key"},
        json={"user_id": "runtime-user-colour-conflict", "text": "my favorite color is blue"},
    )

    assert created.status_code == 201
    assert conflict.status_code == 409
    payload = conflict.json()
    assert payload["status"] == "conflict"
    assert payload["conflict"]["value"].rstrip(".").lower() == "favourite colour is white"
    assert payload["conflicting_belief"]["value"].rstrip(".").lower() == "favourite colour is white"
    assert payload["conflicting_belief"]["confidence"] == "explicit_user_statement"
    assert payload["conflicting_belief"]["id"].startswith("mem_")


def test_v1_memory_ignores_deleted_evolution_history_for_conflict_detection(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    user_id = "runtime-user-evolution-history"
    client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": user_id, "text": "Prefers short answers"},
    )
    deleted = client.post(
        "/v1/forget",
        headers=headers,
        json={"user_id": user_id, "query": "short answers", "confirm": True},
    )
    rewrite = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": user_id, "text": "Prefers long detailed answers"},
    )

    assert deleted.status_code == 200
    assert deleted.json()["status"] == "deleted"
    assert rewrite.status_code == 201
    assert rewrite.json()["memory"]["value"] == "Prefers long detailed answers."


def test_v1_correction_archives_old_value_and_changes_endpoint_returns_event(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-correction", "category": "preference", "value": "use emojis"},
    )

    correction = client.post(
        "/v1/correction",
        headers=headers,
        json={
            "user_id": "runtime-user-correction",
            "category": "communication style",
            "value": "do not use emojis",
            "source_message": "OpenClaw user correction: do not use emojis",
        },
    )

    assert correction.status_code == 201
    event = correction.json()["correction"]
    assert event["old_value"] == "Use emojis."
    assert event["new_value"] == "Do not use emojis."

    changes = client.get(
        "/v1/changes?user_id=runtime-user-correction",
        headers=headers,
    )

    assert changes.status_code == 200
    payload = changes.json()
    assert payload["ok"] is True
    assert payload["count"] >= 1
    assert any(item["action"] == "trusted_memory_change" for item in payload["changes"])


def test_v1_correction_accepts_openclaw_text_and_old_text_shape(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-correction-text", "category": "preference", "text": "use motivational language"},
    )

    correction = client.post(
        "/v1/correction",
        headers=headers,
        json={
            "user_id": "runtime-user-correction-text",
            "text": "Do not assume I want motivational language",
            "source": "openclaw_viri",
            "old_text": "Use motivational language.",
        },
    )

    assert correction.status_code == 201
    event = correction.json()["correction"]
    assert event["old_value"] == "Use motivational language."
    assert event["new_value"] == "Do not assume I want motivational language."


def test_v1_correction_routes_timezone_to_identity(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    user_id = "runtime-user-timezone-correction"

    correction = client.post(
        "/v1/correction",
        headers=headers,
        json={
            "user_id": user_id,
            "text": "Timezone: Asia/Singapore",
            "source": "openclaw_viri",
        },
    )

    assert correction.status_code == 201
    event = correction.json()["correction"]
    assert event["new_file"] == "IDENTITY.md"
    assert event["new_value"] == "Timezone: Asia/Singapore"

    context = client.get(f"/v1/context?user_id={user_id}", headers=headers)

    assert context.status_code == 200
    assert context.json()["identity"]["timezone"] == "Asia/Singapore"
    assert "User is runtime-user-timezone-correction (Asia/Singapore)." in context.json()["system_prompt_injection"]


def test_v1_correction_supersedes_semantic_theme_preference(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    user_id = "runtime-user-theme-correction"
    client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": user_id, "text": "I prefer dark mode"},
    )

    correction = client.post(
        "/v1/correction",
        headers=headers,
        json={
            "user_id": user_id,
            "text": "Prefers light mode",
            "source": "openclaw_viri",
            "old_text": "I prefer dark mode.",
        },
    )

    assert correction.status_code == 201
    event = correction.json()["correction"]
    assert event["old_value"] == "Prefers dark mode."
    assert event["new_value"] == "Prefers light mode."

    context = client.get(f"/v1/context?user_id={user_id}", headers=headers)
    payload = context.json()
    active_values = [item["value"] for item in payload["beliefs"]]
    assert "Prefers light mode" in active_values
    assert "Prefers dark mode" not in active_values
    assert "Prefers light mode." in payload["system_prompt_injection"]
    assert "Prefers dark mode" not in payload["system_prompt_injection"]


def test_v1_context_does_not_inject_audit_only_latest_correction(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    correction = client.post(
        "/v1/correction",
        headers=headers,
        json={
            "user_id": "audit-only-correction-user",
            "text": "Use a temporary launch-test correction",
            "source": "openclaw_viri",
        },
    )
    deleted = client.post(
        "/v1/forget",
        headers=headers,
        json={
            "user_id": "audit-only-correction-user",
            "query": "temporary launch-test correction",
            "file": "CORRECTIONS.md",
            "confirm": True,
        },
    )

    assert correction.status_code == 201
    assert deleted.status_code == 200
    context = client.get("/v1/context?user_id=audit-only-correction-user", headers=headers)
    injection = context.json()["system_prompt_injection"]
    assert "temporary launch-test correction" not in injection
    assert "Latest correction:" not in injection


def test_v1_context_does_not_inject_deleted_memory_audit_as_project_context(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    write = client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "audit-delete-user", "text": "I prefer key-resolved Viri memory proof"},
    )
    deleted = client.post(
        "/v1/forget",
        headers=headers,
        json={"user_id": "audit-delete-user", "query": "key-resolved Viri memory proof", "confirm": True},
    )

    assert write.status_code == 201
    assert deleted.status_code == 200
    context = client.get("/v1/context?user_id=audit-delete-user", headers=headers)
    injection = context.json()["system_prompt_injection"]
    assert "key-resolved Viri memory proof" not in injection
    assert "Deleted memory from" not in injection


def test_v1_forget_previews_then_deletes_with_audit(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    client.post(
        "/v1/memory",
        headers=headers,
        json={"user_id": "runtime-user-forget", "category": "preference", "text": "give long-form answers"},
    )

    preview = client.post(
        "/v1/forget",
        headers=headers,
        json={"user_id": "runtime-user-forget", "query": "long-form answers", "source": "openclaw_viri"},
    )

    assert preview.status_code == 200
    preview_payload = preview.json()
    assert preview_payload["status"] == "needs_confirmation"
    assert preview_payload["count"] == 1
    assert preview_payload["candidates"][0]["value"] == "Give long-form answers."
    assert "file" not in preview_payload["candidates"][0]
    assert "PREFERENCES.md" not in str(preview_payload["candidates"][0])

    deleted = client.post(
        "/v1/forget",
        headers=headers,
        json={
            "user_id": "runtime-user-forget",
            "query": "long-form answers",
            "source": "openclaw_viri",
            "confirm": True,
        },
    )

    assert deleted.status_code == 200
    deleted_payload = deleted.json()
    assert deleted_payload["status"] == "deleted"
    assert deleted_payload["count"] == 1
    assert deleted_payload["deleted"][0]["value"] == "Give long-form answers."
    assert "file" not in deleted_payload["deleted"][0]
    assert "PREFERENCES.md" not in str(deleted_payload["deleted"][0])

    context = client.get("/v1/context?user_id=runtime-user-forget", headers=headers)
    assert "Give long-form answers" not in context.json()["system_prompt_injection"]

    changes = client.get("/v1/changes?user_id=runtime-user-forget", headers=headers)
    assert any(item["action"] == "forget_memory" for item in changes.json()["changes"])


def test_v1_approval_create_poll_and_approve(fake_home, monkeypatch):
    sent_messages: list[object] = []

    def _fake_send(config, outgoing):
        sent_messages.append(outgoing)
        return {"ok": True, "result": {"chat_id": outgoing.chat_id, "text": outgoing.text}}

    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123:abc")
    monkeypatch.setattr(ui_app, "send_telegram_message", _fake_send)
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    created = client.post(
        "/v1/approval",
        headers=headers,
        json={
            "user_id": "runtime-user-approval",
            "action_type": "email_send",
            "summary": "Send investor update to test@example.com",
            "payload": {"to": "test@example.com", "subject": "Phase 1 update"},
            "ttl_seconds": 600,
        },
    )

    assert created.status_code == 201
    approval = created.json()["approval"]
    approval_id = approval["approval_id"]
    assert approval["status"] == "pending"
    assert approval["action_type"] == "email_send"
    assert "Approval requested." in approval["prompt"]
    assert created.json()["telegram_notification"]["attempted"] is True
    assert sent_messages[-1].chat_id == "runtime-user-approval"
    assert sent_messages[-1].action == "approval_notification"
    assert "Approval needed" in sent_messages[-1].text
    assert "Action: Send email" in sent_messages[-1].text
    assert sent_messages[-1].reply_keyboard[0][0]["callback_data"] == f"approval:approved:{approval_id}"
    assert sent_messages[-1].reply_keyboard[0][2]["callback_data"] == f"approval:edit:{approval_id}"

    polled = client.get(
        f"/v1/approval/{approval_id}?user_id=runtime-user-approval",
        headers=headers,
    )

    assert polled.status_code == 200
    assert polled.json()["approval"]["status"] == "pending"

    resolved = client.post(
        f"/v1/approval/{approval_id}",
        headers=headers,
        json={
            "user_id": "runtime-user-approval",
            "decision": "approved",
            "resolved_by": "ashish",
            "reason": "Looks correct",
        },
    )

    assert resolved.status_code == 200
    resolved_payload = resolved.json()["approval"]
    assert resolved_payload["status"] == "approved"
    assert resolved_payload["resolved_by"] == "ashish"
    assert resolved_payload["reason"] == "Looks correct"


def test_v1_approval_notification_uses_payload_telegram_chat_id(fake_home, monkeypatch):
    sent_messages: list[object] = []

    def _fake_send(config, outgoing):
        sent_messages.append(outgoing)
        return {"ok": True, "result": {"chat_id": outgoing.chat_id, "text": outgoing.text}}

    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123:abc")
    monkeypatch.setattr(ui_app, "send_telegram_message", _fake_send)
    client = TestClient(create_app())

    created = client.post(
        "/v1/approval",
        headers={"Authorization": "Bearer local_key"},
        json={
            "user_id": "ashish",
            "action_type": "email_send",
            "summary": "Send approval test email",
            "payload": {"telegram_chat_id": "5989413668", "to": "test@example.com"},
        },
    )

    assert created.status_code == 201
    assert created.json()["telegram_notification"]["ok"] is True
    assert created.json()["telegram_notification"]["chat_id"] == "5989413668"
    assert sent_messages[-1].chat_id == "5989413668"
    assert sent_messages[-1].user_id == "ashish"


def test_v1_approval_notification_uses_env_chat_id_fallback(fake_home, monkeypatch):
    sent_messages: list[object] = []

    def _fake_send(config, outgoing):
        sent_messages.append(outgoing)
        return {"ok": True, "result": {"chat_id": outgoing.chat_id, "text": outgoing.text}}

    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123:abc")
    monkeypatch.setenv("EIGHTMEM_TELEGRAM_APPROVAL_CHAT_ID", "777")
    monkeypatch.setattr(ui_app, "send_telegram_message", _fake_send)
    client = TestClient(create_app())

    created = client.post(
        "/v1/approval",
        headers={"Authorization": "Bearer local_key"},
        json={
            "user_id": "ashish",
            "action_type": "email_send",
            "summary": "Send approval test email",
        },
    )

    assert created.status_code == 201
    assert created.json()["telegram_notification"]["chat_id"] == "777"
    assert sent_messages[-1].chat_id == "777"


def test_v1_approval_notification_uses_payload_bot_token(fake_home, monkeypatch):
    sent_configs: list[object] = []
    sent_messages: list[object] = []

    def _fake_send(config, outgoing):
        sent_configs.append(config)
        sent_messages.append(outgoing)
        return {"ok": True, "result": {"chat_id": outgoing.chat_id, "text": outgoing.text}}

    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "111:default")
    monkeypatch.setattr(ui_app, "send_telegram_message", _fake_send)
    client = TestClient(create_app())

    created = client.post(
        "/v1/approval",
        headers={"Authorization": "Bearer local_key"},
        json={
            "user_id": "govi",
            "action_type": "email_send",
            "summary": "Send Govi approval test email",
            "payload": {
                "telegram_chat_id": "5989413668",
                "telegram_bot_token": "222:hermes",
            },
        },
    )

    assert created.status_code == 201
    assert created.json()["telegram_notification"]["chat_id"] == "5989413668"
    assert sent_configs[-1].bot_token == "222:hermes"
    assert sent_messages[-1].chat_id == "5989413668"


def test_v1_approval_notification_reports_telegram_error(fake_home, monkeypatch):
    def _fake_send(config, outgoing):
        return {"ok": False, "description": "Bad Request: chat not found", "result": None}

    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123:abc")
    monkeypatch.setattr(ui_app, "send_telegram_message", _fake_send)
    client = TestClient(create_app())

    created = client.post(
        "/v1/approval",
        headers={"Authorization": "Bearer local_key"},
        json={
            "user_id": "ashish",
            "action_type": "email_send",
            "summary": "Send approval test email",
        },
    )

    assert created.status_code == 201
    notification = created.json()["telegram_notification"]
    assert notification["attempted"] is True
    assert notification["ok"] is False
    assert notification["error"] == "Bad Request: chat not found"
    assert notification["bot_token_prefix"] == "123:abc"
    assert notification["telegram_error"]["description"] == "Bad Request: chat not found"


def test_v1_approval_notification_can_use_approval_bot_token_override(fake_home, monkeypatch):
    observed_tokens: list[str] = []

    def _fake_send(config, outgoing):
        observed_tokens.append(config.bot_token)
        return {"ok": True, "result": {"chat_id": outgoing.chat_id, "text": outgoing.text}}

    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123:main")
    monkeypatch.setenv("EIGHTMEM_APPROVAL_TELEGRAM_BOT_TOKEN", "456:approval")
    monkeypatch.setenv("EIGHTMEM_TELEGRAM_APPROVAL_CHAT_ID", "777")
    monkeypatch.setattr(ui_app, "send_telegram_message", _fake_send)
    client = TestClient(create_app())

    created = client.post(
        "/v1/approval",
        headers={"Authorization": "Bearer local_key"},
        json={"action_type": "email_send", "summary": "Send approval test email"},
    )

    assert created.status_code == 201
    assert observed_tokens == ["456:approval"]


def test_v1_approval_can_poll_and_resolve_user_scoped_request_by_id_only(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    created = client.post(
        "/v1/approval",
        headers=headers,
        json={
            "user_id": "runtime-user-approval",
            "action_type": "email_send",
            "summary": "Send approval test email",
        },
    )
    approval_id = created.json()["approval"]["approval_id"]

    polled = client.get(f"/v1/approval/{approval_id}", headers=headers)
    resolved = client.post(f"/v1/approval/{approval_id}", headers=headers, json={"decision": "approved"})

    assert polled.status_code == 200
    assert polled.json()["approval"]["status"] == "pending"
    assert resolved.status_code == 200
    assert resolved.json()["approval"]["status"] == "approved"


def test_v1_approvals_lists_pending_and_filters_by_status(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    pending = client.post(
        "/v1/approval",
        headers=headers,
        json={"user_id": "approval-list-user", "action_type": "email_send", "summary": "Send email"},
    ).json()["approval"]
    resolved = client.post(
        "/v1/approval",
        headers=headers,
        json={"user_id": "approval-list-user", "action_type": "public_post", "summary": "Post update"},
    ).json()["approval"]
    client.post(
        f"/v1/approval/{resolved['approval_id']}",
        headers=headers,
        json={"user_id": "approval-list-user", "decision": "denied", "reason": "Not ready"},
    )

    all_items = client.get("/v1/approvals?user_id=approval-list-user", headers=headers)
    pending_items = client.get("/v1/approvals?user_id=approval-list-user&status=pending", headers=headers)
    denied_items = client.get("/v1/approvals?user_id=approval-list-user&status=denied", headers=headers)

    assert all_items.status_code == 200
    assert all_items.json()["total"] == 2
    assert pending_items.json()["approvals"][0]["approval_id"] == pending["approval_id"]
    assert denied_items.json()["approvals"][0]["reason"] == "Not ready"


def test_v1_approvals_without_user_id_lists_user_scoped_requests_after_reload(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    created = client.post(
        "/v1/approval",
        headers=headers,
        json={"user_id": "ashish", "action_type": "email_send", "summary": "Send email"},
    ).json()["approval"]

    reloaded_client = TestClient(create_app())
    listed = reloaded_client.get("/v1/approvals?status=pending", headers=headers)

    assert listed.status_code == 200
    assert listed.json()["total"] == 1
    assert listed.json()["approvals"][0]["approval_id"] == created["approval_id"]


def test_v1_approval_rejects_double_resolution(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    created = client.post(
        "/v1/approval",
        headers=headers,
        json={"action_type": "public_post", "summary": "Post launch note"},
    )
    approval_id = created.json()["approval"]["approval_id"]
    first = client.post(f"/v1/approval/{approval_id}", headers=headers, json={"decision": "denied"})
    second = client.post(f"/v1/approval/{approval_id}", headers=headers, json={"decision": "approved"})

    assert first.status_code == 200
    assert first.json()["approval"]["status"] == "denied"
    assert second.status_code == 409
    assert second.json()["error"] == "Approval is already resolved"
    assert second.json()["approval"]["status"] == "denied"


def test_v1_approval_rejects_expired_resolution_and_filters_pending(fake_home):
    now = datetime(2020, 1, 1, 8, 0, tzinfo=timezone.utc)
    approval = create_approval_request(
        user_id="approval-expiry-user",
        action_type="file_delete",
        summary="Delete local file",
        ttl_seconds=30,
        now=now,
    )
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    polled = client.get(f"/v1/approval/{approval.approval_id}?user_id=approval-expiry-user", headers=headers)
    pending = client.get("/v1/approvals?user_id=approval-expiry-user&status=pending", headers=headers)
    expired = client.get("/v1/approvals?user_id=approval-expiry-user&status=expired", headers=headers)
    resolved = client.post(
        f"/v1/approval/{approval.approval_id}",
        headers=headers,
        json={"user_id": "approval-expiry-user", "decision": "approved"},
    )

    assert polled.status_code == 200
    assert polled.json()["approval"]["status"] == "expired"
    assert pending.status_code == 200
    assert pending.json()["approvals"] == []
    assert expired.status_code == 200
    assert expired.json()["approvals"][0]["approval_id"] == approval.approval_id
    assert resolved.status_code == 409
    assert resolved.json()["error"] == "Approval is already resolved"
    assert resolved.json()["approval"]["status"] == "expired"


def test_v1_approval_resolve_accepts_edit_decision(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}
    approval_id = client.post(
        "/v1/approval",
        headers=headers,
        json={"user_id": "ashish", "action_type": "email_send", "summary": "Send email"},
    ).json()["approval"]["approval_id"]

    resolved = client.post(
        f"/v1/approval/{approval_id}",
        headers=headers,
        json={"user_id": "ashish", "decision": "edit", "resolved_by": "telegram"},
    )

    assert resolved.status_code == 200
    assert resolved.json()["approval"]["status"] == "denied"
    assert resolved.json()["approval"]["reason"] == "User requested edit"


def test_v1_approval_validates_required_fields(fake_home):
    client = TestClient(create_app())
    headers = {"Authorization": "Bearer local_key"}

    missing_action = client.post("/v1/approval", headers=headers, json={"summary": "Send email"})
    missing_summary = client.post("/v1/approval", headers=headers, json={"action_type": "email_send"})
    bad_payload = client.post(
        "/v1/approval",
        headers=headers,
        json={"action_type": "email_send", "summary": "Send email", "payload": "bad"},
    )
    bad_decision = client.post("/v1/approval/missing", headers=headers, json={"decision": "maybe"})

    assert missing_action.status_code == 400
    assert missing_summary.status_code == 400
    assert bad_payload.status_code == 400
    assert bad_decision.status_code == 400


def test_v1_context_without_user_id_uses_default_local_memory(fake_home):
    process_telegram_message(user_id=None, text="Remember: keep answers concise")
    client = TestClient(create_app())

    response = client.get("/v1/context", headers={"Authorization": "Bearer local_key"})

    assert response.status_code == 200
    payload = response.json()
    assert payload["subject"] == "local-user"
    assert "Prefers short, direct replies." in payload["system_prompt_injection"]
    assert "Prefers Prefer" not in payload["system_prompt_injection"]


def test_v1_context_injects_project_truth_to_prevent_engram_hallucination(fake_home):
    client = TestClient(create_app())
    client.post(
        "/files/IDENTITY.md",
        data={
            "content": (
                "# IDENTITY\n\n"
                "Who the user is and what they are focused on.\n\n"
                "## Entries\n\n"
                "- Ashish is building 8mem, Engram, and the OpenClaw integration.\n"
            )
        },
        follow_redirects=False,
    )
    client.post(
        "/files/BELIEFS.md",
        data={
            "content": (
                "# BELIEFS\n\n"
                "Durable beliefs about the user's goals and operating principles.\n\n"
                "## Entries\n\n"
                "- Engram v0.1 is the open JSON context interchange spec for portable AI memory.\n"
                "- OpenClaw integration uses heartbeat to fetch 8mem /v1/context and writes memory/8mem-context.md.\n"
            )
        },
        follow_redirects=False,
    )

    response = client.get("/v1/context", headers={"Authorization": "Bearer local_key"})

    assert response.status_code == 200
    injection = response.json()["system_prompt_injection"]
    assert "Confirmed project context:" in injection
    assert "8mem is an AI memory layer" in injection
    assert "Engram v0.1 is an open JSON/context interchange spec" in injection
    assert "not a vector database" in injection
    assert "the connected agent uses 8mem memory so saved preferences and corrections carry across sessions." in injection
    assert "heartbeat" not in injection
    assert "memory/8mem-context.md" not in injection
    assert "write-back from OpenClaw to 8mem are not implemented yet" not in injection
    assert "Do not infer Engram is a vector database or suggest vector-store work unless explicitly asked." in injection


def test_v1_context_uses_configured_agent_name_for_openclaw_project_fact(fake_home, monkeypatch):
    monkeypatch.setenv("EIGHTMEM_AGENT_NAME", "OpenCore")
    client = TestClient(create_app())
    client.post(
        "/files/BELIEFS.md",
        data={
            "content": (
                "# BELIEFS\n\n"
                "Durable beliefs about the user's goals and operating principles.\n\n"
                "## Entries\n\n"
                "- OpenClaw integration uses heartbeat to fetch 8mem /v1/context and writes memory/8mem-context.md.\n"
            )
        },
        follow_redirects=False,
    )

    response = client.get("/v1/context", headers={"Authorization": "Bearer local_key"})

    assert response.status_code == 200
    injection = response.json()["system_prompt_injection"]
    assert "OpenCore uses 8mem memory so saved preferences and corrections carry across sessions" in injection
    assert "Viri uses 8mem memory so saved preferences and corrections carry across sessions" not in injection
    assert "heartbeat" not in injection


def test_v1_context_does_not_inject_unrelated_low_value_memory_as_project_context(fake_home):
    client = TestClient(create_app())
    client.post(
        "/files/PREFERENCES.md",
        data={
            "content": (
                "# PREFERENCES\n\n"
                "Stable response and formatting preferences.\n\n"
                "## Entries\n\n"
                "- Likes green tea.\n"
                "- Keep answers concise.\n"
            )
        },
        follow_redirects=False,
    )

    response = client.get("/v1/context", headers={"Authorization": "Bearer local_key"})

    assert response.status_code == 200
    injection = response.json()["system_prompt_injection"]
    assert "Confirmed project context:" not in injection
    assert "Likes green tea" not in injection
    assert "Prefers short, direct replies." in injection


def test_dashboard_and_mirror_show_resolution_actions_for_tensions(fake_home):
    client = TestClient(create_app())
    client.post("/files/PREFERENCES.md", data={"content": "# PREFERENCES\n\nStable response and formatting preferences.\n\n## Entries\n\n- Use emojis.\n"}, follow_redirects=False)
    client.post("/files/CORRECTIONS.md", data={"content": "# CORRECTIONS\n\nPersistent corrections made by the user.\n\n## Entries\n\n- do not use emojis\n"}, follow_redirects=False)

    dashboard = client.get("/")
    mirror = client.get("/mirror")
    inbox = client.get("/inbox")

    assert "Resolve contradictions" in dashboard.text
    assert "Resolve emoji guidance" in dashboard.text
    assert "Edit corrections" in dashboard.text
    assert "Resolve contradictions" in mirror.text
    assert "Memory Inbox" in inbox.text
    assert "Keep no emojis" in inbox.text


def test_inbox_resolution_can_keep_correction_and_clear_emoji_tension(fake_home):
    client = TestClient(create_app())
    client.post("/files/PREFERENCES.md", data={"content": "# PREFERENCES\n\nStable response and formatting preferences.\n\n## Entries\n\n- Use emojis.\n"}, follow_redirects=False)
    client.post("/files/CORRECTIONS.md", data={"content": "# CORRECTIONS\n\nPersistent corrections made by the user.\n\n## Entries\n\n- do not use emojis\n"}, follow_redirects=False)

    resolve = client.post(
        "/inbox/resolve",
        data={"item_id": "emoji_guidance", "decision": "keep_correction"},
        follow_redirects=False,
    )

    assert resolve.status_code == 303
    assert resolve.headers["location"] == "/inbox"

    prefs = (memory_dir() / "PREFERENCES.md").read_text(encoding="utf-8").lower()
    assert "use emojis" not in prefs

    dashboard = client.get("/")
    assert "Resolve emoji guidance" not in dashboard.text


def test_inbox_resolution_can_keep_concise_and_clear_detail_conflict(fake_home):
    client = TestClient(create_app())
    client.post(
        "/files/PREFERENCES.md",
        data={"content": "# PREFERENCES\n\nStable response and formatting preferences.\n\n## Entries\n\n- Keep answers concise.\n- Give detailed explanations.\n"},
        follow_redirects=False,
    )

    inbox = client.get("/inbox")
    assert "Keep concise" in inbox.text
    assert "Keep detailed" in inbox.text

    resolve = client.post(
        "/inbox/resolve",
        data={"item_id": "detail_conflict", "decision": "keep_concise"},
        follow_redirects=False,
    )

    assert resolve.status_code == 303
    prefs = (memory_dir() / "PREFERENCES.md").read_text(encoding="utf-8").lower()
    assert "keep answers concise." in prefs
    assert "give detailed explanations." not in prefs


def test_inbox_resolution_can_keep_bullets_and_clear_format_conflict(fake_home):
    client = TestClient(create_app())
    client.post(
        "/files/PREFERENCES.md",
        data={"content": "# PREFERENCES\n\nStable response and formatting preferences.\n\n## Entries\n\n- Prefers short structured updates with bullets.\n- Write in long narrative paragraphs.\n"},
        follow_redirects=False,
    )

    inbox = client.get("/inbox")
    assert "Keep bullets" in inbox.text
    assert "Keep long-form" in inbox.text

    resolve = client.post(
        "/inbox/resolve",
        data={"item_id": "format_conflict", "decision": "keep_bullets"},
        follow_redirects=False,
    )

    assert resolve.status_code == 303
    prefs = (memory_dir() / "PREFERENCES.md").read_text(encoding="utf-8").lower()
    assert "structured updates with bullets" in prefs
    assert "write in long narrative paragraphs" not in prefs


def test_dashboard_can_undo_last_memory_change(fake_home):
    client = TestClient(create_app())
    client.post(
        "/files/PREFERENCES.md",
        data={"content": "# PREFERENCES\n\nStable response and formatting preferences.\n\n## Entries\n\n- Keep answers concise.\n"},
        follow_redirects=False,
    )

    dashboard = client.get("/")
    assert "Undo last memory change" in dashboard.text

    resolve = client.post("/memory/undo", follow_redirects=False)
    assert resolve.status_code == 303
    assert resolve.headers["location"] == "/"

    prefs = (memory_dir() / "PREFERENCES.md").read_text(encoding="utf-8").lower()
    assert "keep answers concise." not in prefs


def test_undo_can_restore_previous_state_after_reset(fake_home):
    client = TestClient(create_app())
    client.post(
        "/files/PREFERENCES.md",
        data={"content": "# PREFERENCES\n\nStable response and formatting preferences.\n\n## Entries\n\n- Keep answers concise.\n"},
        follow_redirects=False,
    )

    from eightmem.services.memory_service import reset_memory_runtime

    reset_memory_runtime()

    dashboard = client.get("/")
    assert "Undo last memory change" in dashboard.text

    undo = client.post("/memory/undo", follow_redirects=False)
    assert undo.status_code == 303

    prefs = (memory_dir() / "PREFERENCES.md").read_text(encoding="utf-8").lower()
    assert "keep answers concise." in prefs


def test_health_and_readiness_routes(fake_home, monkeypatch):
    monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
    monkeypatch.setattr(ui_app, "check_ollama_backend", lambda: (False, "backend unavailable"))
    monkeypatch.setattr(ui_app, "resolve_base_url", lambda: "http://localhost:11434")
    monkeypatch.setattr(ui_app, "resolve_default_model", lambda: "qwen2.5:14b")
    client = TestClient(create_app())

    health = client.get("/healthz")
    assert health.status_code == 200
    assert health.json() == {"ok": True, "status": "healthy"}

    ready = client.get("/readyz")
    assert ready.status_code == 200
    body = ready.json()
    assert body["ok"] is True
    assert body["status"] == "ready"
    assert "IDENTITY.md" in body["memory_files"]
    assert body["telegram_configured"] is False
    assert body["llm_backend_available"] is False
    assert body["llm_backend_error"] == "backend unavailable"
    assert body["llm_base_url"] == "http://localhost:11434"
    assert body["llm_default_model"] == "qwen2.5:14b"


def test_telegram_webhook_rejects_duplicate_update(fake_home, monkeypatch):
    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123:abc")
    monkeypatch.setattr(
        ui_app,
        "send_telegram_message",
        lambda config, outgoing: {"ok": True},
    )
    monkeypatch.setattr(
        ui_app,
        "process_telegram_update",
        lambda payload: type(
            "_Msg",
            (),
            {
                "chat_id": "777",
                "text": "Saved. I'll use that.",
                "user_id": "123",
                "update_id": 2,
                "action": "save_preference",
                "memory_updated": True,
            },
        )(),
    )
    client = TestClient(create_app())

    first = client.post("/channels/telegram/webhook", json={"update_id": 2, "message": {"chat": {"id": 777}, "from": {"id": 123}, "text": "Remember: keep answers concise"}})
    second = client.post("/channels/telegram/webhook", json={"update_id": 2, "message": {"chat": {"id": 777}, "from": {"id": 123}, "text": "Remember: keep answers concise"}})

    assert first.status_code == 200
    assert second.status_code == 200
    assert second.json()["ignored"] is True
    assert second.json()["duplicate"] is True


def test_telegram_webhook_rate_limits_user(fake_home, monkeypatch):
    monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "123:abc")
    monkeypatch.setattr(
        ui_app,
        "send_telegram_message",
        lambda config, outgoing: {"ok": True},
    )
    counter = {"update_id": 0}

    def _fake_process(payload):
        counter["update_id"] += 1
        return type(
            "_Msg",
            (),
            {
                "chat_id": "777",
                "text": "Saved. I'll use that.",
                "user_id": "123",
                "update_id": counter["update_id"],
                "action": "save_preference",
                "memory_updated": True,
            },
        )()

    monkeypatch.setattr(ui_app, "process_telegram_update", _fake_process)
    client = TestClient(create_app())

    responses = deque()
    for _ in range(ui_app.TELEGRAM_RATE_LIMIT_MAX_REQUESTS + 1):
        responses.append(
            client.post(
                "/channels/telegram/webhook",
                json={"update_id": 2, "message": {"chat": {"id": 777}, "from": {"id": 123}, "text": "Remember: keep answers concise"}},
            )
        )

    assert responses[-1].status_code == 429
    assert responses[-1].json()["error"] == "Rate limit exceeded"


def test_file_editor_and_save_route(fake_home):
    client = TestClient(create_app())
    client.get("/")  # ensure templates exist

    edit = client.get("/files/IDENTITY.md")
    assert edit.status_code == 200
    assert "Edit Memory" in edit.text
    assert "IDENTITY.md" in edit.text

    new_content = "# IDENTITY\n\nWho the user is.\n\n## Entries\n\n- test identity entry\n"
    save = client.post("/files/IDENTITY.md", data={"content": new_content}, follow_redirects=False)
    assert save.status_code == 303
    assert save.headers["location"] == "/files/IDENTITY.md"

    stored = (memory_dir() / "IDENTITY.md").read_text(encoding="utf-8")
    assert "test identity entry" in stored


def test_missing_file_routes_redirect(fake_home):
    client = TestClient(create_app())

    missing_get = client.get("/files/NOPE.md", follow_redirects=False)
    assert missing_get.status_code == 303
    assert missing_get.headers["location"] == "/files"

    missing_post = client.post(
        "/files/NOPE.md",
        data={"content": "ignored"},
        follow_redirects=False,
    )
    assert missing_post.status_code == 303
    assert missing_post.headers["location"] == "/files"


def test_analyze_upload_route(fake_home):
    client = TestClient(create_app())
    payload = (
        b'[{"role":"user","content":"I am a release manager. '
        b'Please keep answers short and practical."}]'
    )

    upload = client.post(
        "/analyze-upload",
        files={"file": ("chat.json", payload, "application/json")},
        follow_redirects=False,
    )
    assert upload.status_code == 303
    assert upload.headers["location"] == "/import?status=analyzed"

    identity = (memory_dir() / "IDENTITY.md").read_text(encoding="utf-8").lower()
    prefs = (memory_dir() / "PREFERENCES.md").read_text(encoding="utf-8").lower()
    assert "release manager" in identity
    assert "keep answers short and practical." in prefs


def test_analyze_upload_removes_temp_file_on_error(fake_home, monkeypatch):
    client = TestClient(create_app())

    def _fail_analysis(path, memory_dir):
        assert path.exists()
        raise RuntimeError("analysis failed")

    monkeypatch.setattr(ui_app, "analyze_chat_export", _fail_analysis)

    try:
        client.post(
            "/analyze-upload",
            files={"file": ("chat.json", b"bad import", "application/json")},
            follow_redirects=False,
        )
    except RuntimeError as exc:
        assert str(exc) == "analysis failed"
    else:
        raise AssertionError("analyze-upload should propagate analysis failures")

    assert not (memory_dir() / "_upload_chat.json").exists()
