/**
 * AST helpers for the client slot surface: the `SlotMap` declaration merges
 * that type every slot, and the `slots.register` call sites that say who
 * already occupies one. Both readings are lexical (no type-checker program):
 * the client catalog generator consumes them, and the same scan doubles as its
 * own exhaustiveness backstop because it reads every source file rather than a
 * reachable-export closure.
 */

import { globSync, readFileSync } from 'node:fs'
import { dirname, join, resolve, sep } from 'node:path'
import ts from 'typescript'

/** The module whose `SlotMap` / standard-kit interfaces every slot owner merges into. */
const SLOTS_MODULE = '@deepseek-ai/dsh-client-ui-slots'

/** Cheap textual prefilter for a slot-contract merge, quote-style agnostic. */
const MERGE_HEAD = /declare module ['"]@deepseek-ai\/dsh-client-ui-slots['"]/

/** Cheap textual prefilter for a registration call site. */
const REGISTER_HEAD = /\.register\(/

/** One `SlotMap` member: the slot's contract as its owning package declares it. */
export interface SlotDeclaration {
  /** SlotMap key, e.g. `settings.section`. */
  key: string
  /** Cardinality literal (`single` / `list` / `keyed` / `chain`), or '' when not a literal. */
  kind: string
  /** Data-scope literal (`root` / `session` / `session-maybe`), or '' when not a literal. */
  scope: string
  /** Type name of the owner-supplied props share, absent when the slot declares none. */
  ownerType?: string
  /** Source text of the `keyProps` member (keyed slots), absent otherwise. */
  keyProps?: string
  /** Source text of the `hookContext` member, absent otherwise. */
  hookContext?: string
  /** Type name of the slot-level inject face, absent when the slot declares none. */
  injectType?: string
  /** The member's JSDoc with container indentation removed, '' when undocumented. */
  jsDoc: string
  /** Workspace package that declares the contract. */
  package: string
  /** Source pointer `packages/…/file.ts:line`. */
  source: string
}

/** One `slots.register({ name, … }, Component)` call site. */
export interface SlotRegistration {
  /** Target SlotMap key the entry contributes into. */
  key: string
  /** Workspace package that registers the entry. */
  package: string
  /** Component argument as written (identifier, or a trimmed expression). */
  component: string
  /** `id` literal of a list entry, absent otherwise. */
  id?: string
  /** `key` literal of a keyed entry, absent otherwise. */
  entryKey?: string
  /** SlotMap keys this registration declares as children (they exist while it is mounted). */
  children: string[]
  /** Source pointer `packages/…/file.ts:line`. */
  source: string
}

/** One exported type declaration, retained with its JSDoc for catalog projection. */
export interface TypeDeclaration {
  /** Declared name. */
  name: string
  /** Full declaration text INCLUDING its JSDoc (member docs are the teaching text). */
  text: string
  /** Source pointer `packages/…/file.ts:line`. */
  source: string
}

/** One scanned source file with the artifacts the catalog reads from it. */
export interface ScannedFile {
  /** Repo-relative, `/`-normalized path. */
  rel: string
  /** Workspace package name that owns the file. */
  package: string
  /** Parsed source file. */
  sf: ts.SourceFile
}

/**
 * Parse every file matching `patterns`, keeping the ones that carry a slot
 * contract merge or a registration call. Files without either are skipped so
 * the scan stays cheap over the whole workspace.
 * @param scanRoot - repository root the patterns resolve against.
 * @param patterns - glob(s) selecting the TypeScript/TSX files to scan.
 * @returns one entry per interesting file, in path order.
 */
export function scanSlotFiles(scanRoot: string, patterns: readonly string[]): ScannedFile[] {
  const out: ScannedFile[] = []
  const names = new Map<string, string>()
  const rels = [...new Set(globSync(patterns as string[], { cwd: scanRoot })
    .map(path => path.split(sep).join('/')))].sort()
  for (const rel of rels) {
    const abs = resolve(scanRoot, rel)
    const text = readFileSync(abs, 'utf8')
    if (!MERGE_HEAD.test(text) && !REGISTER_HEAD.test(text)) continue
    out.push({
      rel,
      package: packageNameOf(scanRoot, rel, names),
      sf: ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, scriptKindOf(rel)),
    })
  }
  return out
}

/**
 * Index every exported type declaration of the scanned packages, keeping JSDoc.
 * The catalog resolves owner-props and inject-face shapes through this index
 * instead of a type-checker program: the declaration text with its member
 * documentation IS the teaching material a registrant needs.
 * @param scanRoot - repository root the patterns resolve against.
 * @param patterns - glob(s) selecting the TypeScript/TSX files to index.
 * @returns name → declaration, with names declared more than once dropped as ambiguous.
 */
export function indexExportedTypes(scanRoot: string, patterns: readonly string[]): Map<string, TypeDeclaration> {
  const index = new Map<string, TypeDeclaration>()
  const ambiguous = new Set<string>()
  const rels = [...new Set(globSync(patterns as string[], { cwd: scanRoot })
    .map(path => path.split(sep).join('/')))].sort()
  for (const rel of rels) {
    const abs = resolve(scanRoot, rel)
    const sf = ts.createSourceFile(abs, readFileSync(abs, 'utf8'), ts.ScriptTarget.Latest, true, scriptKindOf(rel))
    for (const statement of sf.statements) {
      if (!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement)) continue
      if (!statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
      const name = statement.name.text
      if (index.has(name)) {
        ambiguous.add(name)
        continue
      }
      index.set(name, {
        name,
        text: declarationText(statement, sf),
        source: `${rel}:${String(lineOf(sf, statement))}`,
      })
    }
  }
  for (const name of ambiguous) index.delete(name)
  return index
}

/**
 * Read every `SlotMap` member declared in one scanned file.
 * @param file - a file returned by {@link scanSlotFiles}.
 * @returns the declared slots, in source order.
 */
export function slotDeclarations(file: ScannedFile): SlotDeclaration[] {
  const out: SlotDeclaration[] = []
  for (const body of slotModuleBodies(file.sf)) {
    for (const statement of body.statements) {
      if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== 'SlotMap') continue
      for (const member of statement.members) {
        if (!ts.isPropertySignature(member) || member.type === undefined) continue
        const key = ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
          ? member.name.text
          : member.name.getText(file.sf)
        const entry = ts.isTypeLiteralNode(member.type) ? member.type : undefined
        const ownerType = memberTypeText(entry, 'owner', file.sf)
        const keyProps = memberTypeText(entry, 'keyProps', file.sf)
        const hookContext = memberTypeText(entry, 'hookContext', file.sf)
        const injectType = memberTypeText(entry, 'inject', file.sf)
        out.push({
          key,
          kind: literalMember(entry, 'kind'),
          scope: literalMember(entry, 'scope'),
          ...ownerType === undefined ? {} : { ownerType },
          ...keyProps === undefined ? {} : { keyProps },
          ...hookContext === undefined ? {} : { hookContext },
          ...injectType === undefined ? {} : { injectType },
          jsDoc: jsDocOf(member, file.sf),
          package: file.package,
          source: `${file.rel}:${String(lineOf(file.sf, member))}`,
        })
      }
    }
  }
  return out
}

