import type { OrganizationGraph } from '../organization-model/graph/types'
import type { OrgKnowledgeKind, OrgKnowledgeNode } from '../organization-model/domains/knowledge'
import { OntologyIdSchema, ontologyGraphNodeId } from '../organization-model/ontology'
import type { OrganizationModel } from '../organization-model/types'
import { listAllSystems } from '../organization-model/helpers'

// ---------------------------------------------------------------------------
// Domain-item constants
// ---------------------------------------------------------------------------

/**
 * The six graph-projected domain-item kinds supported by `omDescribe`.
 * Each maps to a collection on `OrganizationModel` and a graph node kind.
 */
export const DOMAIN_ITEM_KINDS = ['clients', 'roles', 'policies', 'customers', 'offerings', 'goals'] as const
export type DomainItemKind = (typeof DOMAIN_ITEM_KINDS)[number]

/**
 * Bare-id prefix to domain map used for back-compat resolution.
 * e.g. `client:<uuid>` → `clients`
 */
const BARE_PREFIX_TO_DOMAIN: Record<string, DomainItemKind> = {
  client: 'clients',
  role: 'roles',
  policy: 'policies',
  customer: 'customers',
  offering: 'offerings',
  goal: 'goals'
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
 * Graph node ID prefix for knowledge nodes.
 * Graph node IDs follow the convention: `knowledge:<om-node-id>`
 * e.g. `knowledge:knowledge.outreach-playbook`
 */
function toGraphNodeId(omNodeId: string): string {
  return `knowledge:${omNodeId}`
}

function toTargetGraphNodeId(nodeId: string): string {
  if (
    nodeId.startsWith('system:') ||
    nodeId.startsWith('knowledge:') ||
    nodeId.startsWith('resource:') ||
    nodeId.startsWith('ontology:')
  ) {
    return nodeId
  }

  const ontologyId = OntologyIdSchema.safeParse(nodeId)
  if (ontologyId.success) {
    return ontologyGraphNodeId(ontologyId.data)
  }

  return `system:${nodeId}`
}

/**
 * Resolve the sourceId of every knowledge node in the graph, keyed by graph
 * node ID.  The `sourceId` on a knowledge graph node is the OM node id
 * (e.g. `knowledge.outreach-playbook`).
 */
function buildKnowledgeSourceIdMap(graph: OrganizationGraph): Map<string, string> {
  const map = new Map<string, string>()
  for (const node of graph.nodes) {
    if (node.kind === 'knowledge' && node.sourceId) {
      map.set(node.id, node.sourceId)
    }
  }
  return map
}

// ---------------------------------------------------------------------------
// Query functions
// ---------------------------------------------------------------------------

/**
 * Returns all knowledge nodes whose `governs` edges point to the given
 * systemId (graph node ID: `system:<systemId>`).
 *
 * @param graph   - The built OrganizationGraph.
 * @param systemId - The dotted system id (e.g. `sales.crm`).
 */
export function bySystem(
  graph: OrganizationGraph,
  systemId: string,
  knowledgeNodes: OrgKnowledgeNode[]
): OrgKnowledgeNode[] {
  const targetGraphNodeId = `system:${systemId}`

  // Find all knowledge graph node IDs that have a `governs` edge to the target
  const governingKnowledgeNodeIds = new Set<string>()
  for (const edge of graph.edges) {
    if (edge.kind === 'governs' && edge.targetId === targetGraphNodeId && edge.sourceId.startsWith('knowledge:')) {
      governingKnowledgeNodeIds.add(edge.sourceId)
    }
  }

  // Build a lookup from graph-node-id -> OM sourceId
  const sourceIdMap = buildKnowledgeSourceIdMap(graph)

  // Collect the OM node ids that correspond to those graph nodes
  const matchingOmIds = new Set<string>()
  for (const graphNodeId of governingKnowledgeNodeIds) {
    const omId = sourceIdMap.get(graphNodeId)
    if (omId) matchingOmIds.add(omId)
  }

  return knowledgeNodes.filter((n) => matchingOmIds.has(n.id))
}

export function bySystemTree(
  graph: OrganizationGraph,
  systemId: string,
  knowledgeNodes: OrgKnowledgeNode[]
): OrgKnowledgeNode[] {
  const prefix = `${systemId}.`
  const targetGraphNodeIds = new Set(
    graph.nodes
      .filter((node) => node.id === `system:${systemId}` || node.id.startsWith(`system:${prefix}`))
      .map((node) => node.id)
  )

  const governingKnowledgeNodeIds = new Set<string>()
  for (const edge of graph.edges) {
    if (edge.kind === 'governs' && targetGraphNodeIds.has(edge.targetId) && edge.sourceId.startsWith('knowledge:')) {
      governingKnowledgeNodeIds.add(edge.sourceId)
    }
  }

  const sourceIdMap = buildKnowledgeSourceIdMap(graph)
  const matchingOmIds = new Set<string>()
  for (const graphNodeId of governingKnowledgeNodeIds) {
    const omId = sourceIdMap.get(graphNodeId)
    if (omId) matchingOmIds.add(omId)
  }

  return knowledgeNodes.filter((n) => matchingOmIds.has(n.id))
}

export function byOntology(
  graph: OrganizationGraph,
  ontologyId: string,
  knowledgeNodes: OrgKnowledgeNode[]
): OrgKnowledgeNode[] {
  const targetGraphNodeId = toTargetGraphNodeId(ontologyId)

  const governingKnowledgeNodeIds = new Set<string>()
  for (const edge of graph.edges) {
    if (edge.kind === 'governs' && edge.targetId === targetGraphNodeId && edge.sourceId.startsWith('knowledge:')) {
      governingKnowledgeNodeIds.add(edge.sourceId)
    }
  }

  const sourceIdMap = buildKnowledgeSourceIdMap(graph)
  const matchingOmIds = new Set<string>()
  for (const graphNodeId of governingKnowledgeNodeIds) {
    const omId = sourceIdMap.get(graphNodeId)
    if (omId) matchingOmIds.add(omId)
  }

  return knowledgeNodes.filter((n) => matchingOmIds.has(n.id))
}

/**
 * Returns all knowledge nodes whose `kind` matches the given kind.
 *
 * @param graph          - The built OrganizationGraph (unused structurally; kept for API symmetry).
 * @param kind           - The knowledge kind (`'playbook' | 'strategy' | 'reference'`).
 * @param knowledgeNodes - Flat array of OrgKnowledgeNode from the OrganizationModel.
 */
export function byKind(
  _graph: OrganizationGraph,
  kind: OrgKnowledgeKind,
  knowledgeNodes: OrgKnowledgeNode[]
): OrgKnowledgeNode[] {
  return knowledgeNodes.filter((n) => n.kind === kind)
}

/**
 * Returns all knowledge nodes where `ownerIds` includes the given `ownerId`.
 *
 * @param _graph         - The built OrganizationGraph (unused; kept for API symmetry).
 * @param ownerId        - The owner id string (matches values in `node.ownerIds`).
 * @param knowledgeNodes - Flat array of OrgKnowledgeNode from the OrganizationModel.
 */
export function byOwner(
  _graph: OrganizationGraph,
  ownerId: string,
  knowledgeNodes: OrgKnowledgeNode[]
): OrgKnowledgeNode[] {
  return knowledgeNodes.filter((n) => n.ownerIds.includes(ownerId))
}

/**
 * Returns the IDs of the graph nodes that the given knowledge node governs
 * (outgoing `governs` edges from the knowledge graph node).
 *
 * The graph node ID for a knowledge OM node with id `X` is `knowledge:X`.
 *
 * @param graph    - The built OrganizationGraph.
 * @param nodeId   - The OM knowledge node id (e.g. `knowledge.outreach-playbook`).
 *                   Also accepts the graph node ID format (`knowledge:knowledge.outreach-playbook`).
 * @returns Array of target graph node IDs (e.g. `['system:sales.crm', ...]`).
 */
export function governs(graph: OrganizationGraph, nodeId: string): string[] {
  const graphNodeId = nodeId.startsWith('knowledge:') ? nodeId : toGraphNodeId(nodeId)

  const results: string[] = []
  for (const edge of graph.edges) {
    if (edge.kind === 'governs' && edge.sourceId === graphNodeId) {
      results.push(edge.targetId)
    }
  }
  return results
}

/**
 * Returns the IDs of the knowledge graph nodes that govern the given node
 * (incoming `governs` edges pointing to `nodeId`).
 *
 * @param graph   - The built OrganizationGraph.
 * @param nodeId  - The target graph node ID (e.g. `system:sales.crm`) or a
 *                  bare system id (e.g. `sales.crm` â€” will be prefixed with `system:`).
 * @returns Array of source graph node IDs (e.g. `['knowledge:knowledge.outreach-playbook', ...]`).
 */
export function governedBy(graph: OrganizationGraph, nodeId: string): string[] {
  // Accept prefixed graph IDs, bare system IDs, and bare ontology IDs.
  const targetId = toTargetGraphNodeId(nodeId)

  const results: string[] = []
  for (const edge of graph.edges) {
    if (edge.kind === 'governs' && edge.targetId === targetId) {
      results.push(edge.sourceId)
    }
  }
  return results
}

// ---------------------------------------------------------------------------
// Enumeration helpers (Bucket 4: /all-* mounts)
// ---------------------------------------------------------------------------

/**
 * Returns all systems flattened from the recursive OM tree via `listAllSystems`.
 *
 * Each entry is `{ path, system }` — the same shape produced by `listAllSystems`.
 *
 * @param model - The resolved OrganizationModel.
 */
export function listAllSystemsFlat(model: OrganizationModel): ReturnType<typeof listAllSystems> {
  return listAllSystems(model)
}

/**
 * Returns every resource from the model as a flat array, sorted by id.
 *
 * @param model - The resolved OrganizationModel.
 */
export function listAllResources(model: OrganizationModel): NonNullable<OrganizationModel['resources']>[string][] {
  return Object.values(model.resources ?? {}).sort((a, b) => a.id.localeCompare(b.id))
}

/**
 * Returns every role from the model as a flat array, sorted by id.
 *
 * @param model - The resolved OrganizationModel.
 */
export function listAllRoles(model: OrganizationModel): NonNullable<OrganizationModel['roles']>[string][] {
  return Object.values(model.roles ?? {}).sort((a, b) => a.id.localeCompare(b.id))
}

// ---------------------------------------------------------------------------
// Path parser
// ---------------------------------------------------------------------------

/** The recognized mount axes for Knowledge Map paths. */
export type KnowledgeMount =
  | 'by-system'
  | 'by-ontology'
  | 'by-kind'
  | 'by-owner'
  | 'graph'
  | 'node'
  | 'all-systems'
  | 'all-resources'
  | 'all-roles'
  | 'by-domain'
  | 'by-item'

/**
 * The result of parsing a Knowledge Map path string.
 *
 * Shape: `{ mount: KnowledgeMount, args: string[] }`
 *
 * Per-mount arg arrays:
 * - `by-system`:    `[systemId]`      (e.g. `['sales.crm']`)
 * - `by-ontology`:  `[ontologyId]`    (e.g. `['sales.crm:object/deal']`)
 * - `by-kind`:      `[kind]`          (e.g. `['playbook']`)
 * - `by-owner`:     `[ownerId]`       (e.g. `['role.ops-lead']`)
 * - `graph`:        `[nodeId, verb]`  where verb is `'governs'` or `'governed-by'`
 * - `node`:         `[nodeId]`        (single node lookup, no sub-path)
 * - `all-systems`:  `[]`              (no args — returns all systems flattened)
 * - `all-resources`:`[]`              (no args — returns all resources)
 * - `all-roles`:    `[]`              (no args — returns all roles)
 * - `by-domain`:    `[domain]`        (e.g. `['clients']`) — enumerate all items in domain
 * - `by-item`:      `[domain, itemId]` (e.g. `['clients', '<uuid>']`) — single domain-item
 */
export interface ParsedKnowledgePath {
  mount: KnowledgeMount
  args: string[]
}

/**
 * Parses a Knowledge Map path string into a `{ mount, args }` descriptor.
 *
 * Supported path patterns:
 *   `/by-system/<systemId>`           -> `{ mount: ‘by-system’,    args: [‘<systemId>’] }`
 *   `/by-ontology/<ontologyId>`       -> `{ mount: ‘by-ontology’,  args: [‘<ontologyId>’] }`
 *   `/by-kind/<kind>`                 -> `{ mount: ‘by-kind’,      args: [‘<kind>’] }`
 *   `/by-owner/<ownerId>`             -> `{ mount: ‘by-owner’,     args: [‘<ownerId>’] }`
 *   `/graph/<nodeId>/governs`         -> `{ mount: ‘graph’,        args: [‘<nodeId>’, ‘governs’] }`
 *   `/graph/<nodeId>/governed-by`     -> `{ mount: ‘graph’,        args: [‘<nodeId>’, ‘governed-by’] }`
 *   `/<nodeId>`                       -> `{ mount: ‘node’,         args: [‘<nodeId>’] }`
 *   `/all-systems`                    -> `{ mount: ‘all-systems’,  args: [] }`
 *   `/all-resources`                  -> `{ mount: ‘all-resources’,args: [] }`
 *   `/all-roles`                      -> `{ mount: ‘all-roles’,    args: [] }`
 *   `/by-domain/<domain>`             -> `{ mount: ‘by-domain’,    args: [‘<domain>’] }`
 *   `/by-item/<domain>/<itemId>`      -> `{ mount: ‘by-item’,      args: [‘<domain>’, ‘<itemId>’] }`
 *
 * The path MUST start with `/`.  Trailing slashes are stripped before parsing.
 *
 * @throws {Error} If the path is empty, missing a leading slash, or does not
 *   match any supported mount pattern.
 */
export function parsePath(pathString: string): ParsedKnowledgePath {
  if (!pathString || typeof pathString !== 'string') {
    throw new Error('parsePath: path must be a non-empty string')
  }

  if (!pathString.startsWith('/')) {
    throw new Error(`parsePath: path must start with "/", got: "${pathString}"`)
  }

  // Strip trailing slashes then split
  const normalized = pathString.replace(/\/+$/, '')
  const segments = normalized.split('/').filter((s) => s.length > 0)

  if (segments.length === 0) {
    throw new Error(`parsePath: path resolves to root with no mount: "${pathString}"`)
  }

  const [first, ...rest] = segments

  // /by-system/<systemId>
  if (first === 'by-system') {
    if (rest.length === 0) {
      throw new Error(`parsePath: /by-system requires a systemId argument, got: "${pathString}"`)
    }
    return { mount: 'by-system', args: [rest.join('/')] }
  }

  // /by-ontology/<ontologyId>
  if (first === 'by-ontology') {
    if (rest.length === 0) {
      throw new Error(`parsePath: /by-ontology requires an ontologyId argument, got: "${pathString}"`)
    }
    return { mount: 'by-ontology', args: [rest.join('/')] }
  }

  // /by-kind/<kind>
  if (first === 'by-kind') {
    if (rest.length === 0) {
      throw new Error(`parsePath: /by-kind requires a kind argument, got: "${pathString}"`)
    }
    return { mount: 'by-kind', args: [rest[0]] }
  }

  // /by-owner/<ownerId>
  if (first === 'by-owner') {
    if (rest.length === 0) {
      throw new Error(`parsePath: /by-owner requires an ownerId argument, got: "${pathString}"`)
    }
    return { mount: 'by-owner', args: [rest.join('/')] }
  }

  // /graph/<nodeId>/governs  or  /graph/<nodeId>/governed-by
  if (first === 'graph') {
    if (rest.length < 2) {
      throw new Error(`parsePath: /graph requires <nodeId>/<verb> (governs|governed-by), got: "${pathString}"`)
    }
    const graphNodeId = rest.slice(0, -1).join('/')
    const verb = rest[rest.length - 1]
    if (verb !== 'governs' && verb !== 'governed-by') {
      throw new Error(
        `parsePath: /graph/<nodeId> verb must be "governs" or "governed-by", got: "${verb}" in "${pathString}"`
      )
    }
    return { mount: 'graph', args: [graphNodeId, verb] }
  }

  // /all-systems  /all-resources  /all-roles  (no-arg enumeration mounts)
  if (first === 'all-systems' && rest.length === 0) {
    return { mount: 'all-systems', args: [] }
  }
  if (first === 'all-resources' && rest.length === 0) {
    return { mount: 'all-resources', args: [] }
  }
  if (first === 'all-roles' && rest.length === 0) {
    return { mount: 'all-roles', args: [] }
  }

  // /by-domain/<domain>  — enumerate all items in a domain
  if (first === 'by-domain') {
    if (rest.length === 0) {
      throw new Error(`parsePath: /by-domain requires a domain argument, got: "${pathString}"`)
    }
    const domain = rest[0]
    if (!(DOMAIN_ITEM_KINDS as readonly string[]).includes(domain)) {
      throw new Error(`parsePath: /by-domain unknown domain "${domain}". Supported: ${DOMAIN_ITEM_KINDS.join(', ')}`)
    }
    return { mount: 'by-domain', args: [domain] }
  }

  // /by-item/<domain>/<itemId>  — single domain-item resolution
  if (first === 'by-item') {
    if (rest.length < 2) {
      throw new Error(`parsePath: /by-item requires <domain>/<itemId>, got: "${pathString}"`)
    }
    const domain = rest[0]
    if (!(DOMAIN_ITEM_KINDS as readonly string[]).includes(domain)) {
      throw new Error(`parsePath: /by-item unknown domain "${domain}". Supported: ${DOMAIN_ITEM_KINDS.join(', ')}`)
    }
    const itemId = rest.slice(1).join('/')
    return { mount: 'by-item', args: [domain, itemId] }
  }

  // /<nodeId>  (single node)
  // first must not be a recognized mount prefix
  if (segments.length === 1) {
    return { mount: 'node', args: [first] }
  }

  throw new Error(
    `parsePath: unrecognized path pattern "${pathString}". Supported: /by-system/<id>, /by-kind/<kind>, /by-owner/<id>, /by-domain/<domain>, /by-item/<domain>/<itemId>, /graph/<nodeId>/governs, /graph/<nodeId>/governed-by, /<nodeId>, /all-systems, /all-resources, /all-roles`
  )
}

// ---------------------------------------------------------------------------
// omSearch — universal keyword search across all OM surfaces
// ---------------------------------------------------------------------------

/**
 * The kind of OM node that produced a search hit.
 *
 * Maps to the surface being searched:
 * - `system`    — recursive systems tree (model.systems)
 * - `resource`  — model.resources (workflows, agents, integrations, scripts)
 * - `knowledge` — model.knowledge (playbooks, strategies, references)
 * - `ontology`  — system.ontology and model.ontology (objects, actions, events, catalogs, links, ...)
 * - `role`      — model.roles
 * - `policy`    — model.policies
 */
export type OmSearchHitKind = 'system' | 'resource' | 'knowledge' | 'ontology' | 'role' | 'policy'

export interface OmSearchHit {
  /** Surface that produced the hit. */
  kind: OmSearchHitKind
  /** Canonical id (system path, resource id, knowledge id, ontology id, role id, policy id). */
  id: string
  /** Display title (label/title/summary fallback). */
  title: string
  /** One-line description or summary (may be empty). */
  summary: string
  /** Ranking score; higher is better. */
  score: number
  /** Optional sub-kind for ontology / resource (`object`, `action`, `event`, `workflow`, ...). */
  subKind?: string
}

export interface OmSearchOptions {
  /** Max number of hits to return. Defaults to 10. Pass 0 for unlimited. */
  limit?: number
  /** Restrict to specific OM surfaces. Defaults to all kinds. */
  kinds?: OmSearchHitKind[]
}

/**
 * Universal keyword search across the entire Organization Model.
 *
 * Iterates over every surface (knowledge, systems, resources, ontology, roles, policies),
 * scores each entry by term overlap, and returns the top hits ranked by score.
 *
 * Scoring (per query term, summed across terms):
 * - title/label match: ×8
 * - summary/description match: ×4
 * - tags/responsibilities/meta match: ×3
 * - body match: ×1
 * Bonuses: exact id match (+50 for single-term queries), all terms appear in id (+5).
 *
 * Case-insensitive. Empty query returns no hits.
 */
export function omSearch(model: OrganizationModel, query: string, options: OmSearchOptions = {}): OmSearchHit[] {
  const limit = options.limit ?? 10
  const kinds = options.kinds ? new Set(options.kinds) : null

  const terms = tokenize(query)
  if (terms.length === 0) return []

  const entries = collectSearchable(model, kinds)

  const scored: OmSearchHit[] = []
  for (const entry of entries) {
    const score = scoreEntry(entry, terms)
    if (score > 0) {
      scored.push({
        kind: entry.kind,
        id: entry.id,
        title: entry.title,
        summary: entry.summary,
        score,
        subKind: entry.subKind
      })
    }
  }

  scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))

  return limit > 0 ? scored.slice(0, limit) : scored
}

