# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""Tests for Smart Intake skill — vision, classification, routing, history, and main pipeline."""

import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

# ============================================================
# Vision Module Tests
# ============================================================


class TestVisionModels:
    """Test vision model detection and image encoding."""

    def test_image_extensions(self):
        from familiar.skills.intake.vision import IMAGE_EXTENSIONS

        assert ".png" in IMAGE_EXTENSIONS
        assert ".jpg" in IMAGE_EXTENSIONS
        assert ".jpeg" in IMAGE_EXTENSIONS
        assert ".webp" in IMAGE_EXTENSIONS
        assert ".pdf" not in IMAGE_EXTENSIONS

    def test_vision_model_list(self):
        from familiar.skills.intake.vision import VISION_MODELS

        assert len(VISION_MODELS) >= 4
        assert "qwen2.5-vl" in VISION_MODELS
        assert "llava" in VISION_MODELS

    def test_encode_image_base64(self):
        from familiar.skills.intake.vision import encode_image_base64

        with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
            f.write(b"\x89PNG\r\n\x1a\ntest image data")
            f.flush()
            result = encode_image_base64(Path(f.name))
            assert isinstance(result, str)
            assert len(result) > 0
            os.unlink(f.name)

    def test_detect_vision_model_no_ollama(self):
        from familiar.skills.intake.vision import clear_vision_cache, detect_vision_model

        clear_vision_cache()
        with patch("httpx.get", side_effect=Exception("no ollama")):
            with patch("shutil.which", return_value=None):
                result = detect_vision_model(auto_pull=False)
                assert result is None
        clear_vision_cache()

    def test_detect_vision_model_found(self):
        from familiar.skills.intake.vision import clear_vision_cache, detect_vision_model

        clear_vision_cache()
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "models": [
                {"name": "llama3.2:latest"},
                {"name": "llava:latest"},
            ]
        }
        with patch("httpx.get", return_value=mock_resp):
            result = detect_vision_model()
            assert result == "llava"
        clear_vision_cache()

    def test_detect_vision_model_none_available(self):
        from familiar.skills.intake.vision import clear_vision_cache, detect_vision_model

        clear_vision_cache()
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "models": [{"name": "llama3.2:latest"}, {"name": "qwen2.5:3b"}]
        }
        with patch("httpx.get", return_value=mock_resp):
            result = detect_vision_model(auto_pull=False)
            assert result is None
        clear_vision_cache()

    def test_extract_with_vision_no_model(self):
        from familiar.skills.intake.vision import clear_vision_cache, extract_with_vision

        clear_vision_cache()
        with patch("familiar.skills.intake.vision.detect_vision_model", return_value=None):
            result = extract_with_vision(Path("/tmp/test.png"))
            assert result is None
        clear_vision_cache()

    def test_extract_with_vision_file_not_found(self):
        from familiar.skills.intake.vision import extract_with_vision

        result = extract_with_vision(Path("/nonexistent/image.png"), model="llava")
        assert result is None

    def test_extract_with_vision_wrong_extension(self):
        from familiar.skills.intake.vision import extract_with_vision

        with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
            f.write(b"pdf data")
            f.flush()
            result = extract_with_vision(Path(f.name), model="llava")
            assert result is None
            os.unlink(f.name)

    def test_extract_with_vision_success(self):
        from familiar.skills.intake.vision import extract_with_vision

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"message": {"content": "Receipt: $50.00 from Jane Doe"}}

        with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
            f.write(b"\x89PNG\r\ntest")
            f.flush()
            with patch("httpx.post", return_value=mock_resp):
                result = extract_with_vision(Path(f.name), model="llava")
                assert result == "Receipt: $50.00 from Jane Doe"
            os.unlink(f.name)

    def test_extract_with_vision_api_error(self):
        from familiar.skills.intake.vision import extract_with_vision

        mock_resp = MagicMock()
        mock_resp.status_code = 500
        mock_resp.text = "Internal Server Error"

        with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
            f.write(b"\x89PNG\r\ntest")
            f.flush()
            with patch("httpx.post", return_value=mock_resp):
                result = extract_with_vision(Path(f.name), model="llava")
                assert result is None
            os.unlink(f.name)

    def test_extract_with_ocr_unavailable(self):
        from familiar.skills.intake.vision import extract_with_ocr

        with patch(
            "familiar.skills.intake.vision.pytesseract",
            create=True,
            **{"image_to_string.side_effect": Exception("OCR failed")},
        ):
            with patch(
                "familiar.skills.intake.vision.Image",
                create=True,
                **{"open.return_value": MagicMock(mode="RGB")},
            ):
                result = extract_with_ocr(Path("/tmp/test.png"))
                assert result is None

    def test_extract_text_from_image_vision_first(self):
        from familiar.skills.intake.vision import extract_text_from_image

        with patch(
            "familiar.skills.intake.vision.extract_with_vision",
            return_value="Vision text",
        ):
            result = extract_text_from_image(Path("/tmp/fake.png"))
            # May fall back if file doesn't exist, but tests the logic
            assert "Vision text" in result or "not found" in result.lower()

    def test_extract_text_from_image_prefer_ocr(self):
        from familiar.skills.intake.vision import extract_text_from_image

        with patch("familiar.skills.intake.vision.extract_with_ocr", return_value="OCR text"):
            result = extract_text_from_image(Path("/tmp/fake.png"), prefer_ocr=True)
            assert "OCR text" in result or "not found" in result.lower()

    def test_extract_text_file_not_found(self):
        from familiar.skills.intake.vision import extract_text_from_image

        result = extract_text_from_image(Path("/nonexistent/img.png"))
        assert "not found" in result.lower()

    def test_get_ollama_base_default(self):
        from familiar.skills.intake.vision import _get_ollama_base

        with patch.dict(os.environ, {}, clear=True):
            # Remove OLLAMA_HOST if set
            os.environ.pop("OLLAMA_HOST", None)
            assert _get_ollama_base() == "http://localhost:11434"

    def test_get_ollama_base_custom(self):
        from familiar.skills.intake.vision import _get_ollama_base

        with patch.dict(os.environ, {"OLLAMA_HOST": "http://myhost:11434"}):
            assert _get_ollama_base() == "http://myhost:11434"


# ============================================================
# Classifier Tests
# ============================================================