/**
 * Read every registration call site in one scanned file: which slot it
 * occupies, with which component and cell identity, and which child slots it
 * declares. A call whose `name` is not a string literal is skipped — the
 * shipped composition always names its target literally, and a computed name
 * carries no catalog fact.
 * @param file - a file returned by {@link scanSlotFiles}.
 * @returns the registrations, in source order.
 */
export function slotRegistrations(file: ScannedFile): SlotRegistration[] {
  const out: SlotRegistration[] = []
  const visit = (node: ts.Node): void => {
    if (ts.isCallExpression(node)
      && ts.isPropertyAccessExpression(node.expression)
      && node.expression.name.text === 'register'
      && isSlotsReceiver(node.expression.expression, file.sf)
      && node.arguments.length >= 1) {
      const options = node.arguments[0]
      if (options !== undefined && ts.isObjectLiteralExpression(options)) {
        const key = stringProperty(options, 'name')
        if (key !== undefined) {
          const id = stringProperty(options, 'id')
          const entryKey = stringProperty(options, 'key')
          out.push({
            key,
            package: file.package,
            component: componentText(node.arguments[1], file.sf),
            ...id === undefined ? {} : { id },
            ...entryKey === undefined ? {} : { entryKey },
            children: childKeys(options),
            source: `${file.rel}:${String(lineOf(file.sf, node))}`,
          })
        }
      }
    }
    ts.forEachChild(node, visit)
  }
  visit(file.sf)
  return out
}

