// file: libs/sdk/src/skill/flows/search-skills.flow.ts

import { z } from '@frontmcp/lazy-zod';

import { Flow, FlowBase, FlowHooksOf, normalizeToolRef, type FlowPlan, type FlowRunOptions } from '../../common';
import { DependencyNotFoundError, InvalidInputError } from '../../errors';
import { filterSkillMetadataByAuthorities } from '../skill-authorities.helper';
import { type SkillSearchOptions, type SkillSearchResult } from '../skill-storage.interface';

/**
 * Global symbol for the observability TelemetryAccessor token. Mirrors the
 * `Symbol.for('frontmcp:observability:telemetry-accessor')` declared by
 * `@frontmcp/observability` so we can probe for it without taking a hard
 * import dependency on that package (it's an optional peer dep).
 */
const TELEMETRY_ACCESSOR_TOKEN = Symbol.for('frontmcp:observability:telemetry-accessor');

/** Minimal duck-typed surface we use from TelemetryAccessor — keeps SDK isolated. */
interface FlowTelemetryAccessor {
  addEvent(name: string, attributes?: Record<string, string | number | boolean>): void;
  setAttributes(attrs: Record<string, string | number | boolean>): void;
}

/**
 * Probe for the observability TelemetryAccessor without taking a hard import
 * dependency. Returns undefined when ObservabilityPlugin is not installed.
 *
 * Only swallows `DependencyNotFoundError` — any other error (DI
 * misconfiguration, circular deps, factory throws) propagates so real bugs
 * are surfaced rather than silently degrading telemetry.
 */
function tryGetTelemetry(flow: SearchSkillsFlow): FlowTelemetryAccessor | undefined {
  try {
    const t = flow.get(TELEMETRY_ACCESSOR_TOKEN as never) as unknown;
    if (t && typeof (t as FlowTelemetryAccessor).addEvent === 'function') {
      return t as FlowTelemetryAccessor;
    }
    return undefined;
  } catch (err) {
    if (err instanceof DependencyNotFoundError) return undefined;
    throw err;
  }
}

// Input schema matching MCP request format
const inputSchema = z.object({
  request: z.object({
    method: z.literal('skills/search'),
    params: z.object({
      query: z.string().min(1).describe('Search query to find relevant skills'),
      tags: z.array(z.string()).optional().describe('Filter by specific tags'),
      tools: z.array(z.string()).optional().describe('Filter by skills that use specific tools'),
      limit: z.number().min(1).max(50).default(10).describe('Maximum number of results to return'),
      requireAllTools: z.boolean().default(false).describe('Only return skills where all tools are available'),
    }),
  }),
  ctx: z.unknown(),
});

// Output schema
const outputSchema = z.object({
  skills: z.array(
    z.object({
      id: z.string(),
      name: z.string(),
      description: z.string(),
      score: z.number(),
      tags: z.array(z.string()).optional(),
      tools: z.array(
        z.object({
          name: z.string(),
          available: z.boolean(),
        }),
      ),
      source: z.enum(['local', 'external']),
    }),
  ),
  total: z.number(),
  hasMore: z.boolean(),
});

type Input = z.infer<typeof inputSchema>;
type Output = z.infer<typeof outputSchema>;

const stateSchema = z.object({
  query: z.string(),
  options: z.object({
    tags: z.array(z.string()).optional(),
    tools: z.array(z.string()).optional(),
    topK: z.number().optional(),
    requireAllTools: z.boolean().optional(),
  }),
  results: z.array(z.any()),
  output: outputSchema,
});

const plan = {
  pre: ['parseInput'],
  execute: ['search'],
  finalize: ['finalize'],
} as const satisfies FlowPlan<string>;

declare global {
  interface ExtendFlows {
    'skills:search': FlowRunOptions<
      SearchSkillsFlow,
      typeof plan,
      typeof inputSchema,
      typeof outputSchema,
      typeof stateSchema
    >;
  }
}

const name = 'skills:search' as const;
const { Stage } = FlowHooksOf<'skills:search'>(name);

/**
 * Flow for searching skills.
 *
 * This flow handles skill discovery by searching through both local
 * and external skill providers. Results include relevance scores
 * and tool availability information.
 *
 * @example MCP Request
 * ```json
 * {
 *   "method": "skills/search",
 *   "params": {
 *     "query": "review pull request",
 *     "tags": ["github"],
 *     "limit": 10
 *   }
 * }
 * ```
 */
@Flow({
  name,
  plan,
  inputSchema,
  outputSchema,
  access: 'authorized',
})
export default class SearchSkillsFlow extends FlowBase<typeof name> {
  logger = this.scopeLogger.child('SearchSkillsFlow');

  @Stage('parseInput')
  async parseInput() {
    this.logger.verbose('parseInput:start');

    let params: Input['request']['params'];
    try {
      const inputData = inputSchema.parse(this.rawInput);
      params = inputData.request.params;
    } catch (e) {
      throw new InvalidInputError('Invalid Input', e instanceof z.ZodError ? e.issues : undefined);
    }

    const { query, tags, tools, limit, requireAllTools } = params;

    const options: SkillSearchOptions = {
      tags,
      tools,
      topK: limit,
      requireAllTools,
    };

    this.state.set({ query, options });
    this.logger.verbose('parseInput:done');
  }