// ---------------------------------------------------------------------------
// omSearch internals
// ---------------------------------------------------------------------------

interface SearchableEntry {
  kind: OmSearchHitKind
  id: string
  title: string
  summary: string
  subKind?: string
  /** Title-tier fields. Weight ×8 per term match. */
  high: string[]
  /** Description-tier fields. Weight ×4 per term match. */
  mid: string[]
  /** Tag/meta-tier fields (kind, systemPath, responsibilities, owner). Weight ×3 per term match. */
  meta: string[]
  /** Body-tier fields (MDX body, etc). Weight ×1 per term match. */
  low: string[]
}

function tokenize(query: string): string[] {
  return query
    .toLowerCase()
    .split(/[\s,/]+/)
    .map((t) => t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''))
    .filter((t) => t.length >= 2)
}

/**
 * Split a title/id into searchable tokens, breaking on whitespace AND structural
 * separators (`.` `-` `_` `:`). Used for exact-name-match boosts.
 */
function structuralTokens(text: string): Set<string> {
  return new Set(
    text
      .toLowerCase()
      .split(/[\s.\-_:/,;]+/)
      .map((t) => t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''))
      .filter((t) => t.length >= 2)
  )
}

function setsEqual(a: Set<string>, b: Set<string>): boolean {
  if (a.size !== b.size) return false
  for (const item of a) if (!b.has(item)) return false
  return true
}

