import Foundation
import MCPCore
import MCPSharedTools
import SampleIndexModels
import SearchModels
import ServicesModels
import SharedConstants

// MARK: - Unified Cupertino Tool Provider

/// Composite tool provider that provides unified search across all documentation sources.
/// Handles `search_docs` with `source` parameter to search docs, samples, HIG, archive, etc.
public actor CompositeToolProvider: MCP.Core.ToolProvider {
    // Protocol-typed so this file doesn't import the Search, SampleIndex,
    // or Services behavioural targets — every cross-package surface is
    // an abstraction from a Models target. The CLI composition root
    // constructs the concrete actors and passes them across the seam.

    private let docsService: (any Services.DocsSearcher)?
    private let sampleService: (any Sample.Search.Searcher)?
    private let teaserService: (any Services.Teaser)?
    private let unifiedService: (any Services.UnifiedSearcher)?

    // Kept for low-level operations (list frameworks, read document,
    // semantic-symbol searches) that don't have a service-layer wrapper.
    private let searchIndex: (any Search.Database)?
    private let sampleDatabase: (any Sample.Index.Reader)?

    /// `#789`-style architectural gap fix landed in v1.2.0 PR-2. Pre-fix,
    /// MCP `search source=packages` routed through
    /// `handleSearchDocs(source:"packages")` → `docsService.search` →
    /// `Search.Database.search` against `search.db`, which returns zero
    /// rows because `packages` isn't one of the six source values
    /// search.db knows. The fix wires `packages.db` directly through this
    /// seam; the new `handleSearchPackages` dispatch path consults it.
    /// When nil (no packages.db on disk, composition root didn't wire),
    /// the `packages` single-source MCP path responds with the same
    /// "Documentation index not available"-style error frame as the
    /// docs path uses for a missing search.db.
    private let packagesSearcher: (any Search.PackagesSearcher)?

    /// #645 — set when `search.db` exists on disk but couldn't be
    /// opened (schema mismatch, corrupt file, "not a database"). When
    /// non-nil the tool provider still advertises the search.db-
    /// dependent tools so `tools/list` is honest about the server's
    /// capability surface; per-tool handlers throw a clear error
    /// frame naming the reason. When nil + searchIndex is also nil,
    /// the file is legitimately missing and we hide the tools (the
    /// pre-#645 status quo for samples-only servers).
    private let searchIndexDisabledReason: String?

    // #582: fallback resource provider used by `handleReadDocument` when
    // the search-index lookup misses. `MCP.Support.DocsResourceProvider`
    // (the same instance the CLI uses for `resources/read`) does a
    // filesystem fallback over the crawled docs directory that the
    // search-index direct lookup doesn't. Pre-fix, `read_document` and
    // `resources/read` had divergent lookup paths and the tool failed
    // for URIs the resource path accepted (e.g.
    // `apple-docs://accelerate/documentation_accelerate` against a
    // pre-#293 indexed bundle). Wiring the same provider into both
    // paths makes them symmetric.
    //
    // Typed against `MCP.Core.ResourceProvider` (already imported) so
    // this file still doesn't reach for `MCP.Support` and the per-
    // package import-contract stays clean.
    private let documentResourceProvider: (any MCP.Core.ResourceProvider)?

    /// Primary init used by the CLI composition root. Each cross-package
    /// surface arrives pre-wired as a protocol-typed value so this file
    /// doesn't have to import the Search / SampleIndex / Services
    /// behavioural targets to do any wiring of its own.
    public init(
        searchIndex: (any Search.Database)?,
        sampleDatabase: (any Sample.Index.Reader)?,
        docsService: (any Services.DocsSearcher)?,
        sampleService: (any Sample.Search.Searcher)?,
        teaserService: (any Services.Teaser)?,
        unifiedService: (any Services.UnifiedSearcher)?,
        packagesSearcher: (any Search.PackagesSearcher)? = nil,
        documentResourceProvider: (any MCP.Core.ResourceProvider)? = nil,
        searchIndexDisabledReason: String? = nil
    ) {
        self.searchIndex = searchIndex
        self.sampleDatabase = sampleDatabase
        self.docsService = docsService
        self.sampleService = sampleService
        self.teaserService = teaserService
        self.unifiedService = unifiedService
        self.packagesSearcher = packagesSearcher
        self.documentResourceProvider = documentResourceProvider
        self.searchIndexDisabledReason = searchIndexDisabledReason
    }

    /// True when the server should advertise search.db-dependent tools.
    /// The DB is either currently open (ready path) or present-but-
    /// unopenable (configuration-error path, #645). When the file is
    /// legitimately missing, both branches are false and the tools stay
    /// hidden in `tools/list`.
    private var searchToolsVisible: Bool {
        searchIndex != nil || searchIndexDisabledReason != nil
    }

    /// #645 — error frame for tool calls when `search.db` is present
    /// but unopenable. The handler funnel checks `searchIndex` and
    /// throws this when nil with `searchIndexDisabledReason` set,
    /// instead of the generic "index not available" message. AI agents
    /// reading the error see the same actionable text the CLI prints
    /// (e.g. "schema mismatch — run `cupertino setup` …"), matching
    /// the #640 degradation pattern on the unified search path.
    private func searchIndexUnavailableError(_ paramName: String) -> Shared.Core.ToolError {
        let message: String
        if let reason = searchIndexDisabledReason {
            message = "Documentation index disabled: \(reason)"
        } else {
            message = "Documentation index not available"
        }
        return Shared.Core.ToolError.invalidArgument(paramName, message)
    }

    // The two-argument convenience init that constructed concrete
    // service actors from the index seams moved to the
    // SearchToolProviderTests target (`CompositeToolProvider+ServicesWiring.swift`).
    // Production code calls the explicit six-argument init above; that
    // shape keeps `import Services` out of this file.

    // MARK: - ToolProvider

    public func listTools(cursor: String?) async throws -> MCP.Core.Protocols.ListToolsResult {
        var allTools: [MCP.Core.Protocols.Tool] = []

        let searchProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamQuery: stringSchema(
                description: "Search query string."
            ),
            Shared.Constants.Search.schemaParamSource: stringSchema(
                description: "Optional source filter.",
                enumValues: [
                    "all",
                    Shared.Constants.SourcePrefix.appleDocs,
                    Shared.Constants.SourcePrefix.samples,
                    Shared.Constants.SourcePrefix.appleSampleCode,
                    Shared.Constants.SourcePrefix.hig,
                    Shared.Constants.SourcePrefix.appleArchive,
                    Shared.Constants.SourcePrefix.swiftEvolution,
                    Shared.Constants.SourcePrefix.swiftOrg,
                    Shared.Constants.SourcePrefix.swiftBook,
                    Shared.Constants.SourcePrefix.packages,
                ]
            ),
            Shared.Constants.Search.schemaParamFramework: stringSchema(
                description: "Framework filter (e.g. swiftui, foundation)."
            ),
            Shared.Constants.Search.schemaParamLanguage: stringSchema(
                description: "Language filter for Swift.org sources."
            ),
            Shared.Constants.Search.schemaParamIncludeArchive: boolSchema(
                description: "Include Apple Archive results when source is not specified."
            ),
            Shared.Constants.Search.schemaParamLimit: intSchema(
                description: "Maximum results to return (default 20)."
            ),
            Shared.Constants.Search.schemaParamMinIOS: stringSchema(
                description: "Minimum iOS version filter (e.g. 17.0)."
            ),
            Shared.Constants.Search.schemaParamMinMacOS: stringSchema(
                description: "Minimum macOS version filter (e.g. 14.0)."
            ),
            Shared.Constants.Search.schemaParamMinTvOS: stringSchema(
                description: "Minimum tvOS version filter (e.g. 17.0)."
            ),
            Shared.Constants.Search.schemaParamMinWatchOS: stringSchema(
                description: "Minimum watchOS version filter (e.g. 10.0)."
            ),
            Shared.Constants.Search.schemaParamMinVisionOS: stringSchema(
                description: "Minimum visionOS version filter (e.g. 1.0)."
            ),
            Shared.Constants.Search.schemaParamMinSwift: stringSchema(
                description: Self.schemaDescriptionMinSwift
            ),
            Shared.Constants.Search.schemaParamAppleImports: stringSchema(
                description: Self.schemaDescriptionAppleImports
            ),
        ]

        let readDocumentProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamURI: stringSchema(
                description: "Document URI to read."
            ),
            Shared.Constants.Search.schemaParamFormat: stringSchema(
                description: "Output format (json or markdown).",
                enumValues: [
                    Shared.Constants.Search.formatValueJSON,
                    Shared.Constants.Search.formatValueMarkdown,
                ]
            ),
        ]

        let readSampleProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamProjectId: stringSchema(
                description: "Sample project identifier."
            ),
        ]

        let readSampleFileProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamProjectId: stringSchema(
                description: "Sample project identifier."
            ),
            Shared.Constants.Search.schemaParamFilePath: stringSchema(
                description: "File path relative to the sample project root."
            ),
        ]

        // #226 — platform-filter schema fragment shared by all 4
        // AST search-style tools. Same shape as the unified `search`
        // tool's existing platform args (CompositeToolProvider.swift:147-161).
        // Applied client-side via Search.PlatformFilter.passes after the
        // existing Search.Index method returns its result list (the
        // helper fetches min_* per result URI in one batched query).
        let platformFilterProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamMinIOS: stringSchema(
                description: "Minimum iOS version filter (e.g. 17.0)."
            ),
            Shared.Constants.Search.schemaParamMinMacOS: stringSchema(
                description: "Minimum macOS version filter (e.g. 14.0)."
            ),
            Shared.Constants.Search.schemaParamMinTvOS: stringSchema(
                description: "Minimum tvOS version filter (e.g. 17.0)."
            ),
            Shared.Constants.Search.schemaParamMinWatchOS: stringSchema(
                description: "Minimum watchOS version filter (e.g. 10.0)."
            ),
            Shared.Constants.Search.schemaParamMinVisionOS: stringSchema(
                description: "Minimum visionOS version filter (e.g. 1.0)."
            ),
        ]

        let searchSymbolsProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamQuery: stringSchema(
                description: "Symbol name pattern (partial match)."
            ),
            Shared.Constants.Search.schemaParamKind: stringSchema(
                description: "Symbol kind filter (struct, class, actor, enum, protocol, function, property)."
            ),
            Shared.Constants.Search.schemaParamIsAsync: boolSchema(
                description: "Filter async functions only."
            ),
            Shared.Constants.Search.schemaParamFramework: stringSchema(
                description: "Framework filter (e.g. swiftui, foundation)."
            ),
            Shared.Constants.Search.schemaParamLimit: intSchema(
                description: "Maximum results to return (default 20)."
            ),
        ].merging(platformFilterProperties) { lhs, _ in lhs }

        let searchPropertyWrappersProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamWrapper: stringSchema(
                description: "Property wrapper name (with or without @)."
            ),
            Shared.Constants.Search.schemaParamFramework: stringSchema(
                description: "Framework filter (e.g. swiftui, foundation)."
            ),
            Shared.Constants.Search.schemaParamLimit: intSchema(
                description: "Maximum results to return (default 20)."
            ),
        ].merging(platformFilterProperties) { lhs, _ in lhs }

        let searchConcurrencyProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamPattern: stringSchema(
                description: "Concurrency pattern (async, actor, sendable, mainactor, task, asyncsequence)."
            ),
            Shared.Constants.Search.schemaParamFramework: stringSchema(
                description: "Framework filter (e.g. swiftui, foundation)."
            ),
            Shared.Constants.Search.schemaParamLimit: intSchema(
                description: "Maximum results to return (default 20)."
            ),
        ].merging(platformFilterProperties) { lhs, _ in lhs }

        let searchConformancesProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamProtocol: stringSchema(
                description: "Protocol name to search for (e.g. View, Codable)."
            ),
            Shared.Constants.Search.schemaParamFramework: stringSchema(
                description: "Framework filter (e.g. swiftui, foundation)."
            ),
            Shared.Constants.Search.schemaParamLimit: intSchema(
                description: "Maximum results to return (default 20)."
            ),
        ].merging(platformFilterProperties) { lhs, _ in lhs }

        // #665 / #409 Layer 2 — generic-parameter constraint search.
        // #226 follow-up — `platformFilterProperties` merged in so the 12th
        // MCP tool gets the same `min_*` axis the other 4 AST tools carry.
        let searchGenericsProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamConstraint: stringSchema(
                description: "Generic constraint to search for (e.g. Sendable, Hashable, View)."
            ),
            Shared.Constants.Search.schemaParamFramework: stringSchema(
                description: "Framework filter (e.g. swiftui, foundation)."
            ),
            Shared.Constants.Search.schemaParamLimit: intSchema(
                description: "Maximum results to return (default 20)."
            ),
        ].merging(platformFilterProperties) { lhs, _ in lhs }

        // #274 — class-inheritance walk over the `inheritance` edge table.
        let getInheritanceProperties: [String: MCP.Core.Protocols.AnyCodable] = [
            Shared.Constants.Search.schemaParamSymbol: stringSchema(
                description: "Symbol name to walk from (e.g. UIButton, NSView)."
            ),
            Shared.Constants.Search.schemaParamDirection: stringSchema(
                description: "Walk direction. 'up' = ancestors (default), 'down' = descendants, 'both'.",
                enumValues: ["up", "down", "both"]
            ),
            Shared.Constants.Search.schemaParamDepth: intSchema(
                description: "Maximum walk depth (default 5)."
            ),
            Shared.Constants.Search.schemaParamFramework: stringSchema(
                description: "Disambiguate to a specific framework when the symbol exists in multiple."
            ),
        ]

        // Unified search tool (replaces search_docs, search_hig, search_all, search_samples).
        // #645 — visible when EITHER search.db is openable (or present-but-
        // unopenable) or samples.db has content. The schema-mismatch path
        // makes searchToolsVisible true even when searchIndex is nil.
        if searchToolsVisible || sampleDatabase != nil {
            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolSearch,
                description: MCP.SharedTools.Copy.toolSearchDescription,
                inputSchema: objectSchema(
                    properties: searchProperties,
                    required: [Shared.Constants.Search.schemaParamQuery]
                )
            ))
        }

        // List frameworks tool
        if searchToolsVisible {
            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolListFrameworks,
                description: MCP.SharedTools.Copy.toolListFrameworksDescription,
                inputSchema: objectSchema(properties: [:])
            ))

            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolReadDocument,
                description: MCP.SharedTools.Copy.toolReadDocumentDescription,
                inputSchema: objectSchema(
                    properties: readDocumentProperties,
                    required: [Shared.Constants.Search.schemaParamURI]
                )
            ))
        }

        // Sample code tools
        if sampleDatabase != nil {
            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolListSamples,
                description: MCP.SharedTools.Copy.toolListSamplesDescription,
                inputSchema: objectSchema(properties: [:])
            ))

            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolReadSample,
                description: MCP.SharedTools.Copy.toolReadSampleDescription,
                inputSchema: objectSchema(
                    properties: readSampleProperties,
                    required: [Shared.Constants.Search.schemaParamProjectId]
                )
            ))

            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolReadSampleFile,
                description: MCP.SharedTools.Copy.toolReadSampleFileDescription,
                inputSchema: objectSchema(
                    properties: readSampleFileProperties,
                    required: [
                        Shared.Constants.Search.schemaParamProjectId,
                        Shared.Constants.Search.schemaParamFilePath,
                    ]
                )
            ))
        }

        // Semantic search tools (#81)
        if searchToolsVisible {
            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolSearchSymbols,
                description: MCP.SharedTools.Copy.toolSearchSymbolsDescription,
                inputSchema: objectSchema(properties: searchSymbolsProperties)
            ))

            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolSearchPropertyWrappers,
                description: MCP.SharedTools.Copy.toolSearchPropertyWrappersDescription,
                inputSchema: objectSchema(
                    properties: searchPropertyWrappersProperties,
                    required: [Shared.Constants.Search.schemaParamWrapper]
                )
            ))

            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolSearchConcurrency,
                description: MCP.SharedTools.Copy.toolSearchConcurrencyDescription,
                inputSchema: objectSchema(
                    properties: searchConcurrencyProperties,
                    required: [Shared.Constants.Search.schemaParamPattern]
                )
            ))

            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolGetInheritance,
                description: "Walk class-inheritance chains (UIKit / AppKit / Foundation). " +
                    "Returns ancestors (`direction=up`), descendants (`direction=down`), or both. " +
                    "Non-class kinds return empty — Swift value types and protocols don't carry inherits-from edges.",
                inputSchema: objectSchema(
                    properties: getInheritanceProperties,
                    required: [Shared.Constants.Search.schemaParamSymbol]
                )
            ))

            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolSearchConformances,
                description: MCP.SharedTools.Copy.toolSearchConformancesDescription,
                inputSchema: objectSchema(
                    properties: searchConformancesProperties,
                    required: [Shared.Constants.Search.schemaParamProtocol]
                )
            ))

            allTools.append(MCP.Core.Protocols.Tool(
                name: Shared.Constants.Search.toolSearchGenerics,
                description: MCP.SharedTools.Copy.toolSearchGenericsDescription,
                inputSchema: objectSchema(
                    properties: searchGenericsProperties,
                    required: [Shared.Constants.Search.schemaParamConstraint]
                )
            ))
        }

        return MCP.Core.Protocols.ListToolsResult(tools: allTools)
    }

    public func callTool(name: String, arguments: [String: MCP.Core.Protocols.AnyCodable]?) async throws -> MCP.Core.Protocols.CallToolResult {
        let args = MCP.SharedTools.ArgumentExtractor(arguments)

        switch name {
        case Shared.Constants.Search.toolSearch:
            return try await handleSearch(args: args)
        case Shared.Constants.Search.toolListFrameworks:
            return try await handleListFrameworks()
        case Shared.Constants.Search.toolReadDocument:
            return try await handleReadDocument(args: args)
        case Shared.Constants.Search.toolListSamples:
            return try await handleListSamples(args: args)
        case Shared.Constants.Search.toolReadSample:
            return try await handleReadSample(args: args)
        case Shared.Constants.Search.toolReadSampleFile:
            return try await handleReadSampleFile(args: args)
        case Shared.Constants.Search.toolSearchSymbols:
            return try await handleSearchSymbols(args: args)
        case Shared.Constants.Search.toolSearchPropertyWrappers:
            return try await handleSearchPropertyWrappers(args: args)
        case Shared.Constants.Search.toolSearchConcurrency:
            return try await handleSearchConcurrency(args: args)
        case Shared.Constants.Search.toolSearchConformances:
            return try await handleSearchConformances(args: args)
        case Shared.Constants.Search.toolSearchGenerics:
            return try await handleSearchGenerics(args: args)
        case Shared.Constants.Search.toolGetInheritance:
            return try await handleGetInheritance(args: args)
        default:
            throw Shared.Core.ToolError.unknownTool(name)
        }
    }

    private func objectSchema(
        properties: [String: MCP.Core.Protocols.AnyCodable]?,
        required: [String] = []
    ) -> MCP.Core.Protocols.JSONSchema {
        MCP.Core.Protocols.JSONSchema(
            type: Shared.Constants.Search.schemaTypeObject,
            properties: properties,
            required: required
        )
    }

    private func stringSchema(description: String? = nil, enumValues: [String]? = nil) -> MCP.Core.Protocols.AnyCodable {
        var schema: [String: MCP.Core.Protocols.AnyCodable] = ["type": MCP.Core.Protocols.AnyCodable("string")]
        if let description {
            schema["description"] = MCP.Core.Protocols.AnyCodable(description)
        }
        if let enumValues {
            let values = enumValues.map { MCP.Core.Protocols.AnyCodable($0) }
            schema["enum"] = MCP.Core.Protocols.AnyCodable(values)
        }
        return MCP.Core.Protocols.AnyCodable(schema)
    }

    private func boolSchema(description: String? = nil) -> MCP.Core.Protocols.AnyCodable {
        var schema: [String: MCP.Core.Protocols.AnyCodable] = ["type": MCP.Core.Protocols.AnyCodable("boolean")]
        if let description {
            schema["description"] = MCP.Core.Protocols.AnyCodable(description)
        }
        return MCP.Core.Protocols.AnyCodable(schema)
    }

    private func intSchema(description: String? = nil) -> MCP.Core.Protocols.AnyCodable {
        var schema: [String: MCP.Core.Protocols.AnyCodable] = ["type": MCP.Core.Protocols.AnyCodable("integer")]
        if let description {
            schema["description"] = MCP.Core.Protocols.AnyCodable(description)
        }
        return MCP.Core.Protocols.AnyCodable(schema)
    }

    // MARK: - Unified Search Handler

    private func handleSearch(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        let rawQuery: String = try args.require(Shared.Constants.Search.schemaParamQuery)
        // #596: reject empty / whitespace-only queries with an explicit
        // invalidArgument frame. Pre-fix, MCP returned a 620-char "no
        // results" response for `query=""` while CLI errored — clients
        // had to special-case both transports. Tightened MCP side here
        // so both transports now consistently reject empty queries.
        let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !query.isEmpty else {
            throw Shared.Core.ToolError.invalidArgument(
                Shared.Constants.Search.schemaParamQuery,
                "Query cannot be empty"
            )
        }
        let source = args.optional(Shared.Constants.Search.schemaParamSource)
        let framework = args.optional(Shared.Constants.Search.schemaParamFramework)
        let language = args.optional(Shared.Constants.Search.schemaParamLanguage)
        let limit = args.limit()
        let includeArchive = args.includeArchive()
        // #226 — validate + canonicalise the 5 platform filter values up
        // front so empty / malformed strings get a clear MCP error rather
        // than silently no-oping past `Search.PlatformFilter.passes(...)`.
        // The shipped 5-field shape replaces the original "platform +
        // min_version pair" spec; each `min_*` field is self-naming
        // (platform implied by the field) so the original required-
        // together rule doesn't translate — what we can validate is that
        // each present value is well-formed.
        let platform = try Self.extractPlatformArgs(args)
        let minIOS = platform.minIOS
        let minMacOS = platform.minMacOS
        let minTvOS = platform.minTvOS
        let minWatchOS = platform.minWatchOS
        let minVisionOS = platform.minVisionOS
        // #225 Part B — Swift toolchain filter for swift-evolution rows
        // via docs_metadata.implementation_swift_version. Plumbed
        // through handleSearchDocs → Services.SearchQuery; non-evolution
        // rows are rejected by the index's NULL-rejection semantic when
        // this is set. nil when the MCP arg is absent (filter off).
        let minSwift: String? = args.optional(Shared.Constants.Search.schemaParamMinSwift)
        // `#837` PR-2 — apple-framework-import filter on the packages
        // bucket. Threaded through both the single-source path
        // (`handleSearchPackages`) and the fan-out path
        // (`handleSearchAll` → `Services.UnifiedSearchService.searchAll`).
        // No-op when source is anything other than `packages` (the
        // search.db-backed sources don't carry apple_imports_json).
        let appleImports: String? = args.optional(Shared.Constants.Search.schemaParamAppleImports)

        // #226 — decide the cross-source partial-filter notice before
        // dispatch. The notice prepends to the response markdown when
        // the user passed any platform filter AND the dispatch path
        // produces unfiltered rows. Two cases:
        //
        // 1. Specific-source dispatch (`apple-docs` / `apple-archive` /
        //    `swift-evolution` / `swift-org` / `swift-book` / `packages`
        //    via `handleSearchDocs`) — filter IS applied, no notice.
        //    `hig` / `samples` via the standalone HIG/Samples handlers
        //    DO NOT thread platform args — notice fires.
        //
        // 2. Fan-out dispatch (no source, "all", empty) via
        //    `handleSearchAll` — currently drops platform args for ALL
        //    sources. Notice fires for every contributing source. When
        //    `handleSearchAll` is updated to thread args (filed as
        //    follow-up), the partition switches automatically.
        //
        // The `Search.PlatformFilterScope.Dispatch` enum encodes this
        // path-dependent fact so the helper knows whether to treat all
        // contributing sources as unfiltered (fan-out) or partition
        // them per `dispatchAppliesFilter` (single-source).
        let dispatchDecision = Search.PlatformFilterScope.dispatch(for: source)
        let notice = Search.PlatformFilterScope.partialNoticeMarkdown(
            platformDescriptions: Self.platformDescriptions(platform: platform, minSwift: minSwift),
            dispatch: dispatchDecision.kind,
            contributingSources: dispatchDecision.sources
        )

        // Route based on source parameter
        // Default (nil) now searches ALL sources for better results (#81)
        let raw: MCP.Core.Protocols.CallToolResult
        switch source {
        case Shared.Constants.SourcePrefix.samples, Shared.Constants.SourcePrefix.appleSampleCode:
            raw = try await handleSearchSamples(
                query: query,
                framework: framework,
                limit: limit,
                minIOS: minIOS,
                minMacOS: minMacOS,
                minTvOS: minTvOS,
                minWatchOS: minWatchOS,
                minVisionOS: minVisionOS
            )
        case Shared.Constants.SourcePrefix.hig:
            raw = try await handleSearchHIG(
                query: query,
                framework: framework,
                limit: limit
            )
        case Shared.Constants.SourcePrefix.appleDocs,
             Shared.Constants.SourcePrefix.appleArchive,
             Shared.Constants.SourcePrefix.swiftEvolution,
             Shared.Constants.SourcePrefix.swiftOrg,
             Shared.Constants.SourcePrefix.swiftBook:
            // Specific source requested: search only that source
            raw = try await handleSearchDocs(
                query: query,
                source: source,
                framework: framework,
                language: language,
                limit: limit,
                includeArchive: includeArchive,
                minIOS: minIOS,
                minMacOS: minMacOS,
                minTvOS: minTvOS,
                minWatchOS: minWatchOS,
                minVisionOS: minVisionOS,
                minSwift: minSwift
            )
        case Shared.Constants.SourcePrefix.packages:
            // `#789`-style fix: packages live in `packages.db` with a
            // richer schema (BM25 + chunk + apple_imports_json); the
            // pre-PR-2 fall-through to `handleSearchDocs(source:"packages")`
            // returned zero rows because `search.db` doesn't carry the
            // `packages` source value. Route to the dedicated handler.
            raw = try await handleSearchPackages(
                query: query,
                framework: framework,
                limit: limit,
                appleImports: appleImports
            )
        default:
            // Default (nil or "all"): search ALL sources for comprehensive results
            raw = try await handleSearchAll(
                query: query,
                framework: framework,
                limit: limit,
                minIOS: minIOS,
                minMacOS: minMacOS,
                minTvOS: minTvOS,
                minWatchOS: minWatchOS,
                minVisionOS: minVisionOS,
                minSwift: minSwift,
                appleImports: appleImports
            )
        }
        return Self.prependNoticeIfNeeded(notice: notice, to: raw)
    }

    /// #226 — format a per-platform description list (e.g. `["min_ios=18.0",
    /// "min_macos=14.0"]`) for the notice prefix. Includes the `--swift`
    /// filter when set; pre-#226 it lived in #225 Part B's filter and the
    /// notice ignored it.
    private static func platformDescriptions(
        platform: PlatformArgs,
        minSwift: String?
    ) -> [String] {
        var out: [String] = []
        if let value = platform.minIOS { out.append("min_ios=\(value)") }
        if let value = platform.minMacOS { out.append("min_macos=\(value)") }
        if let value = platform.minTvOS { out.append("min_tvos=\(value)") }
        if let value = platform.minWatchOS { out.append("min_watchos=\(value)") }
        if let value = platform.minVisionOS { out.append("min_visionos=\(value)") }
        if let value = minSwift { out.append("min_swift=\(value)") }
        return out
    }

    /// #226 — prepend the notice markdown to the first text-content block
    /// of a `CallToolResult`. No-op when notice is nil or the result has
    /// no text content. Returns a new result rather than mutating.
    static func prependNoticeIfNeeded(
        notice: String?,
        to result: MCP.Core.Protocols.CallToolResult
    ) -> MCP.Core.Protocols.CallToolResult {
        guard let notice else { return result }
        let newContent: [MCP.Core.Protocols.ContentBlock] = result.content.enumerated().map { idx, block in
            guard idx == 0, case let .text(textContent) = block else { return block }
            return .text(MCP.Core.Protocols.TextContent(text: notice + textContent.text))
        }
        return MCP.Core.Protocols.CallToolResult(content: newContent, isError: result.isError)
    }

    // MARK: - Documentation Search

    private func handleSearchDocs(
        query: String,
        source: String?,
        framework: String?,
        language: String?,
        limit: Int,
        includeArchive: Bool,
        minIOS: String?,
        minMacOS: String?,
        minTvOS: String?,
        minWatchOS: String?,
        minVisionOS: String?,
        minSwift: String?
    ) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let docsService else {
            throw Shared.Core.ToolError.invalidArgument("source", "Documentation index not available")
        }

        // Use service layer (same as CLI)
        let results = try await docsService.search(Services.SearchQuery(
            text: query,
            source: source,
            framework: framework,
            language: language,
            limit: limit,
            includeArchive: includeArchive,
            minimumiOS: minIOS,
            minimumMacOS: minMacOS,
            minimumTvOS: minTvOS,
            minimumWatchOS: minWatchOS,
            minimumVisionOS: minVisionOS,
            minimumSwift: minSwift
        ))

        // Fetch teaser results from all sources user didn't search
        let teasers = await fetchAllTeasers(
            query: query,
            framework: framework,
            currentSource: source,
            includeArchive: includeArchive
        )

        // Use shared formatter
        let filters = Services.SearchFilters(
            source: source,
            framework: framework,
            language: language,
            minimumiOS: minIOS,
            minimumMacOS: minMacOS,
            minimumTvOS: minTvOS,
            minimumWatchOS: minWatchOS,
            minimumVisionOS: minVisionOS
        )

        // Configure empty message to suggest archive if not already searching it
        var config = Services.Formatter.Config.mcpDefault
        if results.isEmpty, !includeArchive, source != Shared.Constants.SourcePrefix.appleArchive {
            config = Services.Formatter.Config(
                showScore: true,
                showWordCount: true,
                showSource: false,
                showAvailability: true,
                showSeparators: true,
                emptyMessage: Shared.Constants.Search.messageNoResults + "\n\n" + Shared.Constants.Search.tipTryArchive
            )
        }

        let formatter = Services.Formatter.Markdown(
            query: query,
            filters: filters,
            config: config,
            teasers: teasers
        )
        let markdown = formatter.format(results)

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    // MARK: - Teaser Results

    // Uses shared Services.TeaserService from Services module

    /// Fetch teaser results from all sources the user didn't search
    private func fetchAllTeasers(
        query: String,
        framework: String?,
        currentSource: String?,
        includeArchive: Bool
    ) async -> Services.Formatter.TeaserResults {
        guard let teaserService else { return Services.Formatter.TeaserResults() }
        return await teaserService.fetchAllTeasers(
            query: query,
            framework: framework,
            currentSource: currentSource,
            includeArchive: includeArchive
        )
    }

    // MARK: - Sample Code Search

    private func handleSearchSamples(
        query: String,
        framework: String?,
        limit: Int,
        minIOS: String? = nil,
        minMacOS: String? = nil,
        minTvOS: String? = nil,
        minWatchOS: String? = nil,
        minVisionOS: String? = nil
    ) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let sampleService else {
            throw Shared.Core.ToolError.invalidArgument("source", "Sample code database not available")
        }

        // #732 — pass the 5-field shape natively into `Sample.Search.Query`.
        // Multiple `min_*` values AND-combine inside
        // `Sample.Index.Database.searchProjects` SQL: a project must
        // satisfy every requested minimum to pass. #226's precedence-
        // pick translation (then needed because `searchProjects` only
        // had `(platform, minVersion)`) is gone — the 5-field path is
        // end-to-end now.
        let result = try await sampleService.search(Sample.Search.Query(
            text: query,
            framework: framework,
            searchFiles: true,
            limit: limit,
            minIOS: minIOS,
            minMacOS: minMacOS,
            minTvOS: minTvOS,
            minWatchOS: minWatchOS,
            minVisionOS: minVisionOS
        ))

        // Fetch teaser results from other sources
        let teasers = await fetchAllTeasers(
            query: query,
            framework: framework,
            currentSource: Shared.Constants.SourcePrefix.samples,
            includeArchive: false
        )

        // Use shared formatter
        let formatter = Sample.Format.Markdown.Search(query: query, framework: framework, teasers: teasers)
        let markdown = formatter.format(result)

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    // MARK: - Packages Search (packages.db)

    /// `#789`-style architectural gap fix. Pre-PR-2, MCP
    /// `search source=packages` fell through the docs path against
    /// `search.db`, which never carries the `packages` source value, so
    /// every query returned zero rows. This handler routes through the
    /// dedicated `Search.PackagesSearcher` seam against `packages.db`
    /// (which has the rich `package_metadata` + `package_files` +
    /// `package_symbols` schema). The `--apple-imports` filter applies
    /// here too. Result formatting reuses
    /// `Services.Formatter.Markdown` so the output shape matches the
    /// docs and HIG handlers; the `packages` source label naturally
    /// flows through `Search.Result.source`.
    private func handleSearchPackages(
        query: String,
        framework: String?,
        limit: Int,
        appleImports: String?
    ) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let packagesSearcher else {
            // Composition root didn't wire a `packages.db`-backed
            // searcher (file missing, schema mismatch handled at open
            // time, or test bootstrap took the legacy two-arg init).
            // Fall back to the pre-PR-2 path against `Search.Database`:
            // production search.db doesn't carry `packages` source rows
            // so this returns empty for real users (matching the
            // historical zero-result behaviour). Test fixtures that
            // synthesise `source = packages` rows in search.db continue
            // to be served by the legacy path. `apple_imports` is
            // dropped on the fallback — search.db has no
            // `apple_imports_json` column to filter against.
            return try await handleSearchDocs(
                query: query,
                source: Shared.Constants.SourcePrefix.packages,
                framework: framework,
                language: nil,
                limit: limit,
                includeArchive: false,
                minIOS: nil,
                minMacOS: nil,
                minTvOS: nil,
                minWatchOS: nil,
                minVisionOS: nil,
                minSwift: nil
            )
        }

        let results = try await packagesSearcher.searchPackages(
            query: query,
            limit: limit,
            availability: nil,
            swiftTools: nil,
            appleImport: appleImports
        )

        let teasers = await fetchAllTeasers(
            query: query,
            framework: framework,
            currentSource: Shared.Constants.SourcePrefix.packages,
            includeArchive: false
        )

        let filters = Services.SearchFilters(
            source: Shared.Constants.SourcePrefix.packages,
            framework: framework,
            language: nil,
            minimumiOS: nil,
            minimumMacOS: nil,
            minimumTvOS: nil,
            minimumWatchOS: nil,
            minimumVisionOS: nil
        )
        let formatter = Services.Formatter.Markdown(
            query: query,
            filters: filters,
            config: .mcpDefault,
            teasers: teasers
        )
        let markdown = formatter.format(results)

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    // MARK: - HIG Search

    private func handleSearchHIG(
        query: String,
        framework: String?,
        limit: Int
    ) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let docsService else {
            throw Shared.Core.ToolError.invalidArgument("source", "Documentation index not available")
        }

        // Use service layer (same as CLI)
        let results = try await docsService.search(Services.SearchQuery(
            text: query,
            source: Shared.Constants.SourcePrefix.hig,
            framework: framework,
            language: nil,
            limit: limit,
            includeArchive: false
        ))

        // Fetch teaser results from other sources
        let teasers = await fetchAllTeasers(
            query: query,
            framework: framework,
            currentSource: Shared.Constants.SourcePrefix.hig,
            includeArchive: false
        )

        // Use shared formatter
        let higQuery = Services.HIGQuery(text: query, platform: nil, category: nil)
        let formatter = Services.Formatter.HIG.Markdown(query: higQuery, config: .mcpDefault, teasers: teasers)
        let markdown = formatter.format(results)

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    // MARK: - Unified Search (All Sources)

    private func handleSearchAll(
        query: String,
        framework: String?,
        limit: Int,
        minIOS: String? = nil,
        minMacOS: String? = nil,
        minTvOS: String? = nil,
        minWatchOS: String? = nil,
        minVisionOS: String? = nil,
        minSwift: String? = nil,
        appleImports: String? = nil
    ) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let unifiedService else {
            throw Shared.Core.ToolError.invalidArgument("source", "No indexes available for unified search")
        }
        // #226 expansion: thread platform args + minSwift through the
        // fan-out so each search.db-backed fetcher applies the filter at
        // SQL time. Samples within the fan-out remain unfiltered until
        // the #732 follow-up extends `Sample.Index.Database.searchProjects`
        // — `PlatformFilterScope` partitions samples into the unaware
        // bucket so the partial-filter notice still fires for fan-out +
        // platform args (see `handleSearch` line 535).
        //
        // `#837` PR-2 expansion: `appleImports` is threaded into the
        // packages bucket only. The other 7 sources ignore it.
        let rawInput = await unifiedService.searchAll(
            query: query,
            framework: framework,
            limit: limit,
            minIOS: minIOS,
            minMacOS: minMacOS,
            minTvOS: minTvOS,
            minWatchOS: minWatchOS,
            minVisionOS: minVisionOS,
            minSwift: minSwift,
            appleImports: appleImports
        )

        // #648 (open-time path) — main's post-#642 retest found that the
        // existing `classifyDegradation` plumbing only fires for per-
        // fetcher errors thrown at query time. When `search.db` fails
        // to open at server startup (#645's path), `unifiedService` is
        // constructed with `searchIndex: nil`; the apple-docs / hig /
        // swift-evolution / apple-archive / swift-org / swift-book
        // fetchers register as unavailable and are never called for
        // the query, so no per-fetcher throw exists to classify and
        // `degradedSources` stays empty. The renderer then claims
        // `_Searched ALL sources_` while in fact only samples + packages
        // ran. Bridge the gap here: when `searchIndexDisabledReason` is
        // set on the provider, synthesise one `DegradedSource` per
        // search.db-backed source and merge them into the formatter
        // input. The Markdown / Text / JSON renderers already gate the
        // warning blockquote + "Searched: <list>" line off
        // `degradedSources`, so the same render path now triggers for
        // the open-time path with no formatter changes.
        let input = Self.injectOpenTimeDegradation(
            into: rawInput,
            disabledReason: searchIndexDisabledReason
        )

        // Use shared formatter (identical to CLI --format markdown output)
        let formatter = Services.Formatter.Unified.Markdown(
            query: query,
            framework: framework,
            config: .mcpDefault
        )
        let markdown = formatter.format(input)

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    /// #648 — merge synthetic `DegradedSource` entries for each search.db-
    /// backed source into the formatter input when `disabledReason` is
    /// set. Pure function; no provider state captured beyond the
    /// parameters. Lifted to an internal static so the tests pin the
    /// merge logic (preserved-fields, source-list, dedup-against-existing-
    /// entries) without standing up the full `handleSearchAll` pipeline.
    static func injectOpenTimeDegradation(
        into input: Services.Formatter.Unified.Input,
        disabledReason: String?
    ) -> Services.Formatter.Unified.Input {
        guard let disabledReason else { return input }

        // The 6 sources backed by `search.db`. `samples` (samples.db) and
        // `packages` (packages.db) live in different DBs and aren't
        // affected by `search.db` being closed, so they stay out of the
        // synthesized degraded list.
        let searchDBSources: [String] = [
            Shared.Constants.SourcePrefix.appleDocs,
            Shared.Constants.SourcePrefix.appleArchive,
            Shared.Constants.SourcePrefix.hig,
            Shared.Constants.SourcePrefix.swiftEvolution,
            Shared.Constants.SourcePrefix.swiftOrg,
            Shared.Constants.SourcePrefix.swiftBook,
        ]

        // Dedup against anything the per-fetcher classifier already
        // populated (in practice it won't have, because fetchers don't
        // even run when `searchIndex` is nil — but a future refactor
        // that wires partial-fetcher availability shouldn't double-
        // count).
        let existing = Set(input.degradedSources.map(\.name))
        let synthesised = searchDBSources
            .filter { !existing.contains($0) }
            .map { Search.DegradedSource(name: $0, reason: disabledReason) }

        return Services.Formatter.Unified.Input(
            docResults: input.docResults,
            archiveResults: input.archiveResults,
            sampleResults: input.sampleResults,
            higResults: input.higResults,
            swiftEvolutionResults: input.swiftEvolutionResults,
            swiftOrgResults: input.swiftOrgResults,
            swiftBookResults: input.swiftBookResults,
            packagesResults: input.packagesResults,
            limit: input.limit,
            degradedSources: input.degradedSources + synthesised
        )
    }

    // MARK: - List Frameworks

    private func handleListFrameworks() async throws -> MCP.Core.Protocols.CallToolResult {
        guard let searchIndex else {
            throw searchIndexUnavailableError("index")
        }

        let frameworks = try await searchIndex.listFrameworks()
        let totalDocs = try await searchIndex.documentCount()

        let formatter = Services.Formatter.Frameworks.Markdown(totalDocs: totalDocs)
        let markdown = formatter.format(frameworks)

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    // MARK: - Read Document

    private func handleReadDocument(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let searchIndex else {
            throw searchIndexUnavailableError("index")
        }

        let rawURI: String = try args.require(Shared.Constants.Search.schemaParamURI)
        // #587: accept canonical Apple Developer web URLs by converting
        // them to `apple-docs://...` before search.db lookup, matching
        // CLI `cupertino read`'s entry-point normalisation. Users
        // and AI agents routinely pass web URLs through MCP; rejecting
        // them at the boundary forced clients to learn the URI scheme.
        let uri = Self.normalizeReadDocumentURI(rawURI)
        let formatString = args.format()
        let format: Search.DocumentFormat = formatString == Shared.Constants.Search.formatValueMarkdown
            ? .markdown : .json

        if let documentContent = try await searchIndex.getDocumentContent(uri: uri, format: format) {
            return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: documentContent))])
        }

        // #582: search-index direct lookup missed. Fall back through the
        // same path `resources/read` uses (filesystem read of the
        // crawled JSON / MD under `<outputDirectory>/<framework>/`).
        // Pre-fix the two paths were asymmetric and the tool failed for
        // URIs the resource path accepted (typical with bundles whose
        // indexer-written URIs use the pre-#293 `.lastPathComponent`
        // shape while the resource-list URI generator already used
        // `URLUtilities.filename(from:)`).
        if let provider = documentResourceProvider {
            let result = try await provider.readResource(uri: uri)
            if let firstText = result.contents.compactMap({ contents -> String? in
                if case .text(let textContents) = contents {
                    return textContents.text
                }
                return nil
            }).first {
                return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: firstText))])
            }
        }

        throw Shared.Core.ToolError.invalidArgument(
            Shared.Constants.Search.schemaParamURI,
            "Document not found: \(uri)"
        )
    }

    // MARK: - Read Document — URI normalisation (#587)

    /// Convert a canonical Apple Developer web URL into the lossless
    /// `apple-docs://...` URI shape; pass anything else through unchanged.
    /// Mirrors `Services.ReadService.normalizeIdentifier` so CLI `cupertino
    /// read` and MCP `read_document` accept the same input shapes.
    static func normalizeReadDocumentURI(_ raw: String) -> String {
        guard raw.hasPrefix("https://") || raw.hasPrefix("http://") else {
            return raw
        }
        guard let url = URL(string: raw),
              let uri = Shared.Models.URLUtilities.appleDocsURI(from: url)
        else {
            return raw
        }
        return uri
    }

    // MARK: - Sample Code Tools

    private func handleListSamples(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let sampleDatabase else {
            throw Shared.Core.ToolError.invalidArgument("database", "Sample code database not available")
        }

        let framework = args.optional(Shared.Constants.Search.schemaParamFramework)
        let limit = args.limit(default: 50)

        let projects = try await sampleDatabase.listProjects(framework: framework, limit: limit)
        let totalProjects = try await sampleDatabase.projectCount()
        let totalFiles = try await sampleDatabase.fileCount()

        var markdown = "# Indexed Sample Code Projects\n\n"
        markdown += "Total projects: **\(totalProjects)**\n"
        markdown += "Total files: **\(totalFiles)**\n\n"

        if let framework {
            markdown += "_Filtered to framework: **\(framework)**_\n\n"
        }

        if projects.isEmpty {
            markdown += "_No projects found. Run `cupertino save --samples` to index sample code._\n"
        } else {
            markdown += "| Project | Framework | Files |\n"
            markdown += "|---------|-----------|------:|\n"

            for project in projects {
                let frameworks = project.frameworks.joined(separator: ", ")
                markdown += "| `\(project.id)` | \(frameworks) | \(project.fileCount) |\n"
            }

            markdown += "\n"
            markdown += "💡 **Tip:** Use `search` with `source: samples` to find projects by keyword."
            markdown += "\n"
        }

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    private func handleReadSample(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let sampleDatabase else {
            throw Shared.Core.ToolError.invalidArgument("database", "Sample code database not available")
        }

        let projectId: String = try args.require(Shared.Constants.Search.schemaParamProjectId)

        guard let project = try await sampleDatabase.getProject(id: projectId) else {
            throw Shared.Core.ToolError.invalidArgument(
                Shared.Constants.Search.schemaParamProjectId,
                "Project not found: \(projectId)"
            )
        }

        var markdown = "# \(project.title)\n\n"
        markdown += "**Project ID:** `\(project.id)`\n\n"

        if !project.description.isEmpty {
            markdown += "## Description\n\n"
            markdown += project.description + "\n\n"
        }

        markdown += "## Metadata\n\n"
        markdown += "- **Frameworks:** \(project.frameworks.joined(separator: ", "))\n"
        markdown += "- **Files:** \(project.fileCount)\n"
        markdown += "- **Size:** \(Shared.Utils.Formatting.formatBytes(project.totalSize))\n"
        if !project.webURL.isEmpty {
            markdown += "- **Apple Developer:** \(project.webURL)\n"
        }
        markdown += "\n"

        if let readme = project.readme, !readme.isEmpty {
            markdown += "## README\n\n"
            markdown += readme
            markdown += "\n\n"
        }

        // List some files
        let files = try await sampleDatabase.listFiles(projectId: projectId, folder: nil)
        if !files.isEmpty {
            markdown += "## Files (\(files.count) total)\n\n"
            for file in files.prefix(30) {
                markdown += "- `\(file.path)`\n"
            }
            if files.count > 30 {
                markdown += "- _... and \(files.count - 30) more files_\n"
            }
            markdown += "\n"
            markdown += "💡 Use `read_sample_file` with project_id and file_path to view source code.\n"
        }

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    private func handleReadSampleFile(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let sampleDatabase else {
            throw Shared.Core.ToolError.invalidArgument("database", "Sample code database not available")
        }

        let projectId: String = try args.require(Shared.Constants.Search.schemaParamProjectId)
        let filePath: String = try args.require(Shared.Constants.Search.schemaParamFilePath)

        guard let file = try await sampleDatabase.getFile(projectId: projectId, path: filePath) else {
            throw Shared.Core.ToolError.invalidArgument(
                Shared.Constants.Search.schemaParamFilePath,
                "File not found: \(filePath) in project \(projectId)"
            )
        }

        var markdown = "# \(file.filename)\n\n"
        markdown += "**Project:** `\(file.projectId)`\n"
        markdown += "**Path:** `\(file.path)`\n"
        markdown += "**Size:** \(Shared.Utils.Formatting.formatBytes(file.size))\n\n"

        let language = languageForExtension(file.fileExtension)

        markdown += "```\(language)\n"
        markdown += file.content
        if !file.content.hasSuffix("\n") {
            markdown += "\n"
        }
        markdown += "```\n"

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    // MARK: - Semantic Search Handlers (#81)

    private func handleSearchSymbols(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let searchIndex else {
            throw searchIndexUnavailableError("index")
        }

        let query = args.optional(Shared.Constants.Search.schemaParamQuery)
        let kind = args.optional(Shared.Constants.Search.schemaParamKind)
        let isAsync = args.optionalBool(Shared.Constants.Search.schemaParamIsAsync)
        let framework = args.optional(Shared.Constants.Search.schemaParamFramework)
        let limit = args.limit()
        let platform = try Self.extractPlatformArgs(args)

        let results = try await searchIndex.searchSymbols(
            query: query,
            kind: kind,
            isAsync: isAsync,
            framework: framework,
            limit: limit
        )
        let filtered = try await Self.applyPlatformFilter(
            results: results, platform: platform, searchIndex: searchIndex
        )

        let markdown = formatSymbolResults(
            results: filtered,
            title: "Symbol Search Results",
            query: query,
            filters: ["kind": kind, "is_async": isAsync.map { String($0) }, "framework": framework]
        )

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    private func handleSearchPropertyWrappers(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let searchIndex else {
            throw searchIndexUnavailableError("index")
        }

        let wrapper: String = try args.require(Shared.Constants.Search.schemaParamWrapper)
        let framework = args.optional(Shared.Constants.Search.schemaParamFramework)
        let limit = args.limit()
        let platform = try Self.extractPlatformArgs(args)

        let raw = try await searchIndex.searchPropertyWrappers(
            wrapper: wrapper,
            framework: framework,
            limit: limit
        )
        let results = try await Self.applyPlatformFilter(
            results: raw, platform: platform, searchIndex: searchIndex
        )

        let normalizedWrapper = wrapper.hasPrefix("@") ? wrapper : "@\(wrapper)"
        let markdown = formatSymbolResults(
            results: results,
            title: "Property Wrapper: \(normalizedWrapper)",
            query: wrapper,
            filters: ["wrapper": wrapper, "framework": framework]
        )

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    private func handleSearchConcurrency(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let searchIndex else {
            throw searchIndexUnavailableError("index")
        }

        let pattern: String = try args.require(Shared.Constants.Search.schemaParamPattern)
        let framework = args.optional(Shared.Constants.Search.schemaParamFramework)
        let limit = args.limit()
        let platform = try Self.extractPlatformArgs(args)

        let raw = try await searchIndex.searchConcurrencyPatterns(
            pattern: pattern,
            framework: framework,
            limit: limit
        )
        let results = try await Self.applyPlatformFilter(
            results: raw, platform: platform, searchIndex: searchIndex
        )

        let markdown = formatSymbolResults(
            results: results,
            title: "Concurrency Pattern: \(pattern)",
            query: pattern,
            filters: ["pattern": pattern, "framework": framework]
        )

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    // MARK: - Inheritance walk (#274)

    // swiftlint:disable:next function_body_length
    private func handleGetInheritance(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let searchIndex else {
            throw searchIndexUnavailableError("index")
        }

        let symbol: String = try args.require(Shared.Constants.Search.schemaParamSymbol)
        let directionString = args.optional(Shared.Constants.Search.schemaParamDirection) ?? "up"
        let frameworkFilter = args.optional(Shared.Constants.Search.schemaParamFramework)
        let depth = args.optional(Shared.Constants.Search.schemaParamDepth, default: 5)

        guard let direction = Search.InheritanceDirection(rawValue: directionString.lowercased()) else {
            throw Shared.Core.ToolError.invalidArgument(
                Shared.Constants.Search.schemaParamDirection,
                "Invalid direction `\(directionString)`. Valid values: up, down, both."
            )
        }
        guard depth > 0 else {
            throw Shared.Core.ToolError.invalidArgument(
                Shared.Constants.Search.schemaParamDepth,
                "Depth must be at least 1; got \(depth)."
            )
        }

        let candidates = try await searchIndex.resolveSymbolURIs(title: symbol)
        let candidate: Search.InheritanceCandidate
        switch candidates.count {
        case 0:
            let body = "No symbol named `\(symbol)` in apple-docs. " +
                "Try `search` first to find the right name, or check `list_frameworks`."
            return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: body))])
        case 1:
            candidate = candidates[0]
        default:
            if let frameworkFilter,
               let match = candidates.first(where: { $0.framework.lowercased() == frameworkFilter.lowercased() }) {
                candidate = match
            } else {
                // Ambiguity → emit a disambiguation block per the
                // `get_symbol_summary` pattern (#70).
                var body = "`\(symbol)` is ambiguous across \(candidates.count) frameworks. " +
                    "Re-call with the matching `framework` argument:\n\n"
                for candidate in candidates {
                    body += "- `\(candidate.title)` in `\(candidate.framework)` — \(candidate.uri)\n"
                }
                return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: body))])
            }
        }

        let tree = try await searchIndex.walkInheritance(
            startURI: candidate.uri,
            direction: direction,
            maxDepth: depth
        )

        var body = "# Inheritance: \(candidate.title)\n\n"
        body += "**URI:** `\(candidate.uri)`  **Framework:** `\(candidate.framework)`  **Direction:** `\(direction.rawValue)`  **Depth:** `\(depth)`\n\n"
        if tree.isEmpty {
            body += "_No inheritance data — Swift value types and protocols don't carry inherits-from edges. " +
                "Check `search_conformances` if you're looking for protocol conformance instead._\n"
            return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: body))])
        }
        if !tree.ancestors.isEmpty {
            body += "## Inherits from\n\n"
            renderInheritanceTreeMarkdown(tree.ancestors, indent: 0, into: &body)
            body += "\n"
        }
        if !tree.descendants.isEmpty {
            body += "## Inherited by\n\n"
            renderInheritanceTreeMarkdown(tree.descendants, indent: 0, into: &body)
        }
        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: body))])
    }

    private func renderInheritanceTreeMarkdown(
        _ nodes: [Search.InheritanceNode],
        indent: Int,
        into body: inout String
    ) {
        let pad = String(repeating: "  ", count: indent)
        for node in nodes {
            body += "\(pad)- `\(node.uri)`\n"
            if !node.children.isEmpty {
                renderInheritanceTreeMarkdown(node.children, indent: indent + 1, into: &body)
            }
        }
    }

    private func handleSearchConformances(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        guard let searchIndex else {
            throw searchIndexUnavailableError("index")
        }

        let protocolName: String = try args.require(Shared.Constants.Search.schemaParamProtocol)
        let framework = args.optional(Shared.Constants.Search.schemaParamFramework)
        let limit = args.limit()
        let platform = try Self.extractPlatformArgs(args)

        let raw = try await searchIndex.searchConformances(
            protocolName: protocolName,
            framework: framework,
            limit: limit
        )
        let results = try await Self.applyPlatformFilter(
            results: raw, platform: platform, searchIndex: searchIndex
        )

        let markdown = formatSymbolResults(
            results: results,
            title: "Protocol Conformance: \(protocolName)",
            query: protocolName,
            filters: ["protocol": protocolName, "framework": framework]
        )

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    // MARK: - #226 — platform filter helpers (shared across 4 search-style tools)

    /// 5-tuple of `min*` filter strings extracted from the MCP call's
    /// arguments. All optional. Empty / missing fields stay nil so the
    /// `Search.PlatformFilter.passes(...)` predicate treats them as
    /// "no constraint" downstream.
    private struct PlatformArgs {
        let minIOS: String?
        let minMacOS: String?
        let minTvOS: String?
        let minWatchOS: String?
        let minVisionOS: String?

        var isAnySet: Bool {
            minIOS != nil || minMacOS != nil || minTvOS != nil || minWatchOS != nil || minVisionOS != nil
        }
    }

    private static func extractPlatformArgs(
        _ args: MCP.SharedTools.ArgumentExtractor
    ) throws -> PlatformArgs {
        try PlatformArgs(
            minIOS: validatePlatformValue(
                args.optional(Shared.Constants.Search.schemaParamMinIOS),
                paramName: Shared.Constants.Search.schemaParamMinIOS
            ),
            minMacOS: validatePlatformValue(
                args.optional(Shared.Constants.Search.schemaParamMinMacOS),
                paramName: Shared.Constants.Search.schemaParamMinMacOS
            ),
            minTvOS: validatePlatformValue(
                args.optional(Shared.Constants.Search.schemaParamMinTvOS),
                paramName: Shared.Constants.Search.schemaParamMinTvOS
            ),
            minWatchOS: validatePlatformValue(
                args.optional(Shared.Constants.Search.schemaParamMinWatchOS),
                paramName: Shared.Constants.Search.schemaParamMinWatchOS
            ),
            minVisionOS: validatePlatformValue(
                args.optional(Shared.Constants.Search.schemaParamMinVisionOS),
                paramName: Shared.Constants.Search.schemaParamMinVisionOS
            )
        )
    }

    /// #226 — reject empty or malformed `min_<platform>` values up-front so
    /// they cannot silently no-op past the filter. Pre-#226 the args
    /// extractor accepted any string (or nil); empty strings, whitespace,
    /// and shapes like `"v18.0"` / `"18"` / `"ios18.0"` all flowed through
    /// to `Search.PlatformFilter.passes(...)` which compares lexicographic
    /// after splitting on `.`, producing surprising (and silently wrong)
    /// matches.
    ///
    /// Validation rule: a value is acceptable when its trimmed form matches
    /// `<digits>(\.<digits>)*` (semver-prefix shape — major, major.minor,
    /// or major.minor.patch). Any other shape rejects with a
    /// `ToolError.invalidArgument` carrying the offending param name so the
    /// MCP client sees a clear error frame rather than a silent no-op.
    ///
    /// Returns the *trimmed* value (or nil) so downstream callers consume
    /// the canonical form.
    static func validatePlatformValue(
        _ raw: String?,
        paramName: String
    ) throws -> String? {
        guard let raw else { return nil }
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else {
            throw Shared.Core.ToolError.invalidArgument(
                paramName,
                "Platform version filter must not be empty / whitespace-only — pass nil to omit the filter or a numeric version like \"18.0\"."
            )
        }
        // Permitted: digits, optional dot-separated digit groups.
        // Examples: "18", "18.0", "18.0.1". Rejected: "v18.0", "18.0a",
        // "ios18", "18..0", ".18", "18.".
        let parts = trimmed.split(separator: ".", omittingEmptySubsequences: false)
        let allDigitGroups = parts.allSatisfy { part in
            !part.isEmpty && part.allSatisfy(\.isWholeNumber)
        }
        guard allDigitGroups else {
            throw Shared.Core.ToolError.invalidArgument(
                paramName,
                "Platform version filter must be a numeric semver-prefix (e.g. \"18\", \"18.0\", \"18.0.1\") — got \"\(raw)\"."
            )
        }
        return trimmed
    }

    /// Apply the MCP-level platform filter to a `[SymbolSearchResult]`.
    /// Short-circuits when no filter is set (the common case) — returns
    /// the input unchanged. When set, batch-fetches `docs_metadata.min_*`
    /// for each result's URI in one query, then filters in-process via
    /// `Search.PlatformFilter.passes`. Same semver semantics as the
    /// unified `search` tool (`Search.Index.Search.swift:730`).
    private static func applyPlatformFilter(
        results: [Search.SymbolSearchResult],
        platform: PlatformArgs,
        searchIndex: any Search.Database
    ) async throws -> [Search.SymbolSearchResult] {
        guard platform.isAnySet, !results.isEmpty else { return results }
        let uris = results.map(\.docUri)
        let minimaByURI = try await searchIndex.fetchPlatformMinima(uris: uris)
        return results.filter { row in
            Search.PlatformFilter.passes(
                minima: minimaByURI[row.docUri],
                minIOS: platform.minIOS,
                minMacOS: platform.minMacOS,
                minTvOS: platform.minTvOS,
                minWatchOS: platform.minWatchOS,
                minVisionOS: platform.minVisionOS
            )
        }
    }

    /// #665 / #409 Layer 2 — surfaces `doc_symbols.generic_params`.
    /// #226 follow-up — applies the MCP-level platform filter post-search
    /// to match the other 4 AST tools (search_symbols /
    /// search_property_wrappers / search_concurrency / search_conformances).
    ///
    /// `#857` v1.2.0 expansion: fan out across all three databases
    /// (search.db apple-docs, samples.db `file_symbols`, packages.db
    /// `package_symbols`). The search.db arm preserves the pre-`#857`
    /// rich `Search.SymbolSearchResult` rendering (`formatSymbolResults`)
    /// so callers that relied on the structured output for apple-docs
    /// keep their behaviour. Samples + packages get appended below the
    /// apple-docs block, each in their own section so the response is
    /// readable as a single markdown blob. When `searchIndex` is nil but
    /// `sampleDatabase` or `packagesSearcher` is available, the apple-
    /// docs arm is skipped silently and the response is built from the
    /// remaining sources — closer to the `SmartQuery` fan-out behaviour
    /// for default search than the pre-`#857` all-or-nothing path.
    private func handleSearchGenerics(args: MCP.SharedTools.ArgumentExtractor) async throws -> MCP.Core.Protocols.CallToolResult {
        // `#645` semantic preserved: when search.db is in the
        // disabled-reason state (file present but unopenable, e.g.
        // schema mismatch), still throw an explicit error frame so MCP
        // clients see the configuration-level failure rather than a
        // silently-degraded cross-DB response.
        if searchIndex == nil, let _ = searchIndexDisabledReason {
            throw searchIndexUnavailableError("index")
        }

        let constraint: String = try args.require(Shared.Constants.Search.schemaParamConstraint)
        let framework = args.optional(Shared.Constants.Search.schemaParamFramework)
        let limit = args.limit()
        let platform = try Self.extractPlatformArgs(args)

        // Source A: search.db apple-docs (rich `Search.SymbolSearchResult`).
        var appleDocsMarkdown: String?
        if let searchIndex {
            let raw = try await searchIndex.searchByGenericConstraint(
                constraint: constraint,
                framework: framework,
                limit: limit
            )
            let results = try await Self.applyPlatformFilter(
                results: raw, platform: platform, searchIndex: searchIndex
            )
            appleDocsMarkdown = formatSymbolResults(
                results: results,
                title: "Apple Docs",
                query: constraint,
                filters: ["constraint": constraint, "framework": framework]
            )
        }

        // Source B: samples.db `file_symbols`. Default-impl returns
        // empty array when the reader hasn't been updated, keeping the
        // fan-out resilient to mixed bundle versions.
        var samplesRows: [Sample.Index.FileSearchResult] = []
        if let sampleDatabase {
            samplesRows = await (try? sampleDatabase.searchFilesByGenericConstraint(
                constraint: constraint,
                framework: framework,
                limit: limit
            )) ?? []
        }

        // Source C: packages.db `package_symbols` (joined to
        // `package_files` + `package_metadata` for owner/repo/module).
        var packagesRows: [Search.Result] = []
        if let packagesSearcher {
            packagesRows = await (try? packagesSearcher.searchPackageSymbolsByGenericConstraint(
                constraint: constraint,
                framework: framework,
                limit: limit
            )) ?? []
        }

        let markdown = Self.formatCrossDBGenerics(
            constraint: constraint,
            framework: framework,
            appleDocsMarkdown: appleDocsMarkdown,
            samples: samplesRows,
            packages: packagesRows
        )

        return MCP.Core.Protocols.CallToolResult(content: [.text(MCP.Core.Protocols.TextContent(text: markdown))])
    }

    /// `#857` cross-DB result renderer. Each contributing source emits
    /// its own section, source-tagged in the header so AI agents reading
    /// the response can identify provenance per row without parsing the
    /// surrounding markdown. The apple-docs section reuses the existing
    /// `formatSymbolResults` output so legacy parsers continue to work
    /// against the pre-`#857` shape for that source.
    static func formatCrossDBGenerics(
        constraint: String,
        framework: String?,
        appleDocsMarkdown: String?,
        samples: [Sample.Index.FileSearchResult],
        packages: [Search.Result]
    ) -> String {
        var blocks: [String] = []

        let headerFilters: String = {
            var parts = ["constraint=\(constraint)"]
            if let framework, !framework.isEmpty { parts.append("framework=\(framework)") }
            return parts.joined(separator: ", ")
        }()
        blocks.append("# Generic Constraint: \(constraint)\n")
        blocks.append("**Filters:** \(headerFilters)\n")

        if let appleDocsMarkdown, !appleDocsMarkdown.isEmpty {
            blocks.append("## Apple Docs (search.db)\n")
            blocks.append(appleDocsMarkdown)
        } else {
            blocks.append("## Apple Docs (search.db)\n_No symbols found in apple-docs._\n")
        }

        blocks.append("## Sample Code (samples.db)\n")
        if samples.isEmpty {
            blocks.append("_No symbols found in samples._\n")
        } else {
            blocks.append("Found **\(samples.count)** matching files:\n")
            for row in samples {
                blocks.append("- **\(row.filename)** (`\(row.projectId)`) — \(row.path)")
                if !row.snippet.isEmpty {
                    blocks.append("  ``")
                    blocks.append("  \(row.snippet.replacingOccurrences(of: "\n", with: " "))")
                    blocks.append("  ``")
                }
            }
            blocks.append("")
        }

        blocks.append("## Swift Packages (packages.db)\n")
        if packages.isEmpty {
            blocks.append("_No symbols found in packages._\n")
        } else {
            blocks.append("Found **\(packages.count)** matching symbols:\n")
            for row in packages {
                blocks.append("- **\(row.title)** (`\(row.framework)`) — `\(row.uri)`")
                if !row.summary.isEmpty {
                    let oneLiner = row.summary.replacingOccurrences(of: "\n", with: " ")
                    blocks.append("  ``\(oneLiner)``")
                }
            }
            blocks.append("")
        }

        return blocks.joined(separator: "\n")
    }

    /// Format symbol search results as markdown
    private func formatSymbolResults(
        results: [Search.SymbolSearchResult],
        title: String,
        query: String?,
        filters: [String: String?]
    ) -> String {
        var markdown = "# \(title)\n\n"

        // Show active filters
        let activeFilters = filters.compactMapValues { $0 }
        if !activeFilters.isEmpty {
            markdown += "**Filters:** "
            markdown += activeFilters.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
            markdown += "\n\n"
        }

        if results.isEmpty {
            markdown += "_No symbols found matching your criteria._\n\n"
            markdown += "💡 **Tips:**\n"
            markdown += "- Try a broader search pattern\n"
            markdown += "- Check available symbol kinds: struct, class, actor, enum, protocol, function, property\n"
            return markdown
        }

        markdown += "Found **\(results.count)** symbols:\n\n"

        // Group by document for better organization
        var byDocument: [String: [(Search.SymbolSearchResult, Int)]] = [:]
        for (index, result) in results.enumerated() {
            byDocument[result.docUri, default: []].append((result, index))
        }

        for (docUri, symbols) in byDocument.sorted(by: { $0.key < $1.key }) {
            let firstSymbol = symbols[0].0
            markdown += "### \(firstSymbol.docTitle)\n"
            markdown += "_Framework: \(firstSymbol.framework.isEmpty ? "unknown" : firstSymbol.framework)_ "
            markdown += "| URI: `\(docUri)`\n\n"

            for (symbol, _) in symbols {
                markdown += "- **\(symbol.symbolKind)** `\(symbol.symbolName)`"
                if symbol.isAsync {
                    markdown += " `async`"
                }
                if let sig = symbol.signature, !sig.isEmpty {
                    let truncatedSig = sig.count > 60 ? String(sig.prefix(60)) + "..." : sig
                    markdown += "\n  - Signature: `\(truncatedSig)`"
                }
                if let attrs = symbol.attributes, !attrs.isEmpty {
                    markdown += "\n  - Attributes: \(attrs)"
                }
                if let conforms = symbol.conformances, !conforms.isEmpty {
                    markdown += "\n  - Conforms to: \(conforms)"
                }
                if let generics = symbol.genericParams, !generics.isEmpty {
                    markdown += "\n  - Generic params: `\(generics)`"
                }
                markdown += "\n"
            }
            markdown += "\n"
        }

        markdown += "---\n"
        markdown += "💡 Use `read_document` with the URI to get the full documentation.\n"

        return markdown
    }

    // MARK: - Helpers

    private func languageForExtension(_ ext: String) -> String {
        switch ext.lowercased() {
        case "swift": return "swift"
        case "h", "m", "mm": return "objc"
        case "c": return "c"
        case "cpp", "hpp": return "cpp"
        case "metal": return "metal"
        case "json": return "json"
        case "plist": return "xml"
        case "md": return "markdown"
        case "strings": return "properties"
        default: return ext
        }
    }

    // MARK: - Schema descriptions

    /// Pulled out as `static let` constants so each description can be
    /// long without tripping SwiftLint's 200-char line-length cap.
    private static let schemaDescriptionMinSwift = """
    Maximum Swift toolchain version for swift-evolution results \
    (e.g. 5.5, 6.0). Filters swift-evolution proposals to those \
    implemented at or below the given version; rows from other \
    sources (apple-docs, samples, hig, swift-org, swift-book, \
    packages) are filtered out when this is set.
    """

    private static let schemaDescriptionAppleImports = """
    Restrict packages results to packages that import the given \
    Apple framework (e.g. SwiftUI, Combine, CryptoKit). Filters on \
    packages.db's apple_imports_json column via a quote-bracketed \
    JSON LIKE so SwiftUI does not match SwiftUIHelper. No effect on \
    rows from sources other than packages.
    """
}
