Feature: UKO Indexer — Real-time Index Synchronization
  As a context pipeline consumer
  I need the UKOIndexer to analyze resources and index into backends
  So that context strategies can query pre-built indices

  Background:
    Given idx a clean analyzer registry
    And idx in-memory index backends

  # =================================================================
  # Index backend protocols and stubs
  # =================================================================

  Scenario: InMemoryTextIndexBackend indexes and searches documents
    When idx I index a text document "doc1" with content "hello world" in project "local/app"
    Then idx searching "hello" in project "local/app" should return 1 result
    And idx the search result doc_id should be "doc1"

  Scenario: InMemoryTextIndexBackend returns empty for non-matching query
    When idx I index a text document "doc1" with content "hello world" in project "local/app"
    Then idx searching "foobar" in project "local/app" should return 0 results

  Scenario: InMemoryTextIndexBackend scopes by project
    When idx I index a text document "doc1" with content "hello world" in project "local/app"
    Then idx searching "hello" in project "local/other" should return 0 results

  Scenario: InMemoryTextIndexBackend removes documents
    When idx I index a text document "doc1" with content "hello world" in project "local/app"
    And idx I remove text document "doc1" from project "local/app"
    Then idx searching "hello" in project "local/app" should return 0 results

  Scenario: InMemoryTextIndexBackend rebuilds index
    When idx I index a text document "doc1" with content "hello world" in project "local/app"
    And idx I rebuild text index for project "local/app"
    Then idx searching "hello" in project "local/app" should return 0 results

  Scenario: InMemoryTextIndexBackend rejects empty project
    Then idx indexing a text document with empty project should raise ValueError

  Scenario: InMemoryTextIndexBackend rejects empty content
    Then idx indexing a text document with empty content should raise ValueError

  Scenario: InMemoryTextIndexBackend rejects empty query
    Then idx searching with empty query should raise ValueError

  Scenario: InMemoryTextIndexBackend rejects non-positive limit
    Then idx searching with limit 0 should raise ValueError

  Scenario: InMemoryVectorIndexBackend indexes and searches embeddings
    When idx I index an embedding "emb1" with vector "1.0,2.0,3.0" in project "local/app"
    Then idx searching similar in project "local/app" should return 1 result

  Scenario: InMemoryVectorIndexBackend removes embeddings
    When idx I index an embedding "emb1" with vector "1.0,2.0,3.0" in project "local/app"
    And idx I remove embedding "emb1" from project "local/app"
    Then idx searching similar in project "local/app" should return 0 results

  Scenario: InMemoryVectorIndexBackend rejects out-of-range min_relevance
    When idx I index an embedding "emb1" with vector "1.0,2.0,3.0" in project "local/app"
    Then idx searching similar with min_relevance 1.1 should raise ValueError

  Scenario: InMemoryVectorIndexBackend rejects empty embedding
    Then idx indexing an embedding with empty vector should raise ValueError

  Scenario: InMemoryGraphIndexBackend adds and queries triples
    When idx I add a triple "uko://s" "uko:type" "uko://Class" in project "local/app"
    Then idx querying project "local/app" should return 1 binding
    And idx the binding should have subject "uko://s"

  Scenario: InMemoryGraphIndexBackend removes triples by subject
    When idx I add a triple "uko://s" "uko:type" "uko://Class" in project "local/app"
    And idx I add a triple "uko://s" "uko:name" "Foo" in project "local/app"
    And idx I remove triples with subject "uko://s" from project "local/app"
    Then idx querying project "local/app" should return 0 bindings

  Scenario: InMemoryGraphIndexBackend rejects all-None pattern
    Then idx removing triples with all-None pattern should raise ValueError

  Scenario: InMemoryGraphIndexBackend rejects empty project
    Then idx adding a triple with empty project should raise ValueError

  # =================================================================
  # Provenance model
  # =================================================================

  Scenario: ProvenanceMetadata has required fields
    When idx I create provenance for resource "01HQ8ZDRX50000000000000001"
    Then idx the provenance should have source_resource "01HQ8ZDRX50000000000000001"
    And idx the provenance should be current
    And idx the provenance should have a valid_from timestamp

  Scenario: ProvenanceMetadata rejects empty source_resource
    Then idx creating provenance with empty source_resource should raise ValidationError

  Scenario: ProvenancedTriple wraps triple with provenance
    When idx I create a provenanced triple for resource "01HQ8ZDRX50000000000000001"
    Then idx the provenanced triple should have the triple
    And idx the provenanced triple should have the provenance

  Scenario: ProvenanceMetadata is frozen
    When idx I create provenance for resource "01HQ8ZDRX50000000000000001"
    Then idx mutating the provenance should raise ValidationError

  Scenario: IndexResult tracks indexing summary
    When idx I create an IndexResult with 5 triples and 1 text doc
    Then idx the IndexResult should have triple_count 5
    And idx the IndexResult should have text_docs_indexed 1
    And idx the IndexResult should have analyzer_domain "python"

  Scenario: IndexResult rejects negative triple_count
    Then idx creating IndexResult with negative triple_count should raise ValidationError

  # =================================================================
  # AnalyzerRegistry.get_for_resource
  # =================================================================

  Scenario: AnalyzerRegistry.get_for_resource finds analyzer by location extension
    Given idx a PythonAnalyzer is registered
    When idx I look up analyzer for a resource with location "src/main.py"
    Then idx the analyzer domain should be "python"

  Scenario: AnalyzerRegistry.get_for_resource returns None for unknown extension
    Given idx a PythonAnalyzer is registered
    When idx I look up analyzer for a resource with location "data.csv"
    Then idx no analyzer should be found

  Scenario: AnalyzerRegistry.get_for_resource returns None for no location
    Given idx a PythonAnalyzer is registered
    When idx I look up analyzer for a resource with no location
    Then idx no analyzer should be found

  Scenario: AnalyzerRegistry.get_for_resource finds MarkdownAnalyzer
    Given idx a MarkdownAnalyzer is registered
    When idx I look up analyzer for a resource with location "README.md"
    Then idx the analyzer domain should be "markdown"

  Scenario: AnalyzerRegistry higher-priority analyzer supersedes lower-priority
    Given idx a clean analyzer registry
    And idx a PythonAnalyzer is registered with priority 0
    And idx an alternative Python analyzer "python-advanced" is registered with priority 10
    When idx I look up analyzer for a resource with location "main.py"
    Then idx the analyzer domain should be "python-advanced"

  Scenario: AnalyzerRegistry lower-priority analyzer does not supersede higher-priority
    Given idx a clean analyzer registry
    And idx an alternative Python analyzer "python-advanced" is registered with priority 10
    And idx a PythonAnalyzer is registered with priority 0
    When idx I look up analyzer for a resource with location "main.py"
    Then idx the analyzer domain should be "python-advanced"

  # =================================================================
  # UKOIndexer core
  # =================================================================

  Scenario: UKOIndexer indexes a Python resource into all backends
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    Then idx the index result should have triple_count greater than 0
    And idx the index result should have text_docs_indexed 1
    And idx the index result should have embeddings_indexed 1
    And idx the index result should have analyzer_domain "python"
    And idx the index result should have 0 errors
    And idx the graph backend should have triples for project "local/test"
    And idx the text backend should have 1 document
    And idx the vector backend should have 1 embedding
    And idx the indexer should track 1 indexed resource

  Scenario: UKOIndexer indexes a Markdown resource
    Given idx a MarkdownAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index a Markdown resource "01HQ8ZDRX50000000000000002" with content "# Title\nSome text"
    Then idx the index result should have triple_count greater than 0
    And idx the index result should have analyzer_domain "markdown"

  Scenario: UKOIndexer returns none-domain when no analyzer matches
    And idx a UKOIndexer with all backends
    When idx I index a CSV resource "01HQ8ZDRX50000000000000003" with location "data.csv"
    Then idx the index result should have analyzer_domain "none"
    And idx the index result should have triple_count 0

  Scenario: UKOIndexer degrades gracefully without text backend
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer without text backend
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1"
    Then idx the index result should have text_docs_indexed 0
    And idx the index result should have 0 errors

  Scenario: UKOIndexer degrades gracefully without vector backend
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer without vector backend
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1"
    Then idx the index result should have embeddings_indexed 0
    And idx the index result should have 0 errors

  Scenario: UKOIndexer degrades gracefully without text and vector backends
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with only graph backend
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1"
    Then idx the index result should have text_docs_indexed 0
    And idx the index result should have embeddings_indexed 0
    And idx the index result should have triple_count greater than 0

  Scenario: UKOIndexer reports content read errors without side effects
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with a failing content reader
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1"
    Then idx the index result should have 1 errors
    And idx the index result error should mention "read"
    And idx the indexer should track 0 indexed resources
    And idx the text backend should have 0 documents
    And idx the vector backend should have 0 embeddings

  Scenario: UKOIndexer rejects empty project
    Given idx a UKOIndexer with all backends
    Then idx indexing with empty project should raise ValueError

  # =================================================================
  # UKOIndexer remove and reindex
  # =================================================================

  Scenario: UKOIndexer removes a resource from all backends
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    And idx I remove resource "01HQ8ZDRX50000000000000001" from project "local/test"
    Then idx the graph backend should have 0 triples for project "local/test"
    And idx the text backend should have 0 documents
    And idx the vector backend should have 0 embeddings
    And idx the indexer should track 0 indexed resources

  Scenario: UKOIndexer reindexes a resource
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    And idx I reindex resource "01HQ8ZDRX50000000000000001" with content "class Bar:\n    pass"
    Then idx the index result should have triple_count greater than 0
    And idx the indexer should track 1 indexed resource

  Scenario: UKOIndexer remove rejects empty resource_id
    Given idx a UKOIndexer with all backends
    Then idx removing with empty resource_id should raise ValueError

  Scenario: UKOIndexer remove rejects empty project
    Given idx a UKOIndexer with all backends
    Then idx removing with empty project should raise ValueError

  # =================================================================
  # Lifecycle hooks
  # =================================================================

  Scenario: DefaultLifecycleHook receives on_indexed event
    Given idx a UKOIndexer with a tracking lifecycle hook
    And idx a PythonAnalyzer is registered
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1"
    Then idx the lifecycle hook should have received 1 on_indexed event
    And idx the lifecycle hook should have received 0 on_error events

  Scenario: DefaultLifecycleHook receives on_removed event
    Given idx a UKOIndexer with a tracking lifecycle hook
    And idx a PythonAnalyzer is registered
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1"
    And idx I remove resource "01HQ8ZDRX50000000000000001" from project "local/test"
    Then idx the lifecycle hook should have received 1 on_removed event

  Scenario: Lifecycle hook receives on_error for read failure
    Given idx a UKOIndexer with a tracking lifecycle hook and failing reader
    And idx a PythonAnalyzer is registered
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "x = 1"
    Then idx the lifecycle hook should have received 1 on_error events

  # =================================================================
  # UKOIndexer constructor validation
  # =================================================================

  Scenario: UKOIndexer rejects invalid analyzer_registry type
    Then idx creating UKOIndexer with invalid registry should raise TypeError

  Scenario: UKOIndexer has_text_backend property
    Given idx a UKOIndexer with all backends
    Then idx the indexer should have text backend
    And idx the indexer should have vector backend

  Scenario: UKOIndexer without optional backends reports availability
    Given idx a UKOIndexer with only graph backend
    Then idx the indexer should not have text backend
    And idx the indexer should not have vector backend

  # =================================================================
  # SearchResult and IndexedDocument models
  # =================================================================

  Scenario: SearchResult validates score range
    Then idx creating SearchResult with score 1.5 should raise ValueError

  Scenario: SearchResult rejects empty doc_id
    Then idx creating SearchResult with empty doc_id should raise ValueError

  Scenario: IndexedDocument validates non-negative char_count
    Then idx creating IndexedDocument with negative char_count should raise ValueError

  Scenario: IndexedDocument rejects empty project
    Then idx creating IndexedDocument with empty project should raise ValueError

  # =================================================================
  # Review-discovered edge cases
  # =================================================================

  Scenario: UKOIndexer double-index is idempotent (no stale accumulation)
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    And idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    Then idx the indexer should track 1 indexed resource
    And idx the text backend should have 1 document
    And idx the vector backend should have 1 embedding

  Scenario: UKOIndexer reindex replaces old triples with new
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    And idx I reindex resource "01HQ8ZDRX50000000000000001" with content "class Bar:\n    pass"
    Then idx the indexer should track 1 indexed resource
    And idx the graph backend should contain triple with predicate "rdfs:label" and object "Bar"
    And idx the graph backend should not contain triple with object "Foo"

  Scenario: UKOIndexer remove of never-indexed resource is no-op
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I remove resource "01HQ8ZDRX50000000000000099" from project "local/test"
    Then idx the indexer should track 0 indexed resources

  Scenario: ProvenancedTriple has correct source_resource value
    Given idx a PythonAnalyzer is registered
    And idx an AnalyzerRegistry with the PythonAnalyzer
    When idx I analyze content "x = 1" with resource URI "uko://resource/01HQ8ZDRX50000000000000001"
    And idx I wrap the first triple with provenance for resource "01HQ8ZDRX50000000000000001" at path "src/main.py"
    Then idx the provenance source_resource should be "01HQ8ZDRX50000000000000001"
    And idx the provenance source_path should be "src/main.py"

  # =================================================================
  # Bug-fix verifications (deep review round 2)
  # =================================================================

  Scenario: Idempotent re-index does NOT fire on_removed lifecycle event
    Given idx a UKOIndexer with a tracking lifecycle hook
    And idx a PythonAnalyzer is registered
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    And idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Bar:\n    pass"
    Then idx the lifecycle hook should have received 0 on_removed events
    And idx the lifecycle hook should have received 2 on_indexed events

  Scenario: Cross-project re-index cleans up old project data
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index Python resource "01HQ8ZDRX50000000000000001" under project "org/old" content "class Foo:\n    pass"
    And idx I index Python resource "01HQ8ZDRX50000000000000001" under project "org/new" content "class Bar:\n    pass"
    Then idx the graph backend should have 0 triples for project "org/old"
    And idx the graph backend should have triples for project "org/new"
    And idx the text backend should have 1 document
    And idx the indexer should track 1 indexed resource

  Scenario: Reindex with different project cleans old project
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index Python resource "01HQ8ZDRX50000000000000001" under project "org/old" content "class Foo:\n    pass"
    And idx I reindex resource "01HQ8ZDRX50000000000000001" under project "org/new" content "class Bar:\n    pass"
    Then idx the graph backend should have 0 triples for project "org/old"
    And idx the graph backend should have triples for project "org/new"
    And idx the indexer should track 1 indexed resource

  Scenario: Whitespace-only project is rejected by text backend
    Then idx indexing a text document with whitespace-only project should raise ValueError

  Scenario: Whitespace-only content is rejected by text backend
    Then idx indexing a text document with whitespace-only content should raise ValueError

  Scenario: Whitespace-only subject is rejected by graph backend
    Then idx adding a triple with whitespace-only subject should raise ValueError

  # =================================================================
  # Backend error path scenarios (graceful degradation)
  # =================================================================

  Scenario: UKOIndexer handles graph backend error gracefully
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with a failing graph backend
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    Then idx the index result should have triple_count 0
    And idx the index result errors should mention "Graph backend"

  Scenario: UKOIndexer handles text backend error gracefully
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with a failing text backend
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    Then idx the index result should have text_docs_indexed 0
    And idx the index result errors should mention "Text backend"
    And idx the index result should have triple_count greater than 0

  Scenario: UKOIndexer handles vector backend error gracefully
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with a failing vector backend
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    Then idx the index result should have embeddings_indexed 0
    And idx the index result errors should mention "Vector backend"
    And idx the index result should have triple_count greater than 0

  # =================================================================
  # Strengthened content assertions
  # =================================================================

  Scenario: Text backend stores correct doc_id and content after indexing
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    Then idx the text backend should contain doc_id "uko://resource/01HQ8ZDRX50000000000000001" in project "local/test"

  Scenario: Vector backend stores correct doc_id after indexing
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    Then idx the vector backend should contain doc_id "uko://resource/01HQ8ZDRX50000000000000001" in project "local/test"

  Scenario: Graph backend stores sourceResource provenance link
    Given idx a PythonAnalyzer is registered
    And idx a UKOIndexer with all backends
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    Then idx the graph backend should contain triple with predicate "uko:sourceResource" and object "uko://resource/01HQ8ZDRX50000000000000001"

  # =================================================================
  # Additional negative validation tests
  # =================================================================

  Scenario: IndexResult rejects empty resource_id
    Then idx creating IndexResult with empty resource_id should raise ValidationError

  Scenario: SearchResult rejects negative score
    Then idx creating SearchResult with score -0.1 should raise ValueError

  Scenario: Reindex rejects empty project
    Given idx a UKOIndexer with all backends
    Then idx reindexing with empty project should raise ValueError

  Scenario: Reindex rejects whitespace-only project
    Given idx a UKOIndexer with all backends
    Then idx reindexing with whitespace-only project should raise ValueError

  # =================================================================
  # LocationContentReader tests (P2-5)
  # =================================================================

  Scenario: LocationContentReader rejects path with dotdot components
    Then idx LocationContentReader should reject path with dotdot component

  Scenario: LocationContentReader rejects resource with no location
    Then idx LocationContentReader should reject resource with no location

  Scenario: LocationContentReader rejects non-positive max_content_size
    Then idx LocationContentReader should reject max_content_size of 0

  Scenario: LocationContentReader reads a real file
    Then idx LocationContentReader should read a real file successfully

  Scenario: LocationContentReader rejects non-existent file
    Then idx LocationContentReader should reject non-existent file

  Scenario: LocationContentReader rejects path escaping base_dir
    Then idx LocationContentReader should reject path escaping base_dir

  Scenario: SearchResult rejects NaN score
    Then idx creating SearchResult with NaN score should raise ValueError

  # =================================================================
  # File-watching integration (P1-1: file-watching acceptance criterion)
  # =================================================================

  @file-watching
  Scenario: ResourceFileWatcher starts and stops cleanly
    Given idx a ResourceFileWatcher with default settings
    When idx the file watcher is started
    Then idx the file watcher should be running
    When idx the file watcher is stopped
    Then idx the file watcher should not be running

  @file-watching
  Scenario: Watching a file path registers it for monitoring
    Given idx a ResourceFileWatcher with default settings
    And idx a temporary watched file "test.py" with content "x = 1"
    When idx the watcher registers the file for resource "res-001" project "local/test"
    Then idx the watcher should have 1 watched paths

  @file-watching
  Scenario: Unwatching a file path removes it from monitoring
    Given idx a ResourceFileWatcher with default settings
    And idx a temporary watched file "test.py" with content "x = 1"
    When idx the watcher registers the file for resource "res-001" project "local/test"
    And idx the watcher unregisters the watched file
    Then idx the watcher should have 0 watched paths

  @file-watching
  Scenario: File modification triggers on_change callback
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx a FileModifiedEvent is simulated for the watched file
    Then idx the on_change callback should fire within 2 seconds
    And idx the callback should receive resource "res-001" project "local/test" change "modified"

  @file-watching
  Scenario: File deletion triggers on_change callback
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx a FileDeletedEvent is simulated for the watched file
    Then idx the on_change callback should fire within 2 seconds
    And idx the callback should receive resource "res-001" project "local/test" change "deleted"

  @file-watching
  Scenario: Debouncing coalesces rapid changes into one callback
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.1
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx 5 FileModifiedEvents are simulated rapidly for the watched file
    Then idx the on_change callback should fire within 2 seconds
    And idx the on_change callback should have fired exactly 1 time

  @file-watching
  Scenario: EventBus receives RESOURCE_MODIFIED event on file change
    Given idx a ResourceFileWatcher with EventBus tracking and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx a FileModifiedEvent is simulated for the watched file
    Then idx the EventBus should receive a RESOURCE_MODIFIED event within 2 seconds
    And idx the event details should contain resource_id "res-001"

  @file-watching
  Scenario: Callback errors do not crash the watcher
    Given idx a ResourceFileWatcher with a failing callback and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx a FileModifiedEvent is simulated for the watched file
    And idx we wait 0.2 seconds for debounce
    Then idx the file watcher should be running

  @file-watching
  Scenario: Watching a non-existent path raises ValueError
    Given idx a ResourceFileWatcher with default settings
    Then idx watching path "/nonexistent/path.py" should raise ValueError containing "does not exist"

  @file-watching
  Scenario: Watching a directory path raises ValueError
    Given idx a ResourceFileWatcher with default settings
    And idx a temporary watched directory
    Then idx watching the temporary directory should raise ValueError containing "not a file"

  @file-watching
  Scenario: Negative debounce_seconds raises ValueError
    Then idx creating ResourceFileWatcher with debounce -1.0 should raise ValueError

  @file-watching
  Scenario: Unwatched file changes are ignored
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.05
    And idx the file watcher is started
    When idx a FileModifiedEvent is simulated for path "/tmp/unwatched.py"
    And idx we wait 0.2 seconds for debounce
    Then idx the on_change callback should have fired exactly 0 times

  @file-watching
  Scenario: Directory events are ignored by the watcher
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx a directory event is simulated for the watched path
    And idx we wait 0.2 seconds for debounce
    Then idx the on_change callback should have fired exactly 0 times

  # =================================================================
  # Coverage — file-watcher validation and edge cases
  # =================================================================

  @file-watching
  Scenario: Watch rejects empty resource_id
    Given idx a ResourceFileWatcher with default settings
    And idx a temporary watched file "test.py" with content "x = 1"
    Then idx watching with empty resource_id should raise ValueError

  @file-watching
  Scenario: Watch rejects empty project
    Given idx a ResourceFileWatcher with default settings
    And idx a temporary watched file "test.py" with content "x = 1"
    Then idx watching with empty project should raise ValueError

  @file-watching
  Scenario: Starting an already-running watcher raises RuntimeError
    Given idx a ResourceFileWatcher with default settings
    And idx the file watcher is started
    Then idx starting the watcher again should raise RuntimeError
    And idx I stop the file watcher

  @file-watching
  Scenario: Stopping a non-running watcher is a no-op
    Given idx a ResourceFileWatcher with default settings
    When idx I stop the file watcher
    Then idx the file watcher should not be running

  @file-watching
  Scenario: debounce_seconds property returns configured value
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.25
    Then idx the debounce_seconds should be 0.25

  @file-watching
  Scenario: File creation triggers on_change with created type
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx a FileCreatedEvent is simulated for the watched file
    Then idx the on_change callback should fire within 2 seconds
    And idx the callback should receive resource "res-001" project "local/test" change "created"

  @file-watching
  Scenario: File move triggers on_change with moved type
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx a FileMovedEvent is simulated for the watched file
    Then idx the on_change callback should fire within 2 seconds
    And idx the callback should receive resource "res-001" project "local/test" change "moved"

  @file-watching
  Scenario: Unwatch cancels pending debounce timer
    Given idx a ResourceFileWatcher with callback tracking and debounce 5.0
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx a FileModifiedEvent is simulated for the watched file
    And idx the watcher unregisters the watched file
    And idx we wait 0.2 seconds for debounce
    Then idx the on_change callback should have fired exactly 0 times

  # =================================================================
  # Coverage — UKOIndexer validation and error paths
  # =================================================================

  Scenario: UKOIndexer rejects invalid graph_backend type
    Then idx creating UKOIndexer with invalid graph_backend should raise TypeError

  Scenario: UKOIndexer analyzer_registry property returns the registry
    Given idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1"
    When idx I create an indexer
    Then idx the indexer analyzer_registry should be the same registry

  Scenario: Lifecycle hook error on on_indexed does not crash indexer
    Given idx a PythonAnalyzer is registered
    And idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1\n"
    And idx a lifecycle hook that raises on on_indexed
    When idx I create an indexer with the failing hook
    And idx I index resource "01HQ8ZDRX50000000000000001" in project "local/test"
    Then idx the index result should have 0 errors

  Scenario: Lifecycle hook error on on_removed does not crash indexer
    Given idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1\n"
    And idx a lifecycle hook that raises on on_removed
    When idx I create an indexer with the failing hook
    And idx I index resource "01HQ8ZDRX50000000000000001" in project "local/test"
    Then idx removing resource "01HQ8ZDRX50000000000000001" should not raise

  Scenario: Lifecycle hook error on on_error does not crash indexer
    Given idx a PythonAnalyzer is registered
    And idx a lifecycle hook that raises on on_error
    And idx a content reader that raises on read
    When idx I create an indexer with the failing hook
    And idx I index resource "01HQ8ZDRX50000000000000001" in project "local/test"
    Then idx the index result should have errors

  Scenario: Analyzer exception returns IndexResult with error
    Given idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1\n"
    And idx an analyzer that raises RuntimeError
    When idx I create an indexer
    And idx I index resource "01HQ8ZDRX50000000000000001" in project "local/test"
    Then idx the index result should have errors
    And idx the index result error should mention "Analyzer error"

  Scenario: max_triples caps analyzer output
    Given idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1\n"
    And idx an analyzer that produces 100 triples
    When idx I create an indexer with max_triples 5
    And idx I index resource "01HQ8ZDRX50000000000000001" in project "local/test"
    Then idx the index result triple_count should be at most 5

  # =================================================================
  # Coverage — LocationContentReader edge cases
  # =================================================================

  Scenario: LocationContentReader rejects non-regular file (e.g. named pipe)
    Then idx LocationContentReader should reject a non-regular file

  Scenario: LocationContentReader rejects content exceeding max size
    Then idx LocationContentReader should reject content exceeding max size

  # =================================================================
  # Coverage — ResourceFileWatcher start with pre-registered paths
  # =================================================================

  @file-watching
  Scenario: Starting watcher with pre-registered paths schedules them
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx the file watcher is started
    Then idx the file watcher should be running
    And idx the watcher should have 1 watched paths

  # =================================================================
  # Coverage — IndexedDocument validation
  # =================================================================

  Scenario: IndexedDocument rejects empty doc_id
    Then idx creating IndexedDocument with empty doc_id should raise ValueError

  # =================================================================
  # Coverage — Protocol stub NotImplementedError
  # =================================================================

  Scenario: ContentReader protocol raises NotImplementedError
    Then idx calling ContentReader.read_content directly should raise NotImplementedError

  Scenario: IndexLifecycleHook protocol raises NotImplementedError on on_indexed
    Then idx calling IndexLifecycleHook.on_indexed directly should raise NotImplementedError

  Scenario: IndexLifecycleHook protocol raises NotImplementedError on on_removed
    Then idx calling IndexLifecycleHook.on_removed directly should raise NotImplementedError

  Scenario: IndexLifecycleHook protocol raises NotImplementedError on on_error
    Then idx calling IndexLifecycleHook.on_error directly should raise NotImplementedError

  Scenario: AnalyzerProtocol raises NotImplementedError on supported_extensions
    Then idx calling AnalyzerProtocol.supported_extensions directly should raise NotImplementedError

  Scenario: AnalyzerProtocol raises NotImplementedError on domain
    Then idx calling AnalyzerProtocol.domain directly should raise NotImplementedError

  Scenario: AnalyzerProtocol raises NotImplementedError on analyze
    Then idx calling AnalyzerProtocol.analyze directly should raise NotImplementedError

  # =================================================================
  # Coverage — UKOTriple validation edge cases
  # =================================================================

  Scenario: UKOTriple rejects whitespace-only subject_uri
    Then idx creating UKOTriple with whitespace-only subject_uri should raise ValueError

  Scenario: UKOTriple rejects whitespace-only predicate
    Then idx creating UKOTriple with whitespace-only predicate should raise ValueError

  Scenario: UKOTriple rejects both object_uri and object_value empty
    Then idx creating UKOTriple with empty object_uri and empty object_value should raise ValueError

  # =================================================================
  # Coverage — AnalyzerRegistry edge cases
  # =================================================================

  Scenario: AnalyzerRegistry.get_for_resource returns None for extensionless location
    Given idx a PythonAnalyzer is registered
    When idx I look up analyzer for a resource with location "Makefile"
    Then idx no analyzer should be found

  # =================================================================
  # Coverage — AnalyzerRegistry validation and listing
  # =================================================================

  Scenario: AnalyzerRegistry.register rejects non-protocol object
    Then idx registering a non-protocol object should raise TypeError

  Scenario: AnalyzerRegistry.register rejects analyzer with empty extensions
    Then idx registering an analyzer with empty extensions should raise ValueError

  Scenario: AnalyzerRegistry.get_for_extension with empty string returns None
    Then idx looking up empty extension should return None

  Scenario: AnalyzerRegistry.list_extensions returns registered extensions
    Given idx a PythonAnalyzer is registered
    Then idx list_extensions should include ".py"

  Scenario: AnalyzerRegistry.list_analyzers returns registered analyzers
    Given idx a PythonAnalyzer is registered
    Then idx list_analyzers should return 1 analyzer

  # =================================================================
  # Coverage — fire_on_removed direct call with raising hook
  # =================================================================

  Scenario: fire_on_removed swallows hook exception
    Then idx calling fire_on_removed with a raising hook should not raise

  # =================================================================
  # Coverage — ResourceFileWatcher additional paths
  # =================================================================

  @file-watching
  Scenario: ResourceFileWatcher with auto_reindex disabled does not start observer
    Given idx a ResourceFileWatcher with auto_reindex disabled
    When idx the file watcher is started
    Then idx the file watcher should not be running

  @file-watching
  Scenario: ResourceFileWatcher auto_reindex property reflects construction
    Given idx a ResourceFileWatcher with auto_reindex disabled
    Then idx the auto_reindex property should be false

  @file-watching
  Scenario: _fire_change with failing callback logs error but does not crash
    Given idx a ResourceFileWatcher with a directly-failing callback and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx _fire_change is called directly with failing callback
    Then idx the file watcher should be running

  @file-watching
  Scenario: _fire_change with failing event_bus logs error but does not crash
    Given idx a ResourceFileWatcher with a failing event bus and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx _fire_change is called directly with failing event bus
    Then idx the file watcher should be running

  @file-watching
  Scenario: _fire_change after stop is a no-op
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx the file watcher is stopped
    And idx _fire_change is called directly after stop
    Then idx the on_change callback should have fired exactly 0 times

  @file-watching
  Scenario: _ResourceChangeHandler routes events to watcher
    Given idx a ResourceFileWatcher with callback tracking and debounce 0.05
    And idx a temporary watched file "test.py" with content "x = 1"
    And idx the file watcher is started
    And idx the watcher registers the file for resource "res-001" project "local/test"
    When idx a _ResourceChangeHandler is used to route events
    Then idx the on_change callback should fire within 2 seconds

  # =================================================================
  # Coverage — removal backend failure paths
  # =================================================================

  @coverage
  Scenario: remove_resource tolerates graph backend failure
    Given idx a clean analyzer registry
    And idx in-memory index backends
    And idx a PythonAnalyzer is registered
    And idx a UKOIndexer with removal-failing graph backend
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    And idx I remove resource "01HQ8ZDRX50000000000000001" from project "local/test"
    Then idx the indexer should track 0 indexed resources

  @coverage
  Scenario: remove_resource tolerates text backend failure
    Given idx a clean analyzer registry
    And idx in-memory index backends
    And idx a PythonAnalyzer is registered
    And idx a UKOIndexer with removal-failing text backend
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    And idx I remove resource "01HQ8ZDRX50000000000000001" from project "local/test"
    Then idx the indexer should track 0 indexed resources

  @coverage
  Scenario: remove_resource tolerates vector backend failure
    Given idx a clean analyzer registry
    And idx in-memory index backends
    And idx a PythonAnalyzer is registered
    And idx a UKOIndexer with removal-failing vector backend
    When idx I index a Python resource "01HQ8ZDRX50000000000000001" with content "class Foo:\n    pass"
    And idx I remove resource "01HQ8ZDRX50000000000000001" from project "local/test"
    Then idx the indexer should track 0 indexed resources

  # =================================================================
  # Coverage — max_triples validation
  # =================================================================

  @coverage
  Scenario: max_triples of zero raises ValueError
    Then idx creating a UKOIndexer with max_triples 0 should raise ValueError

  # =================================================================
  # Coverage — reindex_resource on a fresh (not previously indexed) resource
  # =================================================================

  @coverage
  Scenario: reindex_resource on a never-indexed resource succeeds
    Given idx a clean analyzer registry
    And idx in-memory index backends
    And idx a PythonAnalyzer is registered
    And idx a content reader with resource "01HQ8ZDRX50000000000000001" content "x = 1\n"
    When idx I create an indexer
    And idx I reindex resource "01HQ8ZDRX50000000000000001" in project "local/test"
    Then idx the index result should have triple_count greater than 0

  # =================================================================
  # Coverage — index_graph edge cases (internals)
  # =================================================================

  @coverage
  Scenario: index_graph skips triple with no object_uri and no object_value
    Then idx index_graph should skip triples with empty object

  @coverage
  Scenario: index_graph handles rdfs:label failure for dual-object triple
    Then idx index_graph should tolerate rdfs:label failure

  @coverage
  Scenario: index_graph handles provenance link failure
    Then idx index_graph should tolerate provenance link failure

  # =================================================================
  # Coverage — index_stubs edge cases
  # =================================================================

  @coverage
  Scenario: InMemoryTextIndexBackend search respects limit
    Then idx text search should stop at the limit

  @coverage
  Scenario: InMemoryVectorIndexBackend rejects empty query_embedding
    Then idx vector search_similar with empty embedding should raise ValueError

  @coverage
  Scenario: InMemoryVectorIndexBackend rejects zero limit
    Then idx vector search_similar with limit 0 should raise ValueError

  @coverage
  Scenario: InMemoryVectorIndexBackend search filters by project
    Then idx vector search_similar filters by project

  @coverage
  Scenario: InMemoryGraphIndexBackend remove_triples on missing project is no-op
    Then idx graph remove_triples on missing project should be no-op

  @coverage
  Scenario: InMemoryGraphIndexBackend triple_count across all projects
    Then idx graph triple_count without project should sum all projects

  # =================================================================
  # Coverage — index_backends protocol base methods
  # =================================================================

  @coverage
  Scenario: Protocol base method bodies are exercisable
    Then idx all index backend protocol base methods should be callable

  # =================================================================
  # Coverage — min_relevance filtering (P3-3)
  # =================================================================

  @coverage
  Scenario: InMemoryVectorIndexBackend min_relevance above 1.0 raises ValueError
    Then idx vector search_similar with min_relevance above 1.0 raises ValueError

  @coverage
  Scenario: InMemoryVectorIndexBackend min_relevance 1.0 includes exact matches
    Then idx vector search_similar with min_relevance 1.0 includes exact matches

  @coverage
  Scenario: InMemoryVectorIndexBackend min_relevance 0.0 includes all matches
    Then idx vector search_similar with min_relevance 0.0 includes all matches

  # =================================================================
  # Coverage — stub size limits (P2-16)
  # =================================================================

  @coverage
  Scenario: InMemoryTextIndexBackend enforces max_entries limit
    Then idx text stub should raise RuntimeError when max_entries exceeded

  @coverage
  Scenario: InMemoryVectorIndexBackend enforces max_entries limit
    Then idx vector stub should raise RuntimeError when max_entries exceeded

  @coverage
  Scenario: InMemoryGraphIndexBackend enforces max_entries limit
    Then idx graph stub should raise RuntimeError when max_entries exceeded

  # =================================================================
  # Coverage — provenance metadata persistence (P2-22, P2-27)
  # =================================================================

  @coverage
  Scenario: index_graph stores full provenance metadata and confidence
    Then idx index_graph should store provenance metadata triples

  # =================================================================
  # Coverage — callback + event_bus combo (P3-#18)
  # =================================================================

  @coverage
  Scenario: ResourceFileWatcher fires both callback and EventBus
    Then idx file watcher should fire both callback and event_bus on change

  # =================================================================
  # Coverage — source_range validation (#28)
  # =================================================================

  @coverage
  Scenario: ProvenanceMetadata rejects invalid source_range format
    Then idx creating ProvenanceMetadata with invalid source_range should raise

  @coverage
  Scenario: ProvenanceMetadata accepts empty source_range
    Then idx creating ProvenanceMetadata with empty source_range should succeed

  # =================================================================
  # Coverage — move to new directory (#4)
  # =================================================================

  @coverage
  Scenario: FileMovedEvent to new directory schedules observer for dest parent
    Then idx file watcher should schedule new dir on cross-directory move

  # =================================================================
  # Coverage — concurrency (#58)
  # =================================================================

  @coverage
  Scenario: Concurrent indexing of different resources is thread-safe
    Then idx concurrent indexing of different resources should be thread-safe

  # =================================================================
  # Coverage — move then modify sequence (#59)
  # =================================================================

  @coverage
  Scenario: FileMovedEvent followed by FileModifiedEvent on new path
    Then idx file watcher should handle move then modify sequence

  # =================================================================
  # Coverage — empty-string filter validation in remove_triples
  # =================================================================

  @coverage
  Scenario: Graph remove_triples rejects empty-string subject filter
    Given idx a clean analyzer registry
    And idx in-memory index backends
    Then idx removing triples with empty-string subject should raise ValueError

  @coverage
  Scenario: Graph remove_triples rejects empty-string predicate filter
    Given idx a clean analyzer registry
    And idx in-memory index backends
    Then idx removing triples with empty-string predicate should raise ValueError

  @coverage
  Scenario: Graph remove_triples rejects empty-string obj filter
    Given idx a clean analyzer registry
    And idx in-memory index backends
    Then idx removing triples with empty-string obj should raise ValueError

  # =================================================================
  # Coverage — dest timer cancel and EventBus move dest_path
  # =================================================================

  @coverage @file-watching
  Scenario: Rapid create-then-move cancels prior dest timer
    Given idx a clean analyzer registry
    And idx in-memory index backends
    Then idx file watcher should cancel prior dest timer on rapid sequence

  @coverage @file-watching
  Scenario: EventBus move event includes dest_path in details
    Given idx a clean analyzer registry
    And idx in-memory index backends
    Then idx file watcher EventBus move event should include dest_path