function countOccurrences(haystack: string, needle: string): number {
  if (!haystack || !needle) return 0
  const lower = haystack.toLowerCase()
  let count = 0
  let idx = 0
  while ((idx = lower.indexOf(needle, idx)) !== -1) {
    count++
    idx += needle.length
  }
  return count
}

/**
 * Per-kind base boost applied when an entry scores at all. Reflects how
 * "primary" each surface is in the OM: structural identifiers (systems,
 * resources, roles) outrank descriptive content (knowledge bodies) on close
 * ties. Knowledge nodes still dominate when nothing else matches.
 */
const KIND_BOOST: Record<OmSearchHitKind, number> = {
  system: 5,
  resource: 3,
  role: 3,
  policy: 3,
  ontology: 1,
  knowledge: 0
}

function scoreEntry(entry: SearchableEntry, terms: string[]): number {
  let total = 0
  const idLower = entry.id.toLowerCase()

  for (const term of terms) {
    for (const text of entry.high) total += 8 * countOccurrences(text, term)
    for (const text of entry.mid) total += 4 * countOccurrences(text, term)
    for (const text of entry.meta) total += 3 * countOccurrences(text, term)
    for (const text of entry.low) total += 1 * countOccurrences(text, term)
  }

  if (total === 0) return 0

  const queryTokenSet = new Set(terms)

  // Exact-id boost (single-term query): massive jump so /knowledge.outreach-playbook lands first.
  if (terms.length === 1 && idLower === terms[0]) total += 50

  // Exact-title-match boost: the query tokens exactly match the entity's title tokens.
  // This precisely rewards "the user typed the entity's name" (e.g. "lead gen" -> "Lead Gen" system).
  const titleTokens = structuralTokens(entry.title)
  if (setsEqual(queryTokenSet, titleTokens)) total += 25

  // Exact-last-id-segment match: query tokens equal the last dotted id segment's tokens.
  // Helps "lead gen" match `sales.lead-gen` (last segment "lead-gen" -> {lead, gen}).
  const lastSegment = idLower.split('.').pop() ?? idLower
  if (setsEqual(queryTokenSet, structuralTokens(lastSegment))) total += 20

  // All-terms-in-id boost: helpful when no exact match but id contains every term as substring.
  if (terms.every((t) => idLower.includes(t))) total += 5

  // Kind-aware structural preference.
  total += KIND_BOOST[entry.kind]

  return total
}