/**
 * Read one standard-kit interface's members from the scanned files: the props
 * a slot component receives for free from the framework at a given scope.
 * @param files - scanned files to search.
 * @param interfaceName - `GlobalStandardProps`, `SessionStandardProps`, or `SessionMaybeStandardProps`.
 * @returns `member: type` texts in declaration order, merged across declaring files.
 */
export function standardKitMembers(files: readonly ScannedFile[], interfaceName: string): string[] {
  const out: string[] = []
  for (const file of files) {
    for (const body of slotModuleBodies(file.sf)) {
      for (const statement of body.statements) {
        if (!ts.isInterfaceDeclaration(statement) || statement.name.text !== interfaceName) continue
        for (const member of statement.members) {
          if (!ts.isPropertySignature(member)) continue
          const type = member.type === undefined ? 'unknown' : member.type.getText(file.sf)
          out.push(`${member.name.getText(file.sf)}${member.questionToken === undefined ? '' : '?'}: ${collapse(type)}`)
        }
      }
    }
  }
  return out
}

/**
 * Names in the type index that seed texts mention, word-bounded — ONE level, not
 * a transitive closure. The catalog expands an owner-props contract exactly one
 * step: the owner interface carries the interaction protocol in its own member
 * documentation, while the shapes its fields reference belong to the subsystems
 * that own them and would otherwise drag the entire session model into a single
 * slot's report.
 * @param seeds - declaration or signature texts to search.
 * @param index - the type index from {@link indexExportedTypes}.
 * @returns the mentioned names, sorted.
 */
export function referencedTypeNames(
  seeds: readonly string[],
  index: ReadonlyMap<string, TypeDeclaration>,
): string[] {
  const found: string[] = []
  for (const name of index.keys()) {
    const pattern = new RegExp(`\\b${name}\\b`)
    if (seeds.some(text => pattern.test(text))) found.push(name)
  }
  return found.sort()
}

/**
 * Resolve declarations by name, dropping names the index does not hold.
 * @param names - type names to resolve.
 * @param index - the type index from {@link indexExportedTypes}.
 * @returns the resolved declarations, sorted by name.
 */
export function declaredTypes(
  names: readonly string[],
  index: ReadonlyMap<string, TypeDeclaration>,
): TypeDeclaration[] {
  return [...names]
    .flatMap(name => index.get(name) ?? [])
    .sort((left, right) => left.name.localeCompare(right.name))
}

/** Every slot-contract module block in one file, in source order. */
function slotModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
  const bodies: ts.ModuleBlock[] = []
  for (const statement of sf.statements) {
    if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name)) continue
    if (statement.name.text !== SLOTS_MODULE) continue
    if (statement.body !== undefined && ts.isModuleBlock(statement.body)) bodies.push(statement.body)
  }
  return bodies
}

/**
 * Whether a `X.register(...)` receiver is the slots service. Every other
 * registry in the repo (`ctx.tools`, `ctx.commands`, `ctx.settings`, …) also
 * takes an options object with a `name`, so the receiver is what separates a
 * slot occupancy fact from an unrelated registration.
 */
function isSlotsReceiver(receiver: ts.Expression, sf: ts.SourceFile): boolean {
  const text = receiver.getText(sf)
  return text === 'slots' || text.endsWith('.slots')
}

/** The workspace package name owning a repo-relative file, memoized per package root. */
function packageNameOf(scanRoot: string, rel: string, cache: Map<string, string>): string {
  let dir = dirname(resolve(scanRoot, rel))
  while (dir.length > scanRoot.length) {
    const cached = cache.get(dir)
    if (cached !== undefined) return cached
    try {
      const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: unknown }
      if (typeof manifest.name === 'string') {
        cache.set(dir, manifest.name)
        return manifest.name
      }
    } catch {
      // No manifest at this level: keep walking up to the owning package root.
    }
    dir = dirname(dir)
  }
  return '(unknown package)'
}

