import * as fs from 'node:fs';
import * as path from 'node:path';
import type {
  IComponentDiscoveryMetadata,
  IPackageDiscoveryMetadata,
} from './discovery-extractor.js';

type JsonRecord = Record<string, unknown>;

export interface IPackageDiscoveryIndexVersion {
  readonly indexedAt: string;
  readonly indexedBy: string;
  readonly metadata: IPackageDiscoveryMetadata;
}

export interface IPackageDiscoveryIndexPackage {
  readonly versions: Record<string, IPackageDiscoveryIndexVersion>;
}

export interface IPackageDiscoveryIndex {
  readonly packages: Record<string, IPackageDiscoveryIndexPackage>;
}

export interface IPackageDiscoverySearchQuery {
  readonly text?: string;
  readonly tag?: string;
  readonly inferredKind?: string;
  readonly declaredKind?: string;
  readonly capability?: string;
  readonly accepts?: string;
  readonly emits?: string;
  readonly skill?: string;
  readonly shells?: string;
}

export interface IPackageDiscoverySearchResult {
  readonly packageName: string;
  readonly version: string;
  readonly description?: string;
  readonly displayName?: string;
  readonly inferredKind: string;
  readonly declaredKind?: string;
  readonly keywords: readonly string[];
  readonly matchedFields: readonly string[];
  readonly score: number;
  readonly webapp?: {
    readonly appName: string;
    readonly routePrefix?: string;
    readonly shells: readonly string[];
    readonly icon?: string;
  };
  readonly components: readonly IComponentDiscoveryMetadata[];
}

function asRecord(value: unknown): JsonRecord | null {
  return value !== null && typeof value === 'object' && !Array.isArray(value)
    ? value as JsonRecord
    : null;
}

function readOptionalString(value: unknown): string | undefined {
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}

function readStringArray(value: unknown): readonly string[] {
  return Array.isArray(value)
    ? value
      .filter((entry): entry is string => typeof entry === 'string')
      .map((entry) => entry.trim())
      .filter((entry) => entry.length > 0)
    : [];
}

function readComponent(value: unknown): IComponentDiscoveryMetadata | null {
  const record = asRecord(value);
  const type = readOptionalString(record?.type);
  if (!record || !type) {
    return null;
  }
  return {
    type,
    ...(readOptionalString(record.exportName) ? { exportName: readOptionalString(record.exportName) } : {}),
    ...(readOptionalString(record.mount) ? { mount: readOptionalString(record.mount) } : {}),
    ...(readOptionalString(record.surface) ? { surface: readOptionalString(record.surface) } : {}),
    ...(readOptionalString(record.description) ? { description: readOptionalString(record.description) } : {}),
    ...(readOptionalString(record.kind) ? { kind: readOptionalString(record.kind) } : {}),
    accepts: readStringArray(record.accepts),
    emits: readStringArray(record.emits),
    skills: readStringArray(record.skills),
    tags: readStringArray(record.tags),
    capabilities: readStringArray(record.capabilities),
  };
}

function readPackageMetadata(value: unknown): IPackageDiscoveryMetadata | null {
  const record = asRecord(value);
  const packageName = readOptionalString(record?.packageName);
  const version = readOptionalString(record?.version);
  const inferredKind = readOptionalString(record?.inferredKind);
  if (!record || !packageName || !version || !inferredKind) {
    return null;
  }
  const webappRecord = asRecord(record.webapp);
  const appName = readOptionalString(webappRecord?.appName);
  return {
    packageName,
    version,
    ...(readOptionalString(record.displayName) ? { displayName: readOptionalString(record.displayName) } : {}),
    ...(readOptionalString(record.description) ? { description: readOptionalString(record.description) } : {}),
    ...(readOptionalString(record.declaredKind) ? { declaredKind: readOptionalString(record.declaredKind) } : {}),
    inferredKind,
    tags: readStringArray(record.tags),
    capabilities: readStringArray(record.capabilities),
    permissions: asRecord(record.permissions),
    ...(webappRecord && appName ? {
      webapp: {
        appName,
        ...(readOptionalString(webappRecord.routePrefix) ? { routePrefix: readOptionalString(webappRecord.routePrefix) } : {}),
        ...(readOptionalString(webappRecord.displayName) ? { displayName: readOptionalString(webappRecord.displayName) } : {}),
        ...(readOptionalString(webappRecord.icon) ? { icon: readOptionalString(webappRecord.icon) } : {}),
        ...(typeof webappRecord.navOrder === 'number' && Number.isFinite(webappRecord.navOrder)
          ? { navOrder: webappRecord.navOrder }
          : {}),
        ...(readOptionalString(webappRecord.description) ? { description: readOptionalString(webappRecord.description) } : {}),
        shells: readStringArray(webappRecord.shells),
      },
    } : {}),
    components: Array.isArray(record.components)
      ? record.components
        .map(readComponent)
        .filter((entry): entry is IComponentDiscoveryMetadata => Boolean(entry))
      : [],
  };
}