function collectSearchable(model: OrganizationModel, kinds: Set<OmSearchHitKind> | null): SearchableEntry[] {
  const entries: SearchableEntry[] = []

  const include = (kind: OmSearchHitKind): boolean => kinds === null || kinds.has(kind)

  // Knowledge nodes
  if (include('knowledge')) {
    for (const node of Object.values(model.knowledge ?? {})) {
      entries.push({
        kind: 'knowledge',
        id: node.id,
        title: node.title,
        summary: node.summary,
        subKind: node.kind,
        high: [node.id, node.title],
        mid: [node.summary],
        meta: [node.kind, ...(node.ownerIds ?? [])],
        low: [node.body]
      })
    }
  }

  // Systems (recursive tree)
  if (include('system')) {
    for (const { path, system } of listAllSystems(model)) {
      const title = system.label ?? system.title ?? path
      const description = system.description ?? ''
      entries.push({
        kind: 'system',
        id: path,
        title,
        summary: description,
        subKind: system.kind,
        high: [path, title],
        mid: [description],
        meta: [system.kind ?? '', system.lifecycle ?? '', system.responsibleRoleId ?? ''],
        low: []
      })

      // Per-system ontology entries
      if (include('ontology') && system.ontology) {
        entries.push(...collectOntologyEntries(system.ontology))
      }
    }
  } else if (include('ontology')) {
    // If systems are excluded but ontology isn't, still walk systems to find ontology
    for (const { system } of listAllSystems(model)) {
      if (system.ontology) entries.push(...collectOntologyEntries(system.ontology))
    }
  }

  // Top-level ontology (global scope)
  if (include('ontology') && model.ontology) {
    entries.push(...collectOntologyEntries(model.ontology))
  }

  // Resources
  if (include('resource')) {
    for (const resource of Object.values(model.resources ?? {})) {
      const title = resource.title ?? resource.id
      const description = resource.description ?? ''
      entries.push({
        kind: 'resource',
        id: resource.id,
        title,
        summary: description,
        subKind: resource.kind,
        high: [resource.id, title],
        mid: [description],
        meta: [resource.kind, resource.systemPath, resource.status, resource.ownerRoleId ?? ''],
        low: []
      })
    }
  }

  // Roles
  if (include('role')) {
    for (const role of Object.values(model.roles ?? {})) {
      const responsibilities = role.responsibilities ?? []
      entries.push({
        kind: 'role',
        id: role.id,
        title: role.title,
        summary: responsibilities[0] ?? '',
        high: [role.id, role.title],
        mid: [],
        meta: responsibilities,
        low: []
      })
    }
  }

  // Policies
  if (include('policy')) {
    for (const policy of Object.values(model.policies ?? {})) {
      const description = policy.description ?? ''
      entries.push({
        kind: 'policy',
        id: policy.id,
        title: policy.label,
        summary: description,
        subKind: policy.trigger?.kind,
        high: [policy.id, policy.label],
        mid: [description],
        meta: [policy.trigger?.kind ?? ''],
        low: []
      })
    }
  }

  return entries
}

