# 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 the Scribe job class and all supporting modules:
  - Job class registration (Sprint 1)
  - Review queue CRUD (Sprint 1-2)
  - Workspace config + data source (Sprint 3)
  - Folder watcher (Sprint 4)
  - Web clipper (Sprint 5)
  - Extraction templates (Sprint 6)
  - Dashboard API endpoints (Sprint 7)
  - Cross-job integration + onboarding (Sprint 8)
  - Tool registration (Sprint 9)
  - End-to-end pipeline (Sprint 10)
"""

from unittest.mock import MagicMock, patch

import pytest

# ── Fixtures ──────────────────────────────────────────────────────────


@pytest.fixture
def tmp_data_dir(tmp_path):
    """Provide a temp data directory and patch DATA_DIR."""
    with patch("familiar.core.paths.DATA_DIR", tmp_path):
        yield tmp_path


@pytest.fixture
def review_queue(tmp_path):
    """Fresh ReviewQueue with temp storage."""
    from familiar.skills.intake.review import ReviewQueue

    return ReviewQueue(data_file=tmp_path / "test_queue.json")


@pytest.fixture
def folder_watcher(tmp_path):
    """Fresh FolderWatcher with temp storage."""
    from familiar.skills.intake.watcher import FolderWatcher

    return FolderWatcher(config_file=tmp_path / "test_watch.json")


@pytest.fixture
def web_clipper(tmp_path):
    """Fresh WebClipper with temp storage."""
    from familiar.skills.intake.clipper import WebClipper

    clipper = WebClipper(meta_file=tmp_path / "test_clips.json")
    clipper._clips_dir = tmp_path / "clips"
    clipper._clips_dir.mkdir(parents=True, exist_ok=True)
    return clipper


@pytest.fixture
def template_store(tmp_path):
    """Fresh TemplateStore with temp storage."""
    from familiar.skills.intake.templates import TemplateStore

    return TemplateStore(data_file=tmp_path / "test_templates.json")


# ============================================================
# Sprint 1 — Job class registration
# ============================================================


class TestScribeJobClass:
    """Test Scribe job class registration and configuration."""

    def test_scribe_registered(self):
        from familiar.jobs import JOB_CLASS_REGISTRY

        assert "scribe" in JOB_CLASS_REGISTRY

    def test_scribe_key(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        assert jc is not None
        assert jc.key == "scribe"

    def test_scribe_name(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        assert jc.name == "Scribe"

    def test_scribe_icon(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        assert jc.icon == "fa-pen-nib"

    def test_scribe_not_hidden(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        assert not jc.hidden

    def test_scribe_in_list(self):
        from familiar.jobs import list_job_classes

        keys = [jc.key for jc in list_job_classes()]
        assert "scribe" in keys

    def test_scribe_has_workspace(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        assert jc.workspace
        assert jc.workspace["label"] == "Records Office"

    def test_scribe_has_skill_weights(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        assert "smart_intake" in jc.skill_weights
        assert "review_intake" in jc.skill_weights
        assert jc.skill_weights["smart_intake"] == 1.0

    def test_scribe_has_species_flavor(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        assert len(jc.species_flavor) >= 5

    def test_scribe_has_proactive_focus(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        assert len(jc.proactive_focus) >= 3

    def test_scribe_has_recommended_connects(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        assert "nextcloud" in jc.recommended_connects

    def test_scribe_workspace_has_overview_cards(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        cards = jc.workspace.get("overview", {}).get("cards", [])
        assert len(cards) >= 6

    def test_scribe_workspace_has_sections(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        sections = jc.workspace.get("sections", [])
        assert len(sections) >= 6
        keys = {s["key"] for s in sections}
        assert "inbox" in keys
        assert "review_queue" in keys
        assert "statistics" in keys

    def test_scribe_workspace_has_quick_actions(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        actions = jc.workspace.get("quick_actions", [])
        assert len(actions) >= 5


# ============================================================
# Sprint 1-2 — Review queue
# ============================================================


class TestReviewQueue:
    """Test ReviewQueue JSON store."""

    def test_init_creates_file(self, review_queue):
        assert review_queue._file.exists()

    def test_add_for_review(self, review_queue):
        item_id = review_queue.add_for_review(
            source_file="/tmp/test.pdf",
            suggested_type="donation_receipt",
            confidence=0.3,
            extracted_text="Thank you for your donation",
        )
        assert item_id.startswith("review_")

    def test_list_pending(self, review_queue):
        review_queue.add_for_review(
            source_file="/tmp/a.pdf",
            suggested_type="unknown",
            confidence=0.2,
            extracted_text="test",
        )
        review_queue.add_for_review(
            source_file="/tmp/b.pdf",
            suggested_type="recipe",
            confidence=0.4,
            extracted_text="ingredients",
        )
        pending = review_queue.list_pending()
        assert len(pending) == 2
        assert pending[0]["source_file"] == "/tmp/b.pdf"  # most recent first

    def test_approve(self, review_queue):
        item_id = review_queue.add_for_review(
            source_file="/tmp/test.pdf",
            suggested_type="unknown",
            confidence=0.2,
            extracted_text="test content",
        )
        result = review_queue.approve(item_id)
        assert result is not None
        assert result["status"] == "approved"
        # Should no longer be in pending
        assert len(review_queue.list_pending()) == 0

    def test_approve_with_correction(self, review_queue):
        item_id = review_queue.add_for_review(
            source_file="/tmp/test.pdf",
            suggested_type="unknown",
            confidence=0.2,
            extracted_text="test",
        )
        result = review_queue.approve(item_id, corrected_type="donation_receipt")
        assert result["corrected_type"] == "donation_receipt"

    def test_reject(self, review_queue):
        item_id = review_queue.add_for_review(
            source_file="/tmp/test.pdf",
            suggested_type="unknown",
            confidence=0.2,
            extracted_text="test",
        )
        result = review_queue.reject(item_id, reason="Not useful")
        assert result is not None
        assert result["status"] == "rejected"
        assert result["reject_reason"] == "Not useful"

    def test_reject_nonexistent(self, review_queue):
        result = review_queue.reject("nonexistent_id")
        assert result is None

    def test_approve_already_processed(self, review_queue):
        item_id = review_queue.add_for_review(
            source_file="/tmp/test.pdf",
            suggested_type="unknown",
            confidence=0.2,
            extracted_text="test",
        )
        review_queue.approve(item_id)
        result = review_queue.approve(item_id)  # Try again
        assert result is None

    def test_get_stats(self, review_queue):
        id1 = review_queue.add_for_review(
            source_file="/tmp/a.pdf",
            suggested_type="unknown",
            confidence=0.2,
            extracted_text="a",
        )
        id2 = review_queue.add_for_review(
            source_file="/tmp/b.pdf",
            suggested_type="recipe",
            confidence=0.3,
            extracted_text="b",
        )
        review_queue.approve(id1)
        review_queue.reject(id2, reason="bad")
        stats = review_queue.get_stats()
        assert stats["approved"] == 1
        assert stats["rejected"] == 1
        assert stats["pending"] == 0

    def test_needs_review_low_confidence(self, review_queue):
        assert review_queue.needs_review(0.3, "donation_receipt") is True

    def test_needs_review_unknown_type(self, review_queue):
        assert review_queue.needs_review(0.8, "unknown") is True

    def test_no_review_high_confidence(self, review_queue):
        assert review_queue.needs_review(0.7, "donation_receipt") is False

    def test_queue_cap(self, review_queue):
        from familiar.skills.intake.review import MAX_QUEUE_SIZE

        for i in range(MAX_QUEUE_SIZE + 10):
            review_queue.add_for_review(
                source_file=f"/tmp/file_{i}.pdf",
                suggested_type="unknown",
                confidence=0.1,
                extracted_text=f"text {i}",
            )
        pending = review_queue.list_pending(limit=1000)
        assert len(pending) <= MAX_QUEUE_SIZE

    def test_clear_pending(self, review_queue):
        review_queue.add_for_review(
            source_file="/tmp/test.pdf",
            suggested_type="unknown",
            confidence=0.2,
            extracted_text="test",
        )
        review_queue.clear_pending()
        assert len(review_queue.list_pending()) == 0

    def test_get_item(self, review_queue):
        item_id = review_queue.add_for_review(
            source_file="/tmp/test.pdf",
            suggested_type="recipe",
            confidence=0.3,
            extracted_text="ingredients",
        )
        item = review_queue.get_item(item_id)
        assert item is not None
        assert item["suggested_type"] == "recipe"


# ============================================================
# Sprint 2 — Classification needs_review flag
# ============================================================


class TestClassificationReview:
    """Test needs_review flag in classification pipeline."""

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

        result = classify("some random text with no keywords", use_llm=False)
        # general_document at 0.3 confidence → needs review
        assert result.needs_review is True

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

        text = "donation gift tax-deductible charitable contribution donor 501(c) EIN receipt"
        result = classify(text, use_llm=False)
        assert result.confidence >= 0.5
        assert result.needs_review is False


# ============================================================
# Sprint 3 — Workspace config validation
# ============================================================


class TestScribeWorkspace:
    """Test Scribe workspace configuration."""

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

        assert "scribe" in _WORKSPACE_SOURCES

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

        loader = _WORKSPACE_SOURCES["scribe"]
        data = loader()
        assert "inbox_entries" in data
        assert "review_items" in data
        assert "inbox_count" in data
        assert "review_count" in data
        assert "total_processed" in data
        assert "accuracy_pct" in data
        assert "watch_count" in data

    def test_workspace_sections_have_source(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        for section in jc.workspace.get("sections", []):
            assert "source" in section
            assert section["source"] == "scribe"

    def test_workspace_cards_have_metrics(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        cards = jc.workspace["overview"]["cards"]
        metric_cards = [c for c in cards if c.get("metric")]
        assert len(metric_cards) >= 6


# ============================================================
# Sprint 4 — Folder watcher
# ============================================================


class TestFolderWatcher:
    """Test FolderWatcher poll-based file monitoring."""

    def test_init_creates_file(self, folder_watcher):
        assert folder_watcher._file.exists()

    def test_add_watch(self, folder_watcher, tmp_path):
        watch_dir = tmp_path / "inbox"
        watch_dir.mkdir()
        watch_id = folder_watcher.add_watch(str(watch_dir))
        assert watch_id.startswith("watch_")

    def test_add_watch_nonexistent_dir(self, folder_watcher):
        with pytest.raises(ValueError, match="Not a directory"):
            folder_watcher.add_watch("/nonexistent/directory/abc123")

    def test_remove_watch(self, folder_watcher, tmp_path):
        watch_dir = tmp_path / "inbox"
        watch_dir.mkdir()
        watch_id = folder_watcher.add_watch(str(watch_dir))
        assert folder_watcher.remove_watch(watch_id) is True
        assert len(folder_watcher.list_watches()) == 0

    def test_remove_nonexistent(self, folder_watcher):
        assert folder_watcher.remove_watch("nonexistent") is False

    def test_list_watches(self, folder_watcher, tmp_path):
        for i in range(3):
            d = tmp_path / f"dir_{i}"
            d.mkdir()
            folder_watcher.add_watch(str(d), label=f"Watch {i}")
        watches = folder_watcher.list_watches()
        assert len(watches) == 3

    def test_duplicate_watch_returns_same_id(self, folder_watcher, tmp_path):
        watch_dir = tmp_path / "inbox"
        watch_dir.mkdir()
        id1 = folder_watcher.add_watch(str(watch_dir))
        id2 = folder_watcher.add_watch(str(watch_dir))
        assert id1 == id2
        assert len(folder_watcher.list_watches()) == 1

    @patch("familiar.skills.intake.skill._ALL_SUPPORTED", {".txt", ".pdf"})
    def test_check_finds_new_files(self, folder_watcher, tmp_path):
        watch_dir = tmp_path / "inbox"
        watch_dir.mkdir()
        folder_watcher.add_watch(str(watch_dir))

        # Create a supported file
        (watch_dir / "test.txt").write_text("hello world")
        new_files = folder_watcher.check_watches()
        assert len(new_files) == 1
        assert new_files[0]["filepath"].endswith("test.txt")

    @patch("familiar.skills.intake.skill._ALL_SUPPORTED", {".txt", ".pdf"})
    def test_check_ignores_seen_files(self, folder_watcher, tmp_path):
        watch_dir = tmp_path / "inbox"
        watch_dir.mkdir()
        folder_watcher.add_watch(str(watch_dir))

        (watch_dir / "test.txt").write_text("hello world")
        folder_watcher.check_watches()  # First check sees it
        new_files = folder_watcher.check_watches()  # Second check skips it
        assert len(new_files) == 0

    @patch("familiar.skills.intake.skill._ALL_SUPPORTED", {".txt", ".pdf"})
    def test_check_recursive(self, folder_watcher, tmp_path):
        watch_dir = tmp_path / "inbox"
        sub_dir = watch_dir / "subdir"
        sub_dir.mkdir(parents=True)
        folder_watcher.add_watch(str(watch_dir), recursive=True)

        (sub_dir / "deep.txt").write_text("nested file")
        new_files = folder_watcher.check_watches()
        assert len(new_files) == 1

    @patch("familiar.skills.intake.skill._ALL_SUPPORTED", {".txt", ".pdf"})
    def test_check_file_type_filter(self, folder_watcher, tmp_path):
        watch_dir = tmp_path / "inbox"
        watch_dir.mkdir()
        folder_watcher.add_watch(str(watch_dir), file_types=[".pdf"])

        (watch_dir / "test.txt").write_text("text file")
        (watch_dir / "test.pdf").write_bytes(b"fake pdf")
        new_files = folder_watcher.check_watches()
        assert len(new_files) == 1
        assert new_files[0]["filepath"].endswith("test.pdf")

    def test_max_watches_cap(self, folder_watcher, tmp_path):
        from familiar.skills.intake.watcher import MAX_WATCHES

        for i in range(MAX_WATCHES):
            d = tmp_path / f"dir_{i}"
            d.mkdir()
            folder_watcher.add_watch(str(d))
        extra_dir = tmp_path / "extra"
        extra_dir.mkdir()
        with pytest.raises(ValueError, match="Maximum"):
            folder_watcher.add_watch(str(extra_dir))

    def test_get_watch(self, folder_watcher, tmp_path):
        watch_dir = tmp_path / "inbox"
        watch_dir.mkdir()
        watch_id = folder_watcher.add_watch(str(watch_dir), label="My Inbox")
        watch = folder_watcher.get_watch(watch_id)
        assert watch is not None
        assert watch["label"] == "My Inbox"


# ============================================================
# Sprint 5 — Web clipper
# ============================================================


class TestWebClipper:
    """Test WebClipper web page clipping."""

    def test_extract_text_from_html(self):
        from familiar.skills.intake.clipper import _extract_text_from_html

        html = "<html><body><p>Hello world</p><script>bad();</script></body></html>"
        text = _extract_text_from_html(html)
        assert "Hello world" in text
        assert "bad()" not in text

    def test_extract_title(self):
        from familiar.skills.intake.clipper import _extract_title

        html = "<html><head><title>Test Page</title></head></html>"
        assert _extract_title(html) == "Test Page"

    def test_extract_title_empty(self):
        from familiar.skills.intake.clipper import _extract_title

        assert _extract_title("<html></html>") == ""

    @patch("httpx.get")
    def test_clip_success(self, mock_get, web_clipper):
        mock_resp = MagicMock()
        mock_resp.text = (
            "<html><head><title>Grant Opportunity</title></head>"
            "<body><p>This is a grant document about funding and proposals. "
            "The RFP deadline is March 15. Budget justification required. "
            "Award amount up to $50,000. Deliverables include quarterly reports.</p></body></html>"
        )
        mock_resp.status_code = 200
        mock_resp.raise_for_status = MagicMock()
        mock_get.return_value = mock_resp

        result = web_clipper.clip("https://example.com/grant", dry_run=True)
        assert result.get("success") is not None or result.get("error") is None
        assert result.get("title") == "Grant Opportunity"

    @patch("httpx.get")
    def test_clip_saves_markdown(self, mock_get, web_clipper):
        mock_resp = MagicMock()
        mock_resp.text = (
            "<html><head><title>Recipe Page</title></head>"
            "<body><p>A delicious recipe with ingredients, instructions, "
            "preheat oven to 350, 4 servings, tablespoon of butter, "
            "prep time 20 minutes, cook time 45 minutes.</p></body></html>"
        )
        mock_resp.status_code = 200
        mock_resp.raise_for_status = MagicMock()
        mock_get.return_value = mock_resp

        result = web_clipper.clip("https://example.com/recipe")
        assert not result.get("error")
        # Check file was saved
        clips = web_clipper.list_clips()
        assert len(clips) == 1

    @patch("httpx.get")
    def test_clip_error_handling(self, mock_get, web_clipper):
        mock_get.side_effect = Exception("Connection failed")
        result = web_clipper.clip("https://bad-url.example.com")
        assert result.get("error") is not None
        assert result.get("success") is False

    @patch("httpx.get")
    def test_clip_too_little_text(self, mock_get, web_clipper):
        mock_resp = MagicMock()
        mock_resp.text = "<html><body>Hi</body></html>"
        mock_resp.status_code = 200
        mock_resp.raise_for_status = MagicMock()
        mock_get.return_value = mock_resp

        result = web_clipper.clip("https://example.com/short")
        assert result.get("error") is not None

    def test_list_clips_empty(self, web_clipper):
        assert web_clipper.list_clips() == []

    @patch("httpx.get")
    def test_clip_dry_run_no_save(self, mock_get, web_clipper):
        mock_resp = MagicMock()
        mock_resp.text = (
            "<html><head><title>Test</title></head>"
            "<body><p>Enough text content here to meet the minimum threshold "
            "for clipping which is fifty characters at least.</p></body></html>"
        )
        mock_resp.status_code = 200
        mock_resp.raise_for_status = MagicMock()
        mock_get.return_value = mock_resp

        result = web_clipper.clip("https://example.com/test", dry_run=True)
        assert result.get("dry_run") is True
        assert len(web_clipper.list_clips()) == 0

    def test_get_clip_nonexistent(self, web_clipper):
        assert web_clipper.get_clip("nonexistent") is None


# ============================================================
# Sprint 6 — Extraction templates
# ============================================================


class TestExtractionTemplates:
    """Test ExtractionTemplate and TemplateStore."""

    def test_init_creates_file(self, template_store):
        assert template_store._file.exists()

    def test_add_template(self, template_store):
        tmpl_id = template_store.add_template(
            content_type="donation_receipt",
            patterns=["Thank you for your generous", "tax-deductible"],
            field_patterns={"amount": "Amount:", "donor": "From:"},
        )
        assert tmpl_id.startswith("tmpl_")

    def test_list_templates(self, template_store):
        template_store.add_template("recipe", ["Ingredients", "Preheat"])
        template_store.add_template("invoice", ["Amount Due", "Bill To"])
        templates = template_store.list_templates()
        assert len(templates) == 2

    def test_match_template(self, template_store):
        template_store.add_template(
            content_type="donation_receipt",
            patterns=["Thank you", "donation", "tax-deductible"],
            field_patterns={"amount": "Amount:"},
        )
        text = "Thank you for your donation of $100. This is tax-deductible. Amount: $100"
        result = template_store.match_templates(text)
        assert result is not None
        content_type, fields, tmpl_id = result
        assert content_type == "donation_receipt"

    def test_no_match_below_threshold(self, template_store):
        template_store.add_template(
            content_type="recipe",
            patterns=["ingredients", "preheat", "servings", "tablespoon", "bake"],
        )
        text = "This document mentions ingredients but nothing else from the pattern."
        result = template_store.match_templates(text)
        assert result is None

    def test_use_count_tracking(self, template_store):
        template_store.add_template(
            content_type="recipe",
            patterns=["ingredients", "preheat", "servings"],
        )
        text = "Recipe with ingredients, preheat oven, 4 servings"
        template_store.match_templates(text)
        template_store.match_templates(text)
        templates = template_store.list_templates()
        assert templates[0]["use_count"] == 2

    def test_learn_from_correction(self, template_store):
        text = "Donor: John Smith\nAmount: $500\nDate: 2026-01-15\nThank you for your gift."
        tmpl_id = template_store.learn_from_correction(
            content_type="donation_receipt",
            text=text,
            fields={"donor": "John Smith", "amount": "$500"},
        )
        assert tmpl_id is not None
        templates = template_store.list_templates()
        assert len(templates) == 1
        assert templates[0]["content_type"] == "donation_receipt"

    def test_learn_no_matches_in_text(self, template_store):
        tmpl_id = template_store.learn_from_correction(
            content_type="recipe",
            text="Some completely unrelated text",
            fields={"name": "Banana Bread"},
        )
        assert tmpl_id is None

    def test_template_cap(self, template_store):
        from familiar.skills.intake.templates import MAX_TEMPLATES

        for i in range(MAX_TEMPLATES + 5):
            template_store.add_template(f"type_{i}", [f"pattern_{i}"])
        templates = template_store.list_templates(limit=1000)
        assert len(templates) <= MAX_TEMPLATES

    def test_remove_template(self, template_store):
        tmpl_id = template_store.add_template("recipe", ["ingredients"])
        assert template_store.remove_template(tmpl_id) is True
        assert len(template_store.list_templates()) == 0

    def test_remove_nonexistent(self, template_store):
        assert template_store.remove_template("nonexistent") is False

    def test_get_template(self, template_store):
        tmpl_id = template_store.add_template(
            content_type="invoice",
            patterns=["Amount Due", "Bill To"],
        )
        tmpl = template_store.get_template(tmpl_id)
        assert tmpl is not None
        assert tmpl["content_type"] == "invoice"


# ============================================================
# Sprint 7 — Dashboard API endpoints
# ============================================================


class TestScribeAPI:
    """Test Scribe dashboard API endpoint structure."""

    def test_api_endpoints_exist(self):
        """Verify all 12 scribe endpoints are registered."""
        from familiar.dashboard.app import app

        rules = {rule.rule for rule in app.url_map.iter_rules()}
        expected = [
            "/api/scribe/inbox",
            "/api/scribe/inbox/process",
            "/api/scribe/review",
            "/api/scribe/review/<item_id>/approve",
            "/api/scribe/review/<item_id>/reject",
            "/api/scribe/search",
            "/api/scribe/stats",
            "/api/scribe/watches",
            "/api/scribe/clips",
            "/api/scribe/templates",
        ]
        for endpoint in expected:
            assert endpoint in rules, f"Missing endpoint: {endpoint}"

    def test_watches_delete_endpoint(self):
        """Verify DELETE watches/<id> endpoint."""
        from familiar.dashboard.app import app

        rules = {rule.rule for rule in app.url_map.iter_rules()}
        assert "/api/scribe/watches/<watch_id>" in rules


# ============================================================
# Sprint 8 — Cross-job integration + onboarding
# ============================================================


class TestCrossJobIntegration:
    """Test intake weights across all job classes and onboarding."""

    def test_all_visible_jobs_have_smart_intake(self):
        from familiar.jobs import list_job_classes

        for jc in list_job_classes():
            assert "smart_intake" in jc.skill_weights, f"{jc.key} missing smart_intake weight"

    def test_all_visible_jobs_have_review_intake(self):
        from familiar.jobs import list_job_classes

        for jc in list_job_classes():
            assert "review_intake" in jc.skill_weights, f"{jc.key} missing review_intake weight"

    def test_scribe_has_highest_intake_weight(self):
        from familiar.jobs import list_job_classes

        for jc in list_job_classes():
            if jc.key != "scribe":
                assert jc.skill_weights.get("smart_intake", 0) <= 0.8
            else:
                assert jc.skill_weights["smart_intake"] == 1.0

    def test_scribe_in_onboard_service_map(self):
        from familiar.onboard_engine import OnboardingEngine

        engine = OnboardingEngine.__new__(OnboardingEngine)
        assert "scribe" in engine._JOB_SERVICE_MAP
        assert "nextcloud" in engine._JOB_SERVICE_MAP["scribe"]

    def test_onboarding_scribe_recommendations(self):
        from familiar.onboard_engine import OnboardingEngine

        engine = OnboardingEngine.__new__(OnboardingEngine)
        recs = engine._JOB_SERVICE_MAP.get("scribe", [])
        assert "email_server" in recs
        assert "searxng" in recs


# ============================================================
# Sprint 9 — Tool registration
# ============================================================


class TestToolRegistration:
    """Test all 8 intake tools are registered with valid schemas."""

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

        names = {t["name"] for t in TOOLS}
        expected = {
            "smart_intake",
            "batch_intake",
            "intake_history",
            "review_intake",
            "watch_folder",
            "check_watches",
            "clip_webpage",
            "list_templates",
        }
        assert expected.issubset(names)

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

        assert len(TOOLS) == 8

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

        for tool in TOOLS:
            assert callable(tool.get("handler")), f"{tool['name']} missing handler"

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

        for tool in TOOLS:
            assert "input_schema" in tool, f"{tool['name']} missing input_schema"
            assert tool["input_schema"]["type"] == "object"

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

        for tool in TOOLS:
            assert tool.get("category") == "intake"

    def test_scribe_prompt_mentions_cross_job(self):
        from familiar.jobs import get_job_class

        jc = get_job_class("scribe")
        assert "cross-job" in jc.prompt_fragment.lower() or "cross" in jc.prompt_fragment.lower()


# ============================================================
# Sprint 10 — End-to-end tests
# ============================================================


class TestEndToEnd:
    """End-to-end pipeline integration tests."""

    def test_review_queue_process_decision(self, review_queue):
        """Full review → approve → re-route pipeline."""
        item_id = review_queue.add_for_review(
            source_file="/tmp/test.pdf",
            suggested_type="unknown",
            confidence=0.2,
            extracted_text="Donor: Jane Doe\nAmount: $1000\nTax-deductible receipt",
        )
        from familiar.skills.intake.review import process_review_decision

        with patch("familiar.skills.intake.review.get_review_queue", return_value=review_queue):
            result = process_review_decision(
                item_id=item_id,
                action="approve",
                corrected_type="donation_receipt",
            )
        assert "Approved" in result
        assert "donation_receipt" in result

    def test_review_reject_flow(self, review_queue):
        item_id = review_queue.add_for_review(
            source_file="/tmp/junk.pdf",
            suggested_type="unknown",
            confidence=0.1,
            extracted_text="garbled text",
        )
        from familiar.skills.intake.review import process_review_decision

        with patch("familiar.skills.intake.review.get_review_queue", return_value=review_queue):
            result = process_review_decision(
                item_id=item_id,
                action="reject",
                reason="Not a document",
            )
        assert "Rejected" in result

    def test_template_learning_from_correction(self, template_store, review_queue):
        """Approve with correction → template learned."""
        text = "Organization: ACME Corp\nGrant Amount: $50,000\nDeadline: 2026-06-01"
        item_id = review_queue.add_for_review(
            source_file="/tmp/grant.pdf",
            suggested_type="unknown",
            confidence=0.2,
            extracted_text=text,
        )
        with patch(
            "familiar.skills.intake.templates.get_template_store", return_value=template_store
        ):
            with patch("familiar.skills.intake.review.get_review_queue", return_value=review_queue):
                from familiar.skills.intake.review import process_review_decision

                process_review_decision(
                    item_id=item_id,
                    action="approve",
                    corrected_type="grant_document",
                    corrected_fields={"organization": "ACME Corp", "amount": "$50,000"},
                )

        # Template should have been learned
        templates = template_store.list_templates()
        assert len(templates) >= 1

    @patch("familiar.skills.intake.skill._ALL_SUPPORTED", {".txt"})
    def test_watch_to_auto_intake(self, folder_watcher, tmp_path):
        """Watch folder → new file → check_watches detects it."""
        watch_dir = tmp_path / "inbox"
        watch_dir.mkdir()
        folder_watcher.add_watch(str(watch_dir))

        (watch_dir / "receipt.txt").write_text(
            "Thank you for your donation of $500. Tax-deductible. EIN 12-3456789."
        )
        new_files = folder_watcher.check_watches()
        assert len(new_files) == 1
        assert "receipt.txt" in new_files[0]["filepath"]

    def test_review_handler_list(self):
        """Test review_intake tool handler for list action."""
        from familiar.skills.intake.skill import review_intake

        with patch("familiar.skills.intake.review.get_review_queue") as mock_queue:
            mock_q = MagicMock()
            mock_q.list_pending.return_value = []
            mock_queue.return_value = mock_q
            result = review_intake({"action": "list"})
            assert "empty" in result.lower() or "queue" in result.lower()

    def test_watch_folder_handler_list(self):
        """Test watch_folder tool handler for list action."""
        from familiar.skills.intake.skill import watch_folder as wf_handler

        with patch("familiar.skills.intake.watcher.get_folder_watcher") as mock_watcher:
            mock_w = MagicMock()
            mock_w.list_watches.return_value = []
            mock_watcher.return_value = mock_w
            result = wf_handler({"action": "list"})
            assert "No watch folders" in result

    def test_list_templates_handler(self):
        """Test list_templates tool handler."""
        from familiar.skills.intake.skill import list_templates as lt_handler

        with patch("familiar.skills.intake.templates.get_template_store") as mock_store:
            mock_s = MagicMock()
            mock_s.list_templates.return_value = []
            mock_store.return_value = mock_s
            result = lt_handler({})
            assert "No extraction templates" in result

    def test_classification_template_integration(self, template_store):
        """Template match boosts confidence in classify()."""
        template_store.add_template(
            content_type="donation_receipt",
            patterns=["generous gift", "tax-deductible", "501(c)(3)"],
            field_patterns={"amount": "Amount:"},
        )
        text = (
            "Thank you for your generous gift. This is tax-deductible under 501(c)(3). Amount: $250"
        )
        with patch(
            "familiar.skills.intake.templates.get_template_store", return_value=template_store
        ):
            from familiar.skills.intake.classifiers import classify

            result = classify(text, use_llm=False)
            assert result.content_type.value == "donation_receipt"
            # Template should have boosted or keyword should have matched
            assert result.confidence > 0.0