function resolveDiscoveryIndexPath(registryRoot: string): string {
  return path.join(registryRoot, 'discovery-index.json');
}

export function readPackageDiscoveryIndex(registryRoot: string): IPackageDiscoveryIndex {
  const indexPath = resolveDiscoveryIndexPath(registryRoot);
  if (!fs.existsSync(indexPath)) {
    return { packages: {} };
  }
  const root = asRecord(JSON.parse(fs.readFileSync(indexPath, 'utf8')) as unknown);
  const packagesRecord = asRecord(root?.packages) ?? {};
  const packages: Record<string, IPackageDiscoveryIndexPackage> = {};

  for (const [packageName, packageValue] of Object.entries(packagesRecord)) {
    const packageRecord = asRecord(packageValue);
    const versionsRecord = asRecord(packageRecord?.versions) ?? {};
    const versions: Record<string, IPackageDiscoveryIndexVersion> = {};
    for (const [version, versionValue] of Object.entries(versionsRecord)) {
      const versionRecord = asRecord(versionValue);
      const indexedAt = readOptionalString(versionRecord?.indexedAt);
      const indexedBy = readOptionalString(versionRecord?.indexedBy);
      const metadata = readPackageMetadata(versionRecord?.metadata);
      if (!indexedAt || !indexedBy || !metadata) {
        continue;
      }
      versions[version] = { indexedAt, indexedBy, metadata };
    }
    packages[packageName] = { versions };
  }
  return { packages };
}

export function publishPackageDiscoveryMetadata(
  registryRoot: string,
  metadata: IPackageDiscoveryMetadata,
  indexedBy: string,
): void {
  const index = readPackageDiscoveryIndex(registryRoot);
  const packageEntry = index.packages[metadata.packageName] ?? { versions: {} };
  packageEntry.versions[metadata.version] = {
    indexedAt: new Date().toISOString(),
    indexedBy,
    metadata,
  };
  index.packages[metadata.packageName] = packageEntry;

  const indexPath = resolveDiscoveryIndexPath(registryRoot);
  fs.mkdirSync(path.dirname(indexPath), { recursive: true });
  fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf8');
}

function latestVersionEntry(entry: IPackageDiscoveryIndexPackage): IPackageDiscoveryIndexVersion | null {
  const versions = Object.entries(entry.versions);
  if (versions.length === 0) {
    return null;
  }
  versions.sort(([left], [right]) => left.localeCompare(right, undefined, { numeric: true }));
  return versions.at(-1)?.[1] ?? null;
}

function toLowerTerms(values: readonly string[]): readonly string[] {
  return values
    .map((value) => value.trim().toLowerCase())
    .filter((value) => value.length > 0);
}

function globMatches(pattern: string | undefined, values: readonly string[]): boolean {
  if (!pattern) {
    return true;
  }
  const lowerPattern = pattern.toLowerCase();
  const escaped = lowerPattern
    .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
    .replace(/\*/g, '.*');
  const regexp = new RegExp(`^${escaped}$`);
  return values.some((value) => regexp.test(value.toLowerCase()));
}

function collectSearchFields(metadata: IPackageDiscoveryMetadata): readonly [string, string][] {
  const fields: Array<[string, string]> = [];
  function push(field: string, value: string | undefined): void {
    if (value && value.trim().length > 0) {
      fields.push([field, value]);
    }
  }

  push('packageName', metadata.packageName);
  push('displayName', metadata.displayName);
  push('description', metadata.description);
  push('inferredKind', metadata.inferredKind);
  push('declaredKind', metadata.declaredKind);
  for (const tag of metadata.tags) push('tag', tag);
  for (const capability of metadata.capabilities) push('capability', capability);
  if (metadata.webapp) {
    push('webapp.appName', metadata.webapp.appName);
    push('webapp.displayName', metadata.webapp.displayName);
    push('webapp.description', metadata.webapp.description);
    for (const shell of metadata.webapp.shells) push('webapp.shells', shell);
  }
  for (const component of metadata.components) {
    push('component.type', component.type);
    push('component.exportName', component.exportName);
    push('component.mount', component.mount);
    push('component.surface', component.surface);
    push('component.description', component.description);
    push('component.kind', component.kind);
    for (const value of component.accepts) push('component.accepts', value);
    for (const value of component.emits) push('component.emits', value);
    for (const value of component.skills) push('component.skills', value);
    for (const value of component.tags) push('component.tags', value);
    for (const value of component.capabilities) push('component.capabilities', value);
  }
  return fields;
}