interface OntologyRecordLike {
  id?: string
  label?: string
  description?: string
}

function collectOntologyEntries(scope: {
  objectTypes?: Record<string, OntologyRecordLike>
  linkTypes?: Record<string, OntologyRecordLike>
  actionTypes?: Record<string, OntologyRecordLike>
  catalogTypes?: Record<string, OntologyRecordLike>
  eventTypes?: Record<string, OntologyRecordLike>
  interfaceTypes?: Record<string, OntologyRecordLike>
  valueTypes?: Record<string, OntologyRecordLike>
  sharedProperties?: Record<string, OntologyRecordLike>
  groups?: Record<string, OntologyRecordLike>
  endpoints?: Record<string, OntologyRecordLike>
}): SearchableEntry[] {
  const entries: SearchableEntry[] = []

  const buckets: Array<[string, Record<string, OntologyRecordLike> | undefined]> = [
    ['object', scope.objectTypes],
    ['link', scope.linkTypes],
    ['action', scope.actionTypes],
    ['catalog', scope.catalogTypes],
    ['event', scope.eventTypes],
    ['interface', scope.interfaceTypes],
    ['value-type', scope.valueTypes],
    ['property', scope.sharedProperties],
    ['group', scope.groups],
    ['endpoint', scope.endpoints]
  ]

  for (const [subKind, records] of buckets) {
    if (!records) continue
    for (const [recordId, record] of Object.entries(records)) {
      const id = record.id ?? recordId
      const title = record.label ?? id
      const description = record.description ?? ''
      entries.push({
        kind: 'ontology',
        id,
        title,
        summary: description,
        subKind,
        high: [id, title],
        mid: [description],
        meta: [subKind],
        low: []
      })
    }
  }

  return entries
}

// ---------------------------------------------------------------------------
// omDescribe — neighborhood view for any OM node ID
// ---------------------------------------------------------------------------

/**
 * Location context for a domain-item node in the organization graph.
 */
export interface OmDescribeDomainItemLocation {
  /** Human-readable label for the domain (e.g. "Clients"). */
  domainLabel: string
  /** Graph node id for this item (e.g. `client:<uuid>`). */
  graphNodeId: string
  /** Parent graph node ids reachable via `contains` edges (e.g. `['organization-model']`). */
  containsParentIds: string[]
}

/**
 * A single governing knowledge node reference returned by domain-item describe.
 */
export interface OmDescribeDomainItemKnowledgeRef {
  id: string
  title: string
  knowledgeKind: string
}

/**
 * Structured neighborhood view of a domain-item node.
 *
 * Returned by `omDescribe` for `item:<domain>:<id>` and bare-id forms
 * (e.g. `client:<uuid>`). Carries the full domain record payload, graph
 * location, and any governing knowledge nodes.
 */