class TestClassifiers:
    """Test content classification engine."""

    def test_content_type_enum(self):
        from familiar.skills.intake.classifiers import ContentType

        assert ContentType.DONATION_RECEIPT.value == "donation_receipt"
        assert ContentType.GRANT_DOCUMENT.value == "grant_document"
        assert ContentType.INVOICE.value == "invoice"
        assert ContentType.UNKNOWN.value == "unknown"

    def test_classification_result_dataclass(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType

        result = ClassificationResult(
            content_type=ContentType.DONATION_RECEIPT,
            confidence=0.85,
            extracted_text="test",
        )
        assert result.content_type == ContentType.DONATION_RECEIPT
        assert result.confidence == 0.85
        assert result.extracted_fields == {}
        assert result.method == "keyword"

    def test_classify_empty_text(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        result = classify_by_keywords("")
        assert result.content_type == ContentType.UNKNOWN
        assert result.confidence == 0.0

    def test_classify_donation_receipt(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = (
            "Thank you for your tax-deductible donation of $500. "
            "This receipt confirms your charitable contribution to "
            "Gender Justice League. EIN: 12-3456789"
        )
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.DONATION_RECEIPT
        assert result.confidence > 0.5

    def test_classify_grant_document(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = (
            "Request for Proposal: Community Health Grant. "
            "Funding amount: $50,000. Proposal narrative required. "
            "Budget justification must include deliverables."
        )
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.GRANT_DOCUMENT
        assert result.confidence > 0.5

    def test_classify_invoice(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = (
            "INVOICE #2024-001\nBill To: Gender Justice League\n"
            "Amount Due: $1,200.00\nPayment Terms: Net 30\n"
            "Qty: 100 | Unit Price: $12.00"
        )
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.INVOICE
        assert result.confidence > 0.5

    def test_classify_event_flyer(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = (
            "Join us for the Annual Gala! RSVP by March 1st. "
            "Venue: Grand Ballroom. Tickets available now. "
            "Keynote speaker: Dr. Smith."
        )
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.EVENT_FLYER
        assert result.confidence > 0.5

    def test_classify_legislation(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = (
            "HB 1234 - An Act concerning healthcare access. "
            "WHEREAS the legislature finds that... "
            "Section 1. This statute shall... "
            "Committee hearing scheduled for..."
        )
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.LEGISLATION
        assert result.confidence > 0.5

    def test_classify_meeting_notes(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = (
            "Board Meeting Minutes - January 2026\n"
            "Attendees: Alice, Bob, Carol\n"
            "Agenda: Q4 review\n"
            "Action Items: Follow up with donors\n"
            "Motion to approve budget — seconded."
        )
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.MEETING_NOTES
        assert result.confidence > 0.5

    def test_classify_business_card(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = (
            "Jane Doe\nExecutive Director\n"
            "Tel: 555-1234\nFax: 555-5678\n"
            "Email: jane@example.org\nwww.example.org"
        )
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.BUSINESS_CARD
        assert result.confidence > 0.5

    def test_classify_recipe(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = (
            "Chocolate Cake Recipe\nServings: 8\nPrep Time: 20 min\n"
            "Ingredients: 2 cups flour, 1 tablespoon baking powder\n"
            "Instructions: Preheat oven to 350F. Bake for 30 min."
        )
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.RECIPE
        assert result.confidence > 0.5

    def test_classify_general_fallback(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = "This is a random document with no strong keywords."
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.GENERAL_DOCUMENT
        assert result.confidence == 0.3

    def test_classify_medical_record(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = (
            "Patient: John Smith\nDiagnosis: Type 2 Diabetes\n"
            "Medication: Metformin 500mg\nPhysician: Dr. Jones\n"
            "Clinic: Downtown Medical Center"
        )
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.MEDICAL_RECORD
        assert result.confidence > 0.5

    def test_classify_case_note(self):
        from familiar.skills.intake.classifiers import ContentType, classify_by_keywords

        text = (
            "Case #2024-456\nClient: Jane Doe\n"
            "Assessment: housing instability\n"
            "Referral to shelter services\n"
            "Service plan updated."
        )
        result = classify_by_keywords(text)
        assert result.content_type == ContentType.CASE_NOTE

    def test_classify_with_llm_unavailable(self):
        from familiar.skills.intake.classifiers import classify_with_llm

        with patch("httpx.post", side_effect=Exception("no ollama")):
            result = classify_with_llm("some text")
            assert result is None

    def test_classify_with_llm_success(self):
        from familiar.skills.intake.classifiers import ContentType, classify_with_llm

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "message": {
                "content": json.dumps(
                    {
                        "content_type": "donation_receipt",
                        "confidence": 0.9,
                        "fields": {"donor_name": "Jane", "amount": "$500"},
                    }
                )
            }
        }
        with patch("httpx.post", return_value=mock_resp):
            result = classify_with_llm("donation text")
            assert result is not None
            assert result.content_type == ContentType.DONATION_RECEIPT
            assert result.confidence == 0.9
            assert result.extracted_fields["donor_name"] == "Jane"

    def test_classify_with_llm_bad_json(self):
        from familiar.skills.intake.classifiers import classify_with_llm

        mock_resp = MagicMock()
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"message": {"content": "not json"}}
        with patch("httpx.post", return_value=mock_resp):
            result = classify_with_llm("text")
            assert result is None

    def test_classify_tiered_keyword_sufficient(self):
        from familiar.skills.intake.classifiers import ContentType, classify

        text = (
            "INVOICE #100\nBill To: ABC Corp\n"
            "Amount Due: $5000\nPayment Terms: Net 30\n"
            "Qty: 10 items | Unit Price: $500"
        )
        result = classify(text, use_llm=False)
        assert result.content_type == ContentType.INVOICE
        assert result.method == "keyword"

    def test_classify_tiered_llm_upgrade(self):
        from familiar.skills.intake.classifiers import ContentType, classify

        mock_llm_result = MagicMock()
        mock_llm_result.content_type = ContentType.GRANT_DOCUMENT
        mock_llm_result.confidence = 0.9
        mock_llm_result.extracted_fields = {"funder": "NIH"}

        text = "some ambiguous document about funding"
        with patch(
            "familiar.skills.intake.classifiers.classify_with_llm",
            return_value=mock_llm_result,
        ):
            result = classify(text, use_llm=True)
            # LLM should override low-confidence keyword result
            assert result.content_type == ContentType.GRANT_DOCUMENT

    def test_classify_source_file_preserved(self):
        from familiar.skills.intake.classifiers import classify

        result = classify("test text", filepath="/tmp/test.pdf", use_llm=False)
        assert result.source_file == "/tmp/test.pdf"


# ============================================================
# Router Tests
# ============================================================


class TestRouter:
    """Test content routing to stores."""

    def test_route_table_complete(self):
        from familiar.skills.intake.classifiers import ContentType
        from familiar.skills.intake.router import ROUTE_TABLE

        for ct in ContentType:
            assert ct in ROUTE_TABLE, f"Missing route for {ct}"

    def test_route_dry_run(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.DONATION_RECEIPT,
            confidence=0.9,
            extracted_text="test",
            extracted_fields={"donor_name": "Jane", "amount": "$100"},
        )
        route_result = route(result, dry_run=True)
        assert route_result.success is True
        assert "would route" in route_result.summary.lower()

    def test_route_donation_receipt(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.DONATION_RECEIPT,
            confidence=0.9,
            extracted_text="donation text",
            extracted_fields={"donor_name": "Jane Doe", "amount": "$500"},
        )
        with patch("familiar.skills.nonprofit.skill.log_donor", return_value="Donor logged"):
            route_result = route(result)
            assert route_result.destination == "nonprofit.donors"
            assert "Jane Doe" in route_result.summary

    def test_route_grant_document(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.GRANT_DOCUMENT,
            confidence=0.85,
            extracted_text="grant text",
            extracted_fields={"grant_name": "Health Grant", "funder": "NIH"},
        )
        with patch("familiar.skills.nonprofit.skill.add_grant", return_value="Grant added"):
            route_result = route(result)
            assert route_result.destination == "nonprofit.grants"
            assert "Health Grant" in route_result.summary

    def test_route_event_flyer(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.EVENT_FLYER,
            confidence=0.8,
            extracted_text="event text",
            extracted_fields={"event_name": "Gala 2026", "date": "2026-06-01"},
        )
        mock_store = MagicMock()
        mock_store.create_event.return_value = "evt_123"
        with patch("familiar.skills.events.skill.get_event_store", return_value=mock_store):
            route_result = route(result)
            assert route_result.destination == "events"
            assert route_result.record_id == "evt_123"

    def test_route_legislation(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.LEGISLATION,
            confidence=0.9,
            extracted_text="bill text",
            extracted_fields={"bill_number": "HB 1234", "title": "Healthcare Act"},
        )
        mock_store = MagicMock()
        mock_store.track_bill.return_value = "bill_123"
        with patch("familiar.skills.advocacy.skill.get_bill_store", return_value=mock_store):
            route_result = route(result)
            assert route_result.destination == "advocacy.bills"
            assert "HB 1234" in route_result.summary

    def test_route_invoice(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.INVOICE,
            confidence=0.85,
            extracted_text="invoice text",
            extracted_fields={"vendor": "ACME", "amount": "$1200"},
        )
        with patch("familiar.skills.nonprofit.skill.add_note", return_value="Note added"):
            route_result = route(result)
            assert route_result.destination == "notes"
            assert "ACME" in route_result.summary

    def test_route_meeting_notes(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.MEETING_NOTES,
            confidence=0.8,
            extracted_text="Board meeting minutes" * 100,
            extracted_fields={"date": "2026-01-15"},
        )
        with patch("familiar.skills.nonprofit.skill.add_note", return_value="Note added"):
            route_result = route(result)
            assert route_result.destination == "notes"
            assert "meeting notes" in route_result.summary.lower()

    def test_route_business_card(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.BUSINESS_CARD,
            confidence=0.85,
            extracted_text="card text",
            extracted_fields={
                "name": "Jane Doe",
                "title": "Director",
                "email": "jane@example.org",
            },
        )
        with patch("familiar.skills.nonprofit.skill.log_donor", return_value="Added"):
            route_result = route(result)
            assert route_result.destination == "nonprofit.donors"
            assert "Jane Doe" in route_result.summary

    def test_route_recipe(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.RECIPE,
            confidence=0.7,
            extracted_text="recipe text",
            extracted_fields={"name": "Chocolate Cake"},
        )
        with patch("familiar.skills.nonprofit.skill.add_note", return_value="Added"):
            route_result = route(result)
            assert route_result.destination == "notes"
            assert "Chocolate Cake" in route_result.summary

    def test_route_general_fallback(self):
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.GENERAL_DOCUMENT,
            confidence=0.3,
            extracted_text="generic text content",
        )
        with patch("familiar.skills.nonprofit.skill.add_note", return_value="Added"):
            route_result = route(result)
            assert route_result.destination == "notes"

    def test_route_universal_fallback(self):
        """When nonprofit store fails, routes should fall back to universal notes."""
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.DONATION_RECEIPT,
            confidence=0.9,
            extracted_text="text",
            extracted_fields={"donor_name": "Bob", "amount": "$100"},
        )
        with patch(
            "familiar.skills.nonprofit.skill.log_donor",
            side_effect=Exception("store error"),
        ):
            with patch(
                "familiar.skills.intake.router._save_intake_note",
                return_value="inote_123",
            ) as mock_save:
                route_result = route(result)
                # Should succeed via universal fallback, not fail
                assert route_result.success is True
                assert route_result.destination == "notes"
                mock_save.assert_called_once()

    def test_route_general_always_succeeds(self):
        """General route should always succeed via universal notes."""
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.GENERAL_DOCUMENT,
            confidence=0.3,
            extracted_text="generic text content",
        )
        # Even if nonprofit.skill import fails, universal fallback works
        with patch(
            "familiar.skills.nonprofit.skill.add_note",
            side_effect=ImportError("not available"),
        ):
            with patch(
                "familiar.skills.intake.router._save_intake_note",
                return_value="inote_456",
            ):
                route_result = route(result)
                assert route_result.success is True


# ============================================================
# History Store Tests
# ============================================================


class TestIntakeHistory:
    """Test intake history store."""

    def _make_store(self, tmp_path):
        from familiar.skills.intake.history import IntakeHistory

        return IntakeHistory(data_file=tmp_path / "test_history.json")

    def test_create_store(self, tmp_path):
        self._make_store(tmp_path)
        assert (tmp_path / "test_history.json").exists()

    def test_record_entry(self, tmp_path):
        store = self._make_store(tmp_path)
        eid = store.record(
            source_file="/tmp/test.png",
            content_type="donation_receipt",
            confidence=0.9,
            method="keyword",
            destination="nonprofit.donors",
            success=True,
            summary="Logged donation: Jane $500",
        )
        assert eid.startswith("intake_")

    def test_list_entries(self, tmp_path):
        store = self._make_store(tmp_path)
        store.record(
            source_file="/tmp/a.png",
            content_type="donation_receipt",
            confidence=0.9,
            method="keyword",
            destination="donors",
            success=True,
            summary="A",
        )
        store.record(
            source_file="/tmp/b.pdf",
            content_type="grant_document",
            confidence=0.8,
            method="llm",
            destination="grants",
            success=True,
            summary="B",
        )
        entries = store.list_entries()
        assert len(entries) == 2
        # Most recent first
        assert entries[0]["content_type"] == "grant_document"

    def test_list_entries_filter_by_type(self, tmp_path):
        store = self._make_store(tmp_path)
        store.record(
            source_file="a",
            content_type="invoice",
            confidence=0.8,
            method="keyword",
            destination="notes",
            success=True,
            summary="A",
        )
        store.record(
            source_file="b",
            content_type="donation_receipt",
            confidence=0.9,
            method="keyword",
            destination="donors",
            success=True,
            summary="B",
        )
        entries = store.list_entries(content_type="invoice")
        assert len(entries) == 1
        assert entries[0]["content_type"] == "invoice"

    def test_list_entries_limit(self, tmp_path):
        store = self._make_store(tmp_path)
        for i in range(10):
            store.record(
                source_file=f"f{i}",
                content_type="general_document",
                confidence=0.3,
                method="keyword",
                destination="notes",
                success=True,
                summary=f"Entry {i}",
            )
        entries = store.list_entries(limit=3)
        assert len(entries) == 3

    def test_get_entry_by_id(self, tmp_path):
        store = self._make_store(tmp_path)
        eid = store.record(
            source_file="test",
            content_type="invoice",
            confidence=0.8,
            method="keyword",
            destination="notes",
            success=True,
            summary="test",
        )
        entry = store.get_entry(eid)
        assert entry is not None
        assert entry["id"] == eid

    def test_get_entry_not_found(self, tmp_path):
        store = self._make_store(tmp_path)
        assert store.get_entry("nonexistent") is None

    def test_get_stats(self, tmp_path):
        store = self._make_store(tmp_path)
        store.record(
            source_file="a",
            content_type="donation_receipt",
            confidence=0.9,
            method="keyword",
            destination="donors",
            success=True,
            summary="A",
        )
        store.record(
            source_file="b",
            content_type="donation_receipt",
            confidence=0.8,
            method="keyword",
            destination="donors",
            success=True,
            summary="B",
        )
        store.record(
            source_file="c",
            content_type="invoice",
            confidence=0.7,
            method="keyword",
            destination="notes",
            success=False,
            summary="C",
        )
        stats = store.get_stats()
        assert stats["total"] == 3
        assert stats["success_rate"] == round(2 / 3, 2)
        assert stats["by_type"]["donation_receipt"] == 2
        assert stats["by_type"]["invoice"] == 1
        assert stats["by_destination"]["donors"] == 2

    def test_get_stats_empty(self, tmp_path):
        store = self._make_store(tmp_path)
        stats = store.get_stats()
        assert stats["total"] == 0
        assert stats["success_rate"] == 0.0

    def test_clear(self, tmp_path):
        store = self._make_store(tmp_path)
        store.record(
            source_file="x",
            content_type="invoice",
            confidence=0.5,
            method="keyword",
            destination="notes",
            success=True,
            summary="X",
        )
        store.clear()
        assert store.list_entries() == []

    def test_cap_at_1000(self, tmp_path):
        store = self._make_store(tmp_path)
        for i in range(1010):
            store.record(
                source_file=f"f{i}",
                content_type="general_document",
                confidence=0.3,
                method="keyword",
                destination="notes",
                success=True,
                summary=f"Entry {i}",
            )
        data = json.loads((tmp_path / "test_history.json").read_text())
        assert len(data["entries"]) == 1000

    def test_text_preview_truncated(self, tmp_path):
        store = self._make_store(tmp_path)
        long_text = "x" * 1000
        store.record(
            source_file="f",
            content_type="general_document",
            confidence=0.3,
            method="keyword",
            destination="notes",
            success=True,
            summary="S",
            text_preview=long_text,
        )
        entry = store.list_entries()[0]
        assert len(entry["text_preview"]) == 500

    def test_extracted_fields_stored(self, tmp_path):
        store = self._make_store(tmp_path)
        store.record(
            source_file="f",
            content_type="donation_receipt",
            confidence=0.9,
            method="llm",
            destination="donors",
            success=True,
            summary="S",
            extracted_fields={"donor_name": "Jane", "amount": "$500"},
        )
        entry = store.list_entries()[0]
        assert entry["extracted_fields"]["donor_name"] == "Jane"


# ============================================================
# Main Skill / Pipeline Tests
# ============================================================


class TestSmartIntakeSkill:
    """Test the main intake pipeline."""

    def test_detect_file_type_image(self):
        from familiar.skills.intake.skill import detect_file_type

        assert detect_file_type("photo.png") == "image"
        assert detect_file_type("scan.jpg") == "image"
        assert detect_file_type("photo.JPEG") == "image"

    def test_detect_file_type_pdf(self):
        from familiar.skills.intake.skill import detect_file_type

        assert detect_file_type("doc.pdf") == "pdf"

    def test_detect_file_type_document(self):
        from familiar.skills.intake.skill import detect_file_type

        assert detect_file_type("doc.docx") == "document"
        assert detect_file_type("data.csv") == "document"
        assert detect_file_type("sheet.xlsx") == "document"

    def test_detect_file_type_audio(self):
        from familiar.skills.intake.skill import detect_file_type

        assert detect_file_type("voice.mp3") == "audio"
        assert detect_file_type("note.wav") == "audio"
        assert detect_file_type("memo.m4a") == "audio"

    def test_detect_file_type_unknown(self):
        from familiar.skills.intake.skill import detect_file_type

        assert detect_file_type("file.xyz") == "unknown"

    def test_smart_intake_no_filepath(self):
        from familiar.skills.intake.skill import smart_intake

        result = smart_intake({})
        assert "need a file" in result.lower()
        assert "upload" in result.lower()  # Should mention upload option

    def test_smart_intake_file_not_found(self):
        from familiar.skills.intake.skill import smart_intake

        result = smart_intake({"filepath": "/nonexistent/file.pdf"})
        assert "not found" in result.lower()

    def test_smart_intake_text_file(self, tmp_path):
        from familiar.skills.intake.skill import smart_intake

        # Create a text file that looks like a donation receipt
        text = (
            "Thank you for your tax-deductible donation of $500.\n"
            "This receipt confirms your charitable contribution.\n"
            "EIN: 12-3456789"
        )
        f = tmp_path / "receipt.txt"
        f.write_text(text)

        with patch("familiar.skills.nonprofit.skill.log_donor", return_value="Logged"):
            result = smart_intake({"filepath": str(f), "skip_llm": True})
            assert "donation" in result.lower() or "receipt" in result.lower()
            assert "committed" in result.lower() or "Status:" in result

    def test_smart_intake_dry_run(self, tmp_path):
        from familiar.skills.intake.skill import smart_intake

        f = tmp_path / "test.txt"
        f.write_text("INVOICE #123\nBill To: ABC\nAmount Due: $500\nPayment Terms: Net 30")

        result = smart_intake({"filepath": str(f), "dry_run": True, "skip_llm": True})
        assert "preview" in result.lower()

    def test_smart_intake_override_type(self, tmp_path):
        from familiar.skills.intake.skill import smart_intake

        f = tmp_path / "ambiguous.txt"
        f.write_text("This is some generic text about a grant and funding.")

        with patch("familiar.skills.nonprofit.skill.add_grant", return_value="Added"):
            result = smart_intake(
                {
                    "filepath": str(f),
                    "content_type": "grant_document",
                    "skip_llm": True,
                }
            )
            assert "grant" in result.lower()

    def test_smart_intake_bad_override_type(self, tmp_path):
        from familiar.skills.intake.skill import smart_intake

        f = tmp_path / "test.txt"
        f.write_text("test")
        result = smart_intake({"filepath": str(f), "content_type": "invalid_type"})
        assert "unknown content type" in result.lower()

    def test_smart_intake_records_history(self, tmp_path):
        from familiar.skills.intake.skill import smart_intake

        f = tmp_path / "note.txt"
        f.write_text("just a general note about something")

        with patch("familiar.skills.nonprofit.skill.add_note", return_value="Added"):
            with patch("familiar.skills.intake.history.get_intake_history") as mock_hist:
                mock_store = MagicMock()
                mock_hist.return_value = mock_store
                smart_intake({"filepath": str(f), "skip_llm": True})
                mock_store.record.assert_called_once()

    def test_intake_history_tool_list(self):
        from familiar.skills.intake.skill import intake_history

        with patch("familiar.skills.intake.history.get_intake_history") as mock_hist:
            mock_store = MagicMock()
            mock_store.list_entries.return_value = [
                {
                    "timestamp": "2026-01-01T10:00:00",
                    "success": True,
                    "content_type": "donation_receipt",
                    "destination": "donors",
                    "summary": "Logged Jane $500",
                }
            ]
            mock_hist.return_value = mock_store
            result = intake_history({"action": "list"})
            assert "donation_receipt" in result
            assert "Jane" in result

    def test_intake_history_tool_stats(self):
        from familiar.skills.intake.skill import intake_history

        with patch("familiar.skills.intake.history.get_intake_history") as mock_hist:
            mock_store = MagicMock()
            mock_store.get_stats.return_value = {
                "total": 10,
                "success_rate": 0.9,
                "by_type": {"donation_receipt": 5, "invoice": 3},
                "by_destination": {"donors": 5, "notes": 3},
            }
            mock_hist.return_value = mock_store
            result = intake_history({"action": "stats"})
            assert "10" in result
            assert "90%" in result

    def test_intake_history_empty(self):
        from familiar.skills.intake.skill import intake_history

        with patch("familiar.skills.intake.history.get_intake_history") as mock_hist:
            mock_store = MagicMock()
            mock_store.list_entries.return_value = []
            mock_hist.return_value = mock_store
            result = intake_history({})
            assert "no intake" in result.lower()

    def test_batch_intake_no_files(self):
        from familiar.skills.intake.skill import batch_intake

        result = batch_intake({})
        assert "no files" in result.lower()

    def test_batch_intake_directory(self, tmp_path):
        from familiar.skills.intake.skill import batch_intake

        (tmp_path / "a.txt").write_text("test doc a")
        (tmp_path / "b.txt").write_text("test doc b")
        (tmp_path / "c.xyz").write_text("unsupported")

        with patch("familiar.skills.nonprofit.skill.add_note", return_value="Added"):
            result = batch_intake(
                {
                    "directory": str(tmp_path),
                    "skip_llm": True,
                }
            )
            assert "2" in result  # 2 supported files (a.txt, b.txt)

    def test_batch_intake_dry_run(self, tmp_path):
        from familiar.skills.intake.skill import batch_intake

        (tmp_path / "test.txt").write_text("invoice test data")
        result = batch_intake(
            {
                "directory": str(tmp_path),
                "dry_run": True,
                "skip_llm": True,
            }
        )
        assert "dry run" in result.lower()

    def test_batch_intake_file_list(self, tmp_path):
        from familiar.skills.intake.skill import batch_intake

        f1 = tmp_path / "a.txt"
        f1.write_text("general text")

        with patch("familiar.skills.nonprofit.skill.add_note", return_value="Added"):
            result = batch_intake(
                {
                    "filepaths": [str(f1)],
                    "skip_llm": True,
                }
            )
            assert "1" in result

    def test_batch_intake_nonexistent_dir(self):
        from familiar.skills.intake.skill import batch_intake

        result = batch_intake({"directory": "/nonexistent/path"})
        assert "not found" in result.lower()


# ============================================================
# Tool Registration Tests
# ============================================================


class TestToolRegistration:
    """Test tool definitions are correct."""

    def test_tools_list(self):
        from familiar.skills.intake.skill import TOOLS

        assert len(TOOLS) == 3
        names = {t["name"] for t in TOOLS}
        assert "smart_intake" in names
        assert "intake_history" in names
        assert "batch_intake" in names

    def test_tool_handlers_callable(self):
        from familiar.skills.intake.skill import TOOLS

        for tool in TOOLS:
            assert callable(tool["handler"])

    def test_tool_schemas_valid(self):
        from familiar.skills.intake.skill import TOOLS

        for tool in TOOLS:
            schema = tool["input_schema"]
            assert schema["type"] == "object"
            assert "properties" in schema

    def test_smart_intake_schema_has_filepath(self):
        from familiar.skills.intake.skill import TOOLS

        intake_tool = next(t for t in TOOLS if t["name"] == "smart_intake")
        assert "filepath" in intake_tool["input_schema"]["properties"]
        assert "filepath" in intake_tool["input_schema"]["required"]


# ============================================================
# Workspace / API Integration Tests
# ============================================================


class TestWorkspaceIntegration:
    """Test workspace data source integration."""

    def test_intake_source_registered(self):
        from familiar.dashboard.workspace import _WORKSPACE_SOURCES

        assert "intake" in _WORKSPACE_SOURCES

    def test_intake_loader_returns_expected_keys(self):
        from familiar.dashboard.workspace import _load_intake_data

        with patch("familiar.skills.intake.history.get_intake_history") as mock_hist:
            mock_store = MagicMock()
            mock_store.get_stats.return_value = {
                "total": 5,
                "success_rate": 0.8,
                "by_type": {"invoice": 3},
            }
            mock_store.list_entries.return_value = [{"id": "test"}]
            mock_hist.return_value = mock_store
            data = _load_intake_data()
            assert "intake_entries" in data
            assert "intake.total_count" in data
            assert "intake.success_rate" in data

    def test_intake_loader_graceful_degradation(self):
        from familiar.dashboard.workspace import _load_intake_data

        with patch(
            "familiar.skills.intake.history.get_intake_history",
            side_effect=ImportError("not installed"),
        ):
            data = _load_intake_data()
            assert data["intake.total_count"] == 0
            assert data["intake_entries"] == []


class TestJobClassIntegration:
    """Test job class skill weight integration."""

    def test_nonprofit_director_intake_weights(self):
        from familiar.jobs.nonprofit_director import NONPROFIT_DIRECTOR

        weights = NONPROFIT_DIRECTOR.skill_weights
        assert "smart_intake" in weights
        assert "batch_intake" in weights
        assert "intake_history" in weights
        assert weights["smart_intake"] >= 0.7

    def test_nonprofit_director_intake_section(self):
        from familiar.jobs.nonprofit_director import NONPROFIT_DIRECTOR

        sections = NONPROFIT_DIRECTOR.workspace["sections"]
        intake_sections = [s for s in sections if s["key"] == "intake"]
        assert len(intake_sections) == 1
        assert intake_sections[0]["source"] == "intake"

    def test_nonprofit_director_intake_overview_card(self):
        from familiar.jobs.nonprofit_director import NONPROFIT_DIRECTOR

        cards = NONPROFIT_DIRECTOR.workspace["overview"]["cards"]
        intake_cards = [c for c in cards if c["key"] == "intake_count"]
        assert len(intake_cards) == 1
        assert intake_cards[0]["source"] == "intake"

    def test_nonprofit_director_scan_quick_action(self):
        from familiar.jobs.nonprofit_director import NONPROFIT_DIRECTOR

        actions = NONPROFIT_DIRECTOR.workspace["quick_actions"]
        scan_actions = [a for a in actions if a["key"] == "scan_document"]
        assert len(scan_actions) == 1

    def test_helper_intake_weights(self):
        from familiar.jobs.helper import HELPER

        weights = HELPER.skill_weights
        assert "smart_intake" in weights


# ============================================================
# Friction Fix Tests
# ============================================================


class TestFrictionFixes:
    """Tests for the 9 friction-point fixes."""

    def test_vision_diagnostics_ollama_missing(self):
        """Diagnostic message should mention Ollama when it's not installed."""
        from familiar.skills.intake.vision import (
            _get_extraction_diagnostics,
            clear_vision_cache,
        )

        clear_vision_cache()
        with patch("httpx.get", side_effect=Exception("no conn")):
            with patch("shutil.which", return_value=None):
                msg = _get_extraction_diagnostics()
                assert "ollama" in msg.lower()
        clear_vision_cache()

    def test_vision_diagnostics_ollama_stopped(self):
        """Diagnostic should tell user to start Ollama."""
        from familiar.skills.intake.vision import (
            _get_extraction_diagnostics,
            clear_vision_cache,
        )

        clear_vision_cache()
        with patch("httpx.get", side_effect=Exception("conn refused")):
            with patch("shutil.which", return_value="/usr/bin/ollama"):
                msg = _get_extraction_diagnostics()
                assert "ollama serve" in msg.lower()
        clear_vision_cache()

    def test_vision_ocr_auto_install(self):
        """OCR should attempt auto-install via deps system."""
        from familiar.skills.intake.vision import _ensure_ocr

        with patch(
            "familiar.skills.intake.vision.pytesseract",
            create=True,
            side_effect=ImportError("no pytesseract"),
        ):
            with patch("familiar.core.deps.ensure_packages", return_value=(True, [])):
                # Should call ensure_packages
                result = _ensure_ocr()
                assert result is True

    def test_audio_ffmpeg_check(self):
        from familiar.skills.intake.skill import _extract_from_audio

        with patch("shutil.which", return_value=None):
            result = _extract_from_audio(Path("/tmp/test.mp3"))
            assert "ffmpeg" in result.lower()
            assert "sudo apt" in result.lower()

    def test_audio_wav_no_ffmpeg_needed(self):
        """WAV files should work without ffmpeg."""
        from familiar.skills.intake.skill import _extract_from_audio

        with patch("shutil.which", return_value=None):
            with patch(
                "familiar.skills.transcription.skill._transcribe_file",
                return_value=("Hello world", "faster-whisper"),
            ):
                result = _extract_from_audio(Path("/tmp/test.wav"))
                assert result == "Hello world"

    def test_router_save_intake_note(self):
        """Universal notes store should always work."""
        from familiar.skills.intake.router import _save_intake_note

        with tempfile.TemporaryDirectory() as tmp:
            with patch(
                "familiar.skills.intake.router._get_notes_dir",
                return_value=Path(tmp),
            ):
                note_id = _save_intake_note("Test note", category="test")
                assert note_id.startswith("inote_")

                # Verify file was created
                notes_file = Path(tmp) / "intake_notes.json"
                assert notes_file.exists()
                data = json.loads(notes_file.read_text())
                assert len(data["notes"]) == 1
                assert data["notes"][0]["content"] == "Test note"

    def test_pdf_temp_file_uses_system_tmpdir(self, tmp_path):
        """PDF OCR should use system temp dir, not user's directory."""
        from familiar.skills.intake.skill import _extract_from_pdf

        # Create a minimal PDF that will trigger OCR path
        pdf_path = tmp_path / "scan.pdf"
        pdf_path.write_bytes(b"%PDF-1.0 test")

        with patch(
            "familiar.skills.filereader.skill._read_pdf",
            return_value="No text extracted from PDF",
        ):
            with patch(
                "familiar.skills.intake.vision.extract_text_from_image",
                return_value="OCR text found",
            ) as mock_extract:
                mock_doc = MagicMock()
                mock_page = MagicMock()
                mock_pix = MagicMock()
                mock_doc.__len__ = lambda self: 1
                mock_doc.__getitem__ = lambda self, idx: mock_page
                mock_page.get_pixmap.return_value = mock_pix

                with patch("fitz.open", return_value=mock_doc):
                    result = _extract_from_pdf(pdf_path)
                    assert result == "OCR text found"
                    # Verify extract was called with a temp path (not in user dir)
                    call_path = mock_extract.call_args[0][0]
                    assert "intake_" in str(call_path)

    def test_smart_intake_no_filepath_gives_guidance(self):
        """Empty filepath should give actionable guidance, not just error."""
        from familiar.skills.intake.skill import smart_intake

        result = smart_intake({})
        assert "upload" in result.lower()
        assert "dashboard" in result.lower()
        assert "jpg" in result.lower() or "png" in result.lower()

    def test_all_supported_extensions_set(self):
        """_ALL_SUPPORTED should include all file types."""
        from familiar.skills.intake.skill import _ALL_SUPPORTED

        assert ".jpg" in _ALL_SUPPORTED
        assert ".pdf" in _ALL_SUPPORTED
        assert ".docx" in _ALL_SUPPORTED
        assert ".mp3" in _ALL_SUPPORTED
        assert ".wav" in _ALL_SUPPORTED

    def test_ollama_status_check(self):
        """Ollama status should detect running/stopped/missing."""
        from familiar.skills.intake.vision import _check_ollama_status, clear_vision_cache

        clear_vision_cache()

        # Test "running"
        mock_resp = MagicMock()
        mock_resp.status_code = 200
        with patch("httpx.get", return_value=mock_resp):
            status = _check_ollama_status()
            assert status == "running"

        clear_vision_cache()

        # Test "stopped" (connection refused but binary exists)
        with patch("httpx.get", side_effect=Exception("refused")):
            with patch("shutil.which", return_value="/usr/bin/ollama"):
                status = _check_ollama_status()
                assert status == "stopped"

        clear_vision_cache()

        # Test "missing"
        with patch("httpx.get", side_effect=Exception("refused")):
            with patch("shutil.which", return_value=None):
                status = _check_ollama_status()
                assert status == "missing"

        clear_vision_cache()


# ============================================================
# FileStore + MemoryTree Integration Tests
# ============================================================


class TestMemoryTreeIntegration:
    """Test that intake data flows into FileStore and MemoryTree."""

    def test_route_result_has_file_and_memory_fields(self):
        """RouteResult should have file_id and memory_id fields."""
        from familiar.skills.intake.router import RouteResult

        result = RouteResult(success=True, destination="notes", summary="test")
        assert hasattr(result, "file_id")
        assert hasattr(result, "memory_id")
        assert result.file_id is None
        assert result.memory_id is None

    def test_index_in_filestore_no_source_file(self):
        """Should return (None, None) when no source file."""
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import RouteResult, _index_in_filestore

        result = ClassificationResult(
            content_type=ContentType.GENERAL_DOCUMENT,
            confidence=0.5,
            extracted_text="test",
            source_file="",
        )
        route_result = RouteResult(success=True, destination="notes", summary="test")
        fid, mid = _index_in_filestore(result, route_result)
        assert fid is None
        assert mid is None

    def test_index_in_filestore_missing_file(self):
        """Should return (None, None) when source file doesn't exist."""
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import RouteResult, _index_in_filestore

        result = ClassificationResult(
            content_type=ContentType.GENERAL_DOCUMENT,
            confidence=0.5,
            extracted_text="test",
            source_file="/nonexistent/file.pdf",
        )
        route_result = RouteResult(success=True, destination="notes", summary="test")
        fid, mid = _index_in_filestore(result, route_result)
        assert fid is None
        assert mid is None

    def test_index_in_filestore_stores_file(self):
        """Should store file in FileStore and return file_id."""
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import RouteResult, _index_in_filestore

        with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
            f.write(b"%PDF-1.0 test content")
            f.flush()
            filepath = f.name

        try:
            result = ClassificationResult(
                content_type=ContentType.INVOICE,
                confidence=0.85,
                extracted_text="Invoice from ACME Corp",
                extracted_fields={"vendor": "ACME", "amount": "$500"},
                source_file=filepath,
            )
            route_result = RouteResult(success=True, destination="notes", summary="test")

            mock_record = MagicMock()
            mock_record.file_id = "file_abc123"
            mock_store = MagicMock()
            mock_store.store.return_value = mock_record

            mock_entry = MagicMock()
            mock_entry.memory_id = "mem_xyz789"
            mock_tree = MagicMock()
            mock_tree.store.return_value = mock_entry

            with patch("familiar.core.files.get_file_store", return_value=mock_store):
                with patch(
                    "familiar.core.memory_tree.get_memory_tree",
                    return_value=mock_tree,
                ):
                    fid, mid = _index_in_filestore(result, route_result)

            assert fid == "file_abc123"
            assert mid == "mem_xyz789"

            # Verify FileStore.store was called with correct args
            store_call = mock_store.store.call_args
            assert store_call.kwargs["filename"] == Path(filepath).name
            assert store_call.kwargs["record_type"] == "intake"
            assert b"%PDF-1.0 test content" in store_call.kwargs["data"]

            # Verify MemoryTree.store was called with extracted text
            tree_call = mock_tree.store.call_args
            assert "Invoice from ACME" in tree_call.kwargs["body"]
            assert "intake" in tree_call.kwargs["tags"]
            assert tree_call.kwargs["meta"]["content_type"] == "invoice"

            # Verify cross-link was created
            mock_store.add_mention.assert_called_once_with(
                file_id="file_abc123",
                memory_id="mem_xyz789",
                context="Intake: Invoice",
            )
        finally:
            os.unlink(filepath)

    def test_index_in_filestore_handles_filestore_error(self):
        """Should still create MemoryTree node if FileStore fails."""
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import RouteResult, _index_in_filestore

        with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
            f.write(b"test content")
            f.flush()
            filepath = f.name

        try:
            result = ClassificationResult(
                content_type=ContentType.GENERAL_DOCUMENT,
                confidence=0.5,
                extracted_text="test text",
                source_file=filepath,
            )
            route_result = RouteResult(success=True, destination="notes", summary="test")

            mock_entry = MagicMock()
            mock_entry.memory_id = "mem_fallback"
            mock_tree = MagicMock()
            mock_tree.store.return_value = mock_entry

            with patch(
                "familiar.core.files.get_file_store",
                side_effect=Exception("FileStore unavailable"),
            ):
                with patch(
                    "familiar.core.memory_tree.get_memory_tree",
                    return_value=mock_tree,
                ):
                    fid, mid = _index_in_filestore(result, route_result)

            assert fid is None
            assert mid == "mem_fallback"
        finally:
            os.unlink(filepath)

    def test_index_in_filestore_handles_memorytree_error(self):
        """Should still return file_id if MemoryTree fails."""
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import RouteResult, _index_in_filestore

        with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
            f.write(b"test content")
            f.flush()
            filepath = f.name

        try:
            result = ClassificationResult(
                content_type=ContentType.GENERAL_DOCUMENT,
                confidence=0.5,
                extracted_text="test text",
                source_file=filepath,
            )
            route_result = RouteResult(success=True, destination="notes", summary="test")

            mock_record = MagicMock()
            mock_record.file_id = "file_onlyfs"
            mock_store = MagicMock()
            mock_store.store.return_value = mock_record

            with patch("familiar.core.files.get_file_store", return_value=mock_store):
                with patch(
                    "familiar.core.memory_tree.get_memory_tree",
                    side_effect=Exception("MemoryTree unavailable"),
                ):
                    fid, mid = _index_in_filestore(result, route_result)

            assert fid == "file_onlyfs"
            assert mid is None
        finally:
            os.unlink(filepath)

    def test_route_populates_file_and_memory_ids(self):
        """route() should set file_id and memory_id on RouteResult."""
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
            f.write(b"general text")
            f.flush()
            filepath = f.name

        try:
            result = ClassificationResult(
                content_type=ContentType.GENERAL_DOCUMENT,
                confidence=0.5,
                extracted_text="general text",
                source_file=filepath,
            )

            with patch(
                "familiar.skills.intake.router._index_in_filestore",
                return_value=("file_123", "mem_456"),
            ):
                with patch(
                    "familiar.skills.nonprofit.skill.add_note",
                    return_value="ok",
                ):
                    route_result = route(result)
                    assert route_result.file_id == "file_123"
                    assert route_result.memory_id == "mem_456"
        finally:
            os.unlink(filepath)

    def test_dry_run_skips_indexing(self):
        """Dry run should not index in FileStore or MemoryTree."""
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import route

        result = ClassificationResult(
            content_type=ContentType.INVOICE,
            confidence=0.8,
            extracted_text="test",
        )

        with patch("familiar.skills.intake.router._index_in_filestore") as mock_index:
            route_result = route(result, dry_run=True)
            mock_index.assert_not_called()
            assert route_result.file_id is None
            assert route_result.memory_id is None

    def test_extracted_fields_in_memory_body(self):
        """MemoryTree body should include extracted fields for searchability."""
        from familiar.skills.intake.classifiers import ClassificationResult, ContentType
        from familiar.skills.intake.router import RouteResult, _index_in_filestore

        with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
            f.write(b"\x89PNG fake image")
            f.flush()
            filepath = f.name

        try:
            result = ClassificationResult(
                content_type=ContentType.DONATION_RECEIPT,
                confidence=0.9,
                extracted_text="Thank you for your donation",
                extracted_fields={
                    "donor_name": "Jane Doe",
                    "amount": "$1000",
                    "date": "2026-03-15",
                },
                source_file=filepath,
            )
            route_result = RouteResult(
                success=True,
                destination="nonprofit.donors",
                summary="test",
            )

            mock_record = MagicMock()
            mock_record.file_id = "file_test"
            mock_store = MagicMock()
            mock_store.store.return_value = mock_record

            mock_entry = MagicMock()
            mock_entry.memory_id = "mem_test"
            mock_tree = MagicMock()
            mock_tree.store.return_value = mock_entry

            with patch("familiar.core.files.get_file_store", return_value=mock_store):
                with patch(
                    "familiar.core.memory_tree.get_memory_tree",
                    return_value=mock_tree,
                ):
                    _index_in_filestore(result, route_result)

            tree_call = mock_tree.store.call_args
            body = tree_call.kwargs["body"]
            assert "Jane Doe" in body
            assert "$1000" in body
            assert "2026-03-15" in body
            assert "Extracted Fields" in body
        finally:
            os.unlink(filepath)

    def test_job_class_detection(self):
        """Should detect active job class for FileStore organization."""
        from familiar.skills.intake.router import _get_active_job_key

        # When no job class active, should return "general"
        with patch("familiar.jobs.get_active_job_class", return_value=None):
            assert _get_active_job_key() == "general"

        # When job class active, should return its key
        mock_jc = MagicMock()
        mock_jc.key = "nonprofit_director"
        with patch("familiar.jobs.get_active_job_class", return_value=mock_jc):
            assert _get_active_job_key() == "nonprofit_director"

    def test_smart_intake_shows_indexing_info(self):
        """smart_intake response should show FileStore and MemoryTree IDs."""
        from familiar.skills.intake.skill import smart_intake

        with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
            f.write(b"Some general text content for testing")
            f.flush()
            filepath = f.name

        try:
            mock_route_result = MagicMock()
            mock_route_result.success = True
            mock_route_result.destination = "notes"
            mock_route_result.summary = "Saved as note"
            mock_route_result.record_id = None
            mock_route_result.file_id = "abcdef12-3456"
            mock_route_result.memory_id = "xyz78901-2345"

            with patch(
                "familiar.skills.intake.skill.extract_text",
                return_value=("test text", "text"),
            ):
                with patch(
                    "familiar.skills.intake.classifiers.classify",
                    return_value=MagicMock(
                        content_type=MagicMock(value="general_document"),
                        confidence=0.5,
                        method="keyword",
                        extracted_fields={},
                    ),
                ):
                    with patch(
                        "familiar.skills.intake.router.route",
                        return_value=mock_route_result,
                    ):
                        result = smart_intake({"filepath": filepath})
                        assert "FileStore" in result
                        assert "abcdef12" in result
                        assert "MemoryTree" in result
                        assert "xyz78901" in result
        finally:
            os.unlink(filepath)