/** TSX must parse as TSX; a `.ts` file with JSX-looking generics must not. */
function scriptKindOf(rel: string): ts.ScriptKind {
  return rel.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
}

/** 1-based line of a node's first character. */
function lineOf(sf: ts.SourceFile, node: ts.Node): number {
  return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1
}

/** Declaration text including leading JSDoc, with container indentation removed. */
function declarationText(statement: ts.Node, sf: ts.SourceFile): string {
  return dedent(sf.text.slice(statement.getStart(sf, true), statement.getEnd()))
}

/** One member's JSDoc comment text, '' when the member has none. */
function jsDocOf(member: ts.Node, sf: ts.SourceFile): string {
  // getStart(includeJsDoc) brackets exactly the doc comment: with it the range
  // opens at `/**`, without it at the member itself.
  const withDoc = member.getStart(sf, true)
  const withoutDoc = member.getStart(sf, false)
  if (withDoc >= withoutDoc) return ''
  return dedent(sf.text.slice(withDoc, withoutDoc).trimEnd())
}

/** Strip the shared leading indentation of a multi-line source slice. */
function dedent(text: string): string {
  const lines = text.split('\n')
  const indents = lines.slice(1).filter(line => line.trim() !== '')
    .map(line => (/^\s*/.exec(line) as RegExpExecArray)[0].length)
  const shared = indents.length === 0 ? 0 : Math.min(...indents)
  return [lines[0] ?? '', ...lines.slice(1).map(line => line.slice(shared))].join('\n').trimEnd()
}

/** Collapse a type text to one line so catalog rows stay one row. */
function collapse(text: string): string {
  return text.replace(/\s+/g, ' ').trim()
}

/** A type-literal member's string-literal type text, '' when absent or computed. */
function literalMember(entry: ts.TypeLiteralNode | undefined, name: string): string {
  const member = namedMember(entry, name)
  if (member?.type === undefined) return ''
  return ts.isLiteralTypeNode(member.type) && ts.isStringLiteral(member.type.literal)
    ? member.type.literal.text
    : ''
}

/** A type-literal member's type text on one line, absent when the member is. */
function memberTypeText(
  entry: ts.TypeLiteralNode | undefined,
  name: string,
  sf: ts.SourceFile,
): string | undefined {
  const member = namedMember(entry, name)
  return member?.type === undefined ? undefined : collapse(member.type.getText(sf))
}

/** One named property signature of a type literal. */
function namedMember(entry: ts.TypeLiteralNode | undefined, name: string): ts.PropertySignature | undefined {
  if (entry === undefined) return undefined
  for (const member of entry.members) {
    if (ts.isPropertySignature(member) && memberName(member.name) === name) return member
  }
  return undefined
}

/** A property name's text, quotes removed. */
function memberName(name: ts.PropertyName): string {
  return ts.isStringLiteral(name) || ts.isIdentifier(name) ? name.text : name.getText()
}

/** One string-literal property of an options object literal. */
function stringProperty(options: ts.ObjectLiteralExpression, name: string): string | undefined {
  for (const property of options.properties) {
    if (!ts.isPropertyAssignment(property)) continue
    if (memberName(property.name) !== name) continue
    if (ts.isStringLiteral(property.initializer)) return property.initializer.text
  }
  return undefined
}

/** The SlotMap keys a registration's `children` table declares. */
function childKeys(options: ts.ObjectLiteralExpression): string[] {
  for (const property of options.properties) {
    if (!ts.isPropertyAssignment(property)) continue
    if (memberName(property.name) !== 'children') continue
    if (!ts.isObjectLiteralExpression(property.initializer)) return []
    return property.initializer.properties
      .flatMap(child => (child.name === undefined ? [] : [memberName(child.name)]))
  }
  return []
}

/** The component argument as written; a non-identifier expression is collapsed. */
function componentText(argument: ts.Expression | undefined, sf: ts.SourceFile): string {
  if (argument === undefined) return '(none)'
  const text = collapse(argument.getText(sf))
  return text.length > 60 ? `${text.slice(0, 57)}…` : text
}