export interface OmDescribeDomainItem {
  kind: 'domain-item'
  /** The `item:<domain>:<id>` canonical reference string. */
  id: string
  /** The domain collection this item belongs to (e.g. `'clients'`). */
  domain: DomainItemKind
  /** Human-readable name or label for this item. */
  name: string
  /** Graph node location: graph id, domain label, and parentage. */
  location: OmDescribeDomainItemLocation
  /**
   * Full domain record payload. Shape varies by domain:
   * - `clients`: full `ClientProfile` (identity, branding, links, prompts, config, etc.)
   * - `roles`: id, title, responsibilities, responsibleFor, reportsToId
   * - `policies`: id, label, description, trigger, appliesTo, actions
   * - `customers`: id, name, description, jobsToBeDone, pains, gains, firmographics
   * - `offerings`: id, name, description, pricingModel, price, targetSegmentIds
   * - `goals`: id, description, periodStart, periodEnd, keyResults
   */
  record: Record<string, unknown>
  /** Knowledge nodes that govern this domain item via `governs` edges. */
  governingKnowledge: OmDescribeDomainItemKnowledgeRef[]
}

/**
 * Structured neighborhood view of a single OM node.
 *
 * The `kind` discriminator selects the shape: `system` carries parent/children
 * and resource summaries; `knowledge` carries title/summary/body and
 * `governs` edges; etc. Use `formatOmDescribe()` to render as text or pass
 * directly to `JSON.stringify` for machine-readable output.
 */
export type OmDescribeResult =
  | OmDescribeSystem
  | OmDescribeResource
  | OmDescribeKnowledge
  | OmDescribeOntology
  | OmDescribeRole
  | OmDescribePolicy
  | OmDescribeDomainItem

export interface OmDescribeSystem {
  kind: 'system'
  id: string
  label: string
  description: string
  systemKind?: string
  lifecycle?: string
  parentSystemId?: string
  responsibleRoleId?: string
  childSystemPaths: string[]
  governingKnowledgeIds: string[]
  resourceCountsByKind: Record<string, number>
  resourceIdsByKind: Record<string, string[]>
  ontologyCountsByKind: Record<string, number>
}

export interface OmDescribeResource {
  kind: 'resource'
  id: string
  resourceKind: string
  systemPath: string
  title: string
  description: string
  status: string
  ownerRoleId?: string
  ontology?: {
    primaryAction?: string
    actions?: string[]
    reads?: string[]
    writes?: string[]
    emits?: string[]
    usesCatalogs?: string[]
  }
  codeRefPaths: string[]
}

export interface OmDescribeKnowledge {
  kind: 'knowledge'
  id: string
  knowledgeKind: string
  title: string
  summary: string
  bodyExcerpt: string
  bodyLineCount: number
  ownerIds: string[]
  governs: string[]
  updatedAt: string
}

export interface OmDescribeOntology {
  kind: 'ontology'
  id: string
  ontologyKind: string
  scope: string
  localId: string
  label: string
  description: string
  governingKnowledgeIds: string[]
}

export interface OmDescribeRole {
  kind: 'role'
  id: string
  title: string
  responsibilities: string[]
  responsibleFor: string[]
  reportsToId?: string
}

export interface OmDescribePolicy {
  kind: 'policy'
  id: string
  label: string
  description: string
  triggerKind: string
  appliesToSystemIds: string[]
  appliesToActionIds: string[]
  appliesToResourceIds: string[]
  appliesToRoleIds: string[]
  effectKinds: string[]
}

/**
 * Detect the OM surface this ID belongs to. Returns `undefined` when the
 * shape doesn't match any known pattern.
 *
 * Detection rules (highest priority first):
 * - Contains `:<ontology-kind>/<local-id>` → ontology
 * - Starts with `knowledge.` → knowledge
 * - Starts with `role.` (dotted) → role (OM role, NOT domain-item bare form)
 * - Starts with `policy.` (dotted) → policy (OM policy, NOT domain-item bare form)
 * - Starts with `item:<domain>:` → domain-item (canonical form)
 * - Starts with a bare domain prefix followed by `:` → domain-item (back-compat)
 * - Resolves via `getSystem()` → system
 * - Matches a key in `model.resources` → resource
 */
function detectKind(model: OrganizationModel, id: string): OmSearchHitKind | 'domain-item' | undefined {
  if (/:(object|action|event|catalog|link|interface|value-type|property|group|endpoint)\//.test(id)) {
    return 'ontology'
  }
  if (id.startsWith('knowledge.')) return 'knowledge'
  // Dotted role/policy forms (e.g. role.ops-lead, policy.approve-large-deals) — OM search kinds.
  // These must be checked BEFORE the bare colon forms so 'role.' does not collide with 'role:'.
  if (id.startsWith('role.')) return 'role'
  if (id.startsWith('policy.')) return 'policy'
  // Canonical domain-item form: item:<domain>:<id>
  if (id.startsWith('item:')) {
    const afterItem = id.slice('item:'.length)
    const colonIdx = afterItem.indexOf(':')
    if (colonIdx > 0) {
      const domain = afterItem.slice(0, colonIdx)
      if ((DOMAIN_ITEM_KINDS as readonly string[]).includes(domain)) return 'domain-item'
    }
    return undefined
  }
  // Bare domain-item prefix forms: client:<id>, role:<id>, policy:<id>,
  // customer:<id>, offering:<id>, goal:<id>.
  // These use a colon separator (NOT a dot), so 'role:' is distinct from 'role.'.
  const firstColon = id.indexOf(':')
  if (firstColon > 0) {
    const prefix = id.slice(0, firstColon)
    if (prefix in BARE_PREFIX_TO_DOMAIN) return 'domain-item'
  }
  if (model.systems && getSystemByPath(model.systems, id)) return 'system'
  if (model.resources?.[id]) return 'resource'
  return undefined
}

function getSystemByPath(
  systems: Record<string, { systems?: Record<string, unknown>; subsystems?: Record<string, unknown> }>,
  path: string
): unknown {
  const segments = path.split('.')
  let current: Record<string, unknown> = systems
  for (const seg of segments) {
    const node = current[seg]
    if (node === undefined || typeof node !== 'object' || node === null) return undefined
    const next = node as { systems?: Record<string, unknown>; subsystems?: Record<string, unknown> }
    if (seg === segments[segments.length - 1]) return next
    current = (next.systems ?? next.subsystems ?? {}) as Record<string, unknown>
  }
  return undefined
}