function scoreText(metadata: IPackageDiscoveryMetadata, text: string | undefined): {
  readonly matches: boolean;
  readonly score: number;
  readonly matchedFields: readonly string[];
} {
  const tokens = toLowerTerms((text ?? '').split(/\s+/));
  if (tokens.length === 0) {
    return { matches: true, score: 0, matchedFields: [] };
  }
  const fields = collectSearchFields(metadata);
  let score = 0;
  const matchedFields = new Set<string>();
  for (const token of tokens) {
    let tokenMatched = false;
    for (const [field, value] of fields) {
      if (value.toLowerCase().includes(token)) {
        tokenMatched = true;
        matchedFields.add(field);
        score += field === 'packageName' || field === 'displayName' ? 5 : 1;
      }
    }
    if (!tokenMatched) {
      return { matches: false, score: 0, matchedFields: [] };
    }
  }
  return {
    matches: true,
    score,
    matchedFields: [...matchedFields].sort((left, right) => left.localeCompare(right)),
  };
}

function metadataMatchesFilters(metadata: IPackageDiscoveryMetadata, query: IPackageDiscoverySearchQuery): boolean {
  if (query.tag && !metadata.tags.some((tag) => tag.toLowerCase() === query.tag?.toLowerCase())) {
    return false;
  }
  if (query.inferredKind && metadata.inferredKind.toLowerCase() !== query.inferredKind.toLowerCase()) {
    return false;
  }
  if (query.declaredKind && metadata.declaredKind?.toLowerCase() !== query.declaredKind.toLowerCase()) {
    return false;
  }
  if (query.capability && !metadata.capabilities.some((capability) => capability.toLowerCase() === query.capability?.toLowerCase())) {
    return false;
  }
  if (query.shells && !metadata.webapp?.shells.some((shell) => shell.toLowerCase() === query.shells?.toLowerCase())) {
    return false;
  }
  if (!globMatches(query.accepts, metadata.components.flatMap((component) => component.accepts))) {
    return false;
  }
  if (!globMatches(query.emits, metadata.components.flatMap((component) => component.emits))) {
    return false;
  }
  if (!globMatches(query.skill, metadata.components.flatMap((component) => component.skills))) {
    return false;
  }
  return true;
}

function keywordsFor(metadata: IPackageDiscoveryMetadata): readonly string[] {
  return [
    metadata.inferredKind,
    ...(metadata.declaredKind ? [metadata.declaredKind] : []),
    ...metadata.tags,
    ...metadata.capabilities,
    ...(metadata.webapp ? metadata.webapp.shells : []),
    ...metadata.components.flatMap((component) => [
      component.surface,
      component.kind,
      ...component.skills,
      ...component.tags,
      ...component.capabilities,
    ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0)),
  ].filter((value, index, all) => all.indexOf(value) === index);
}

export function searchPackageDiscoveryIndex(
  registryRoot: string,
  query: IPackageDiscoverySearchQuery,
  limit: number,
): readonly IPackageDiscoverySearchResult[] {
  const index = readPackageDiscoveryIndex(registryRoot);
  const results: IPackageDiscoverySearchResult[] = [];
  for (const [packageName, entry] of Object.entries(index.packages)) {
    const latest = latestVersionEntry(entry);
    if (!latest) {
      continue;
    }
    const metadata = latest.metadata;
    if (metadata.packageName !== packageName) {
      continue;
    }
    if (!metadataMatchesFilters(metadata, query)) {
      continue;
    }
    const textScore = scoreText(metadata, query.text);
    if (!textScore.matches) {
      continue;
    }
    results.push({
      packageName: metadata.packageName,
      version: metadata.version,
      ...(metadata.description ? { description: metadata.description } : {}),
      ...(metadata.displayName ? { displayName: metadata.displayName } : {}),
      inferredKind: metadata.inferredKind,
      ...(metadata.declaredKind ? { declaredKind: metadata.declaredKind } : {}),
      keywords: keywordsFor(metadata),
      matchedFields: textScore.matchedFields,
      score: textScore.score,
      ...(metadata.webapp ? {
        webapp: {
          appName: metadata.webapp.appName,
          ...(metadata.webapp.routePrefix ? { routePrefix: metadata.webapp.routePrefix } : {}),
          shells: metadata.webapp.shells,
          ...(metadata.webapp.icon ? { icon: metadata.webapp.icon } : {}),
        },
      } : {}),
      components: metadata.components,
    });
  }
  return results
    .sort((left, right) => {
      if (left.score !== right.score) return right.score - left.score;
      return left.packageName.localeCompare(right.packageName);
    })
    .slice(0, limit);
}