  @Stage('search')
  async search() {
    this.logger.verbose('search:start');
    const { query, options } = this.state.required;

    const telemetry = tryGetTelemetry(this);
    const topK = options.topK ?? 10;
    // Privacy: emit ONLY non-identifying shape information about the query.
    // The raw query text is unbounded user/LLM-supplied free text and may
    // contain PII, tenant/customer identifiers, or confidential strings. It
    // must never appear in span attributes, since exporters route attributes
    // to vendor SaaS without redaction.
    telemetry?.addEvent('skill_search.query', {
      'query.length': query.length,
      topK,
      mcp_only: true,
    });

    const skillRegistry = this.scope.skills;

    if (!skillRegistry || !skillRegistry.hasAny()) {
      this.state.set({ results: [] });
      // Same privacy rule applies to result events: emit only counts/booleans,
      // never per-result IDs or scores.
      telemetry?.addEvent('skill_search.results', { count: 0, truncated: false });
      this.logger.verbose('search:no-skills');
      return;
    }

    // Search for skills
    const results = await skillRegistry.search(query, options);
    this.state.set({ results });

    telemetry?.addEvent('skill_search.results', {
      count: results.length,
      truncated: results.length >= topK,
    });

    this.logger.verbose(`search:found ${results.length} skills`);
  }

  @Stage('finalize')
  async finalize() {
    this.logger.verbose('finalize:start');
    const { results, options } = this.state.required;

    // Store pre-filtered count for hasMore calculation
    const preFilteredCount = (results as SkillSearchResult[]).length;

    // Filter by MCP visibility (only 'mcp' or 'both' should be visible via MCP tools)
    const mcpVisibleResults = (results as SkillSearchResult[]).filter((result) => {
      const visibility = result.metadata.visibility ?? 'both';
      return visibility === 'mcp' || visibility === 'both';
    });

    // Hide authority-gated skills the caller can't discover (mirrors
    // `filterByAuthorities` for tools/resources). Evaluated WITHOUT request
    // input — role/permission/claims-based authorities only; `fromInput`
    // policies can't be evaluated at search time.
    const authVisibleResults = await this.filterResultsByAuthorities(mcpVisibleResults);

    // Transform results to output format
    const skills = authVisibleResults.map((result) => ({
      id: result.metadata.id ?? result.metadata.name,
      name: result.metadata.name,
      description: result.metadata.description,
      score: result.score,
      tags: result.metadata.tags,
      tools: (result.metadata.tools ?? []).map((t) => {
        // Use normalizeToolRef to correctly handle all tool reference types
        // including class-based refs where t.name would be the class name
        try {
          const normalized = normalizeToolRef(t);
          return {
            name: normalized.name,
            available: result.availableTools.includes(normalized.name),
          };
        } catch {
          // Fallback for edge cases
          const toolName = typeof t === 'string' ? t : ((t as { name?: string }).name ?? 'unknown');
          return {
            name: toolName,
            available: result.availableTools.includes(toolName),
          };
        }
      }),
      source: result.source,
    }));

    // Pagination info:
    // - total: number of MCP-visible results returned
    // - hasMore: true if pre-filtered results hit the limit (more results may exist)
    // Note: We use preFilteredCount for hasMore because visibility filtering is post-search.
    // We can't know the exact total of matching skills without a full scan,
    // so we report the actual returned count and indicate if limit was reached.
    const limit = options.topK ?? 10;
    const total = skills.length;
    const hasMore = preFilteredCount >= limit;

    const output: Output = {
      skills,
      total,
      hasMore,
    };

    this.state.set({ output });
    this.respond(output);
    this.logger.verbose('finalize:done');
  }

  /**
   * Remove search results the caller is not authorized to discover.
   *
   * Search-result metadata is projected from `SkillContent` and does NOT carry
   * the `authorities` field, so we resolve the live skill entry (by id/name)
   * to read its declared authorities, then evaluate via the scope's engine.
   * No-op when no authorities engine is configured; results unchanged.
   *
   * Evaluated WITHOUT request input — `fromInput`/ReBAC-by-input policies can't
   * be evaluated at search time and will be treated as a denial. Use
   * role/permission/claims-based authorities for discoverable skills.
   */
  private async filterResultsByAuthorities(results: SkillSearchResult[]): Promise<SkillSearchResult[]> {
    const registry = this.scope.skills;
    if (!registry) return results;
    const ctx = (this.rawInput as Record<string, unknown>)['ctx'] as Record<string, unknown> | undefined;
    const authInfo = (ctx?.['authInfo'] ?? {}) as Record<string, unknown>;
    return filterSkillMetadataByAuthorities(this.scope, registry, results, authInfo);
  }
}