/**
 * Build a structured neighborhood view of any OM node.
 *
 * Returns `undefined` when no entity matches the given id. The result is a
 * JSON-safe discriminated union — pass to `formatOmDescribe()` for human
 * output or `JSON.stringify` for machine consumption.
 *
 * @example
 * omDescribe(model, 'sales.lead-gen')
 *   // -> { kind: 'system', id: 'sales.lead-gen', resourceCountsByKind: {...}, ... }
 */
export function omDescribe(model: OrganizationModel, id: string): OmDescribeResult | undefined {
  const kind = detectKind(model, id)
  if (kind === undefined) return undefined

  switch (kind) {
    case 'system':
      return describeSystem(model, id)
    case 'resource':
      return describeResource(model, id)
    case 'knowledge':
      return describeKnowledge(model, id)
    case 'ontology':
      return describeOntology(model, id)
    case 'role':
      return describeRole(model, id)
    case 'policy':
      return describePolicy(model, id)
    case 'domain-item':
      return describeDomainItem(model, id)
  }
}

function describeSystem(model: OrganizationModel, path: string): OmDescribeSystem | undefined {
  const allSystems = listAllSystems(model)
  const match = allSystems.find((s) => s.path === path)
  if (!match) return undefined
  const { system } = match

  const prefix = path + '.'
  const childSystemPaths = allSystems
    .filter((s) => s.path.startsWith(prefix) && !s.path.slice(prefix.length).includes('.'))
    .map((s) => s.path)

  const resources = Object.values(model.resources ?? {}).filter((r) => r.systemPath === path)
  const resourceCountsByKind: Record<string, number> = {}
  const resourceIdsByKind: Record<string, string[]> = {}
  for (const r of resources) {
    resourceCountsByKind[r.kind] = (resourceCountsByKind[r.kind] ?? 0) + 1
    if (!resourceIdsByKind[r.kind]) resourceIdsByKind[r.kind] = []
    resourceIdsByKind[r.kind].push(r.id)
  }

  const governingKnowledgeIds: string[] = []
  for (const node of Object.values(model.knowledge ?? {})) {
    if ((node.links ?? []).some((link) => link.nodeId === `system:${path}`)) {
      governingKnowledgeIds.push(node.id)
    }
  }

  const ontologyCountsByKind: Record<string, number> = {}
  const ontology = system.ontology
  if (ontology) {
    const buckets: Array<[string, Record<string, unknown> | undefined]> = [
      ['object', ontology.objectTypes],
      ['action', ontology.actionTypes],
      ['event', ontology.eventTypes],
      ['catalog', ontology.catalogTypes],
      ['link', ontology.linkTypes],
      ['interface', ontology.interfaceTypes],
      ['value-type', ontology.valueTypes],
      ['property', ontology.sharedProperties],
      ['group', ontology.groups],
      ['endpoint', ontology.endpoints]
    ]
    for (const [k, records] of buckets) {
      const count = records ? Object.keys(records).length : 0
      if (count > 0) ontologyCountsByKind[k] = count
    }
  }

  return {
    kind: 'system',
    id: path,
    label: system.label ?? system.title ?? path,
    description: system.description ?? '',
    systemKind: system.kind,
    lifecycle: system.lifecycle,
    parentSystemId: system.parentSystemId,
    responsibleRoleId: system.responsibleRoleId,
    childSystemPaths,
    governingKnowledgeIds,
    resourceCountsByKind,
    resourceIdsByKind,
    ontologyCountsByKind
  }
}

function describeResource(model: OrganizationModel, id: string): OmDescribeResource | undefined {
  const r = model.resources?.[id]
  if (!r) return undefined
  return {
    kind: 'resource',
    id: r.id,
    resourceKind: r.kind,
    systemPath: r.systemPath,
    title: r.title ?? r.id,
    description: r.description ?? '',
    status: r.status,
    ownerRoleId: r.ownerRoleId,
    ontology: r.ontology
      ? {
          primaryAction: r.ontology.primaryAction,
          actions: r.ontology.actions,
          reads: r.ontology.reads,
          writes: r.ontology.writes,
          emits: r.ontology.emits,
          usesCatalogs: r.ontology.usesCatalogs
        }
      : undefined,
    codeRefPaths: (r.codeRefs ?? []).map((c) => c.path)
  }
}

function describeKnowledge(model: OrganizationModel, id: string): OmDescribeKnowledge | undefined {
  const node = model.knowledge?.[id]
  if (!node) return undefined
  const bodyLines = node.body.split('\n')
  const bodyExcerpt = bodyLines.slice(0, 6).join('\n') + (bodyLines.length > 6 ? '\n...' : '')
  return {
    kind: 'knowledge',
    id: node.id,
    knowledgeKind: node.kind,
    title: node.title,
    summary: node.summary,
    bodyExcerpt,
    bodyLineCount: bodyLines.length,
    ownerIds: node.ownerIds ?? [],
    governs: (node.links ?? []).map((link) => link.nodeId),
    updatedAt: node.updatedAt
  }
}

function describeOntology(model: OrganizationModel, id: string): OmDescribeOntology | undefined {
  const parsed = id.match(/^([^:]+):([a-z-]+)\/(.+)$/)
  if (!parsed) return undefined
  const [, scope, ontologyKind, localId] = parsed

  // Walk all systems + top-level ontology to find the record.
  const buckets: Array<[string, string]> = [
    ['object', 'objectTypes'],
    ['action', 'actionTypes'],
    ['event', 'eventTypes'],
    ['catalog', 'catalogTypes'],
    ['link', 'linkTypes'],
    ['interface', 'interfaceTypes'],
    ['value-type', 'valueTypes'],
    ['property', 'sharedProperties'],
    ['group', 'groups'],
    ['endpoint', 'endpoints']
  ]
  const bucketKey = buckets.find((b) => b[0] === ontologyKind)?.[1]
  if (!bucketKey) return undefined

  type OntologyRecord = { id?: string; label?: string; description?: string }
  type OntologyScopeShape = Record<string, Record<string, OntologyRecord> | undefined>

  function findIn(scopeObj: OntologyScopeShape | undefined): OntologyRecord | undefined {
    if (!scopeObj) return undefined
    const records = scopeObj[bucketKey!]
    return records?.[id]
  }

  let record: OntologyRecord | undefined
  // Search per-system scopes first, then global
  for (const { system } of listAllSystems(model)) {
    record = findIn(system.ontology as OntologyScopeShape | undefined)
    if (record) break
  }
  if (!record) record = findIn(model.ontology as OntologyScopeShape | undefined)
  if (!record) return undefined

  const governingKnowledgeIds: string[] = []
  for (const node of Object.values(model.knowledge ?? {})) {
    if ((node.links ?? []).some((link) => link.nodeId === `ontology:${id}`)) {
      governingKnowledgeIds.push(node.id)
    }
  }

  return {
    kind: 'ontology',
    id,
    ontologyKind,
    scope,
    localId,
    label: record.label ?? id,
    description: record.description ?? '',
    governingKnowledgeIds
  }
}

function describeRole(model: OrganizationModel, id: string): OmDescribeRole | undefined {
  const role = model.roles?.[id]
  if (!role) return undefined
  return {
    kind: 'role',
    id: role.id,
    title: role.title,
    responsibilities: role.responsibilities ?? [],
    responsibleFor: role.responsibleFor ?? [],
    reportsToId: role.reportsToId
  }
}

function describePolicy(model: OrganizationModel, id: string): OmDescribePolicy | undefined {
  const policy = model.policies?.[id]
  if (!policy) return undefined
  return {
    kind: 'policy',
    id: policy.id,
    label: policy.label,
    description: policy.description ?? '',
    triggerKind: policy.trigger?.kind ?? 'unknown',
    appliesToSystemIds: policy.appliesTo?.systemIds ?? [],
    appliesToActionIds: policy.appliesTo?.actionIds ?? [],
    appliesToResourceIds: policy.appliesTo?.resourceIds ?? [],
    appliesToRoleIds: policy.appliesTo?.roleIds ?? [],
    effectKinds: (policy.actions ?? []).map((a) => a.kind)
  }
}

// ---------------------------------------------------------------------------
// Domain-item describe helpers
// ---------------------------------------------------------------------------

/**
 * Human-readable labels for each domain kind.
 */
const DOMAIN_LABEL: Record<DomainItemKind, string> = {
  clients: 'Clients',
  roles: 'Roles',
  policies: 'Policies',
  customers: 'Customers',
  offerings: 'Offerings',
  goals: 'Goals'
}

/**
 * Graph node id prefix for each domain kind. Mirrors the convention used by
 * `buildOrganizationGraph` in `organization-model/graph/build.ts`:
 *   client:<id>, role:<id>, policy:<id>, customer-segment:<id>, offering:<id>, goal:<id>
 */
const DOMAIN_GRAPH_PREFIX: Record<DomainItemKind, string> = {
  clients: 'client',
  roles: 'role',
  policies: 'policy',
  customers: 'customer-segment',
  offerings: 'offering',
  goals: 'goal'
}

/**
 * Parse a domain-item ref (canonical `item:<domain>:<id>` or bare `<prefix>:<id>`)
 * into `{ domain, itemId }`. Returns `undefined` when the ref cannot be parsed.
 */
function parseDomainItemRef(id: string): { domain: DomainItemKind; itemId: string } | undefined {
  // Canonical: item:<domain>:<id>
  if (id.startsWith('item:')) {
    const afterItem = id.slice('item:'.length)
    const colonIdx = afterItem.indexOf(':')
    if (colonIdx <= 0) return undefined
    const domain = afterItem.slice(0, colonIdx) as DomainItemKind
    const itemId = afterItem.slice(colonIdx + 1)
    if (!(DOMAIN_ITEM_KINDS as readonly string[]).includes(domain) || !itemId) return undefined
    return { domain, itemId }
  }
  // Bare prefix: client:<id>, role:<id>, etc.
  const firstColon = id.indexOf(':')
  if (firstColon <= 0) return undefined
  const prefix = id.slice(0, firstColon)
  const domain = BARE_PREFIX_TO_DOMAIN[prefix]
  if (!domain) return undefined
  const itemId = id.slice(firstColon + 1)
  if (!itemId) return undefined
  return { domain, itemId }
}

/**
 * Build a structured neighborhood view of a domain-item node.
 *
 * Accepts canonical `item:<domain>:<id>` or bare back-compat forms
 * (`client:<uuid>`, `role:<id>`, etc.).
 */
function describeDomainItem(model: OrganizationModel, id: string): OmDescribeDomainItem | undefined {
  const parsed = parseDomainItemRef(id)
  if (!parsed) return undefined

  const { domain, itemId } = parsed

  // Look up the record in the appropriate domain collection.
  const collection = model[domain] as Record<string, Record<string, unknown>> | undefined
  const record = collection?.[itemId]
  if (!record) return undefined

  // Derive the item's name. Each domain uses a different field for the display name.
  const name =
    domain === 'clients'
      ? String((record['name'] as string | undefined) ?? itemId)
      : domain === 'roles'
        ? String((record['title'] as string | undefined) ?? itemId)
        : domain === 'policies'
          ? String((record['label'] as string | undefined) ?? itemId)
          : domain === 'goals'
            ? String((record['description'] as string | undefined) ?? itemId)
            : String((record['name'] as string | undefined) ?? itemId)

  // Graph node id follows the build.ts convention.
  const graphNodeId = `${DOMAIN_GRAPH_PREFIX[domain]}:${itemId}`

  // Contains parentage — all domain items have a single `contains` edge from
  // `organization-model`. We derive this from the known convention without
  // building the full graph (avoids heavy dependency).
  const containsParentIds = ['organization-model']

  // Governing knowledge: knowledge nodes whose links target this item's graph node.
  const governingKnowledge: OmDescribeDomainItemKnowledgeRef[] = []
  for (const node of Object.values(model.knowledge ?? {})) {
    const governs = (node.links ?? []).some((link) => link.nodeId === graphNodeId)
    if (governs) {
      governingKnowledge.push({
        id: node.id,
        title: node.title,
        knowledgeKind: node.kind
      })
    }
  }

  // Canonical id is always the `item:<domain>:<id>` form.
  const canonicalId = `item:${domain}:${itemId}`

  return {
    kind: 'domain-item',
    id: canonicalId,
    domain,
    name,
    location: {
      domainLabel: DOMAIN_LABEL[domain],
      graphNodeId,
      containsParentIds
    },
    record: record as Record<string, unknown>,
    governingKnowledge
  }
}
