/**
 * Pure Storybook discovery aggregator.
 *
 * Given a repo's candidate files (path + content) and an optional
 * `.storybook/main.*` config, this distills the Storybook into per-component
 * facts: the documented variants, story count, import specifier, title and
 * confidence. It runs identically in the CLI (local fs reads) and the Cloud
 * scan (GitHub API content) — it never touches the filesystem.
 *
 * This is the F1 core: a connected repo's Storybook becomes a structured list
 * of proposed canonical primitives. Matching those facts onto the extracted
 * component inventory and writing canonical dispositions happens downstream;
 * this layer only reads story content.
 */
import { parseStoryContent } from "./parse-story.js";
import { extractCategory } from "./parse-story.js";
import { parseStorybookConfigContent, DEFAULT_STORY_PATTERNS } from "./parse-config.js";
import { isStoryFilePath } from "./story-paths.js";
import path from "node:path";
import type { ParsedStoryFile } from "./types.js";

/** A single enumerated variant axis documented for a component. */
export interface StorybookVariant {
  /** The prop name (e.g. "variant", "size"). */
  prop: string;
  /** The enumerated options (e.g. ["primary", "secondary"]). */
  options: string[];
  /** The Storybook control type, when known (e.g. "select", "radio"). */
  control?: string;
}

/** A concrete documented story state: its export name + the args it renders with. */
export interface StorybookStory {
  name: string;
  args: Record<string, unknown>;
}

/** Distilled facts for one component documented by a Storybook. */
export interface StorybookComponentFacts {
  /** Component name as documented (the story's `component` ref or title leaf). */
  componentName: string;
  /** The import specifier used by the story (e.g. "@acme/ui" or "."). */
  importSpecifier?: string;
  /**
   * Whether `importSpecifier` is a bare package import (not relative/absolute).
   * A package import is directly usable as a documented public import path.
   */
  importIsPackage: boolean;
  /** Full story title path (e.g. "Forms/Button"). */
  title?: string;
  /** Atomic-design / folder tier derived from the title. */
  category: string;
  /** Tags declared on the story meta (e.g. ["autodocs", "canonical"]). */
  tags: string[];
  /** Component description from autodocs metadata, when statically present. */
  description?: string;
  /** Enumerated variant axes (argTypes whose options are listed). */
  variants: StorybookVariant[];
  /** All prop names documented via argTypes (superset of variant props). */
  documentedProps: string[];
  /** Number of story exports. */
  storyCount: number;
  /** Story export names. */
  storyNames: string[];
  /** Concrete per-story states (export name + parsed args) — the CSF seed. */
  stories: StorybookStory[];
  /** Highest extraction confidence across the merged story files (0-1). */
  confidence: number;
  /** Story file paths that document this component (usually one). */
  storyFilePaths: string[];
}

/** Storybook configuration facts, if a config file was supplied. */
export interface StorybookConfigFacts {
  detected: boolean;
  configPath?: string;
  framework?: string;
  builder?: string;
  storyPatterns: string[];
}

/** The full result of discovering a Storybook across a set of files. */
export interface StorybookDiscovery {
  /** One entry per documented component, sorted by name. */
  components: StorybookComponentFacts[];
  /** Configuration facts (framework/builder/globs). */
  config: StorybookConfigFacts;
  /** Number of story files that parsed. */
  storyFileCount: number;
  /** Non-fatal warnings (e.g. a story file that failed to parse). */
  warnings: string[];
}

export interface DiscoverStorybookInput {
  /** Candidate files (may be the whole repo tree or pre-filtered stories). */
  files: ReadonlyArray<{ path: string; content: string }>;
  /** The `.storybook/main.*` config content, if found. */
  configContent?: string;
  /** The `.storybook/main.*` path, used for reporting. */
  configPath?: string;
}

const UNKNOWN_COMPONENT = "Unknown";

/** Distill a Storybook from a set of files into per-component facts. */
export function discoverStorybook(input: DiscoverStorybookInput): StorybookDiscovery {
  const config = resolveConfig(input.configContent, input.configPath);
  const warnings: string[] = [];

  const storyFiles = input.files.filter((file) =>
    shouldParseStoryFile(file.path, config.storyPatterns, config.configPath)
  );
  const parsed: ParsedStoryFile[] = [];
  for (const file of storyFiles) {
    try {
      const story = parseStoryContent(file.content, file.path);
      // The parser recovers from most syntax errors rather than throwing, so
      // surface its per-file warnings here — they are the real signal that a
      // story parsed partially and its facts may be incomplete.
      for (const warning of story.warnings) {
        warnings.push(`${file.path}: ${warning}`);
      }
      parsed.push(story);
    } catch {
      warnings.push(`Failed to parse story file: ${file.path}`);
    }
  }

  const byComponent = new Map<string, StorybookComponentFacts>();
  for (const story of parsed) {
    const facts = toFacts(story);
    // Distinct unnamed components must not collapse into one bucket.
    const key =
      facts.componentName === UNKNOWN_COMPONENT
        ? `${UNKNOWN_COMPONENT}\0${story.filePath}`
        : facts.componentName;
    const existing = byComponent.get(key);
    byComponent.set(key, existing ? mergeFacts(existing, facts) : facts);
  }

  const components = [...byComponent.values()].sort((a, b) =>
    a.componentName.localeCompare(b.componentName)
  );

  return {
    components,
    config,
    storyFileCount: parsed.length,
    warnings,
  };
}

function resolveConfig(configContent?: string, configPath?: string): StorybookConfigFacts {
  if (!configContent) {
    return { detected: false, storyPatterns: [...DEFAULT_STORY_PATTERNS] };
  }
  const parsed = parseStorybookConfigContent(configContent, configPath ?? ".storybook/main.ts");
  return {
    detected: true,
    configPath: parsed.configPath,
    framework: parsed.framework,
    builder: parsed.builder,
    storyPatterns: parsed.storyPatterns,
  };
}

function shouldParseStoryFile(
  filePath: string,
  storyPatterns: string[],
  configPath?: string
): boolean {
  if (!isStoryFilePath(filePath)) return false;
  if (!configPath) return true;

  return storyPatterns.some((pattern) =>
    globMatches(normalizePath(filePath), normalizePattern(pattern, configPath))
  );
}

function normalizePattern(pattern: string, configPath: string): string {
  const configDir = path.posix.dirname(normalizePath(configPath));
  const normalizedPattern = normalizePath(pattern);
  const resolved = normalizedPattern.startsWith(".")
    ? path.posix.normalize(path.posix.join(configDir, normalizedPattern))
    : normalizedPattern;
  return resolved.replace(/^\.\//, "").replace(/^\//, "");
}

function normalizePath(value: string): string {
  return value.replace(/\\/g, "/");
}

function globMatches(filePath: string, pattern: string): boolean {
  return globToRegExp(pattern).test(filePath);
}

function globToRegExp(pattern: string): RegExp {
  return new RegExp(`^${globToRegExpSource(pattern)}$`);
}

function globToRegExpSource(pattern: string): string {
  let source = "";
  for (let i = 0; i < pattern.length; i++) {
    const char = pattern[i];
    const next = pattern[i + 1];

    if (char === "*" && next === "*") {
      if (pattern[i + 2] === "/") {
        source += "(?:.*/)?";
        i += 2;
      } else {
        source += ".*";
        i += 1;
      }
      continue;
    }

    if (char === "*") {
      source += "[^/]*";
      continue;
    }

    if (char === "@" && next === "(") {
      const end = findExtglobEnd(pattern, i + 2);
      if (end !== -1) {
        const options = splitExtglobOptions(pattern.slice(i + 2, end)).map(globToRegExpSource);
        source += `(?:${options.join("|")})`;
        i = end;
        continue;
      }
    }

    if (char === "{") {
      const end = pattern.indexOf("}", i + 1);
      if (end !== -1) {
        const options = pattern
          .slice(i + 1, end)
          .split(",")
          .map(escapeRegExp)
          .join("|");
        source += `(?:${options})`;
        i = end;
        continue;
      }
    }

    source += escapeRegExp(char);
  }

  return source;
}

function findExtglobEnd(pattern: string, start: number): number {
  let depth = 1;
  for (let i = start; i < pattern.length; i++) {
    if (pattern[i] === "@" && pattern[i + 1] === "(") {
      depth++;
      i++;
      continue;
    }
    if (pattern[i] === ")") {
      depth--;
      if (depth === 0) return i;
    }
  }
  return -1;
}

function splitExtglobOptions(content: string): string[] {
  const options: string[] = [];
  let start = 0;
  let depth = 0;
  for (let i = 0; i < content.length; i++) {
    if (content[i] === "@" && content[i + 1] === "(") {
      depth++;
      i++;
      continue;
    }
    if (content[i] === ")") {
      depth--;
      continue;
    }
    if (content[i] !== "|" || depth !== 0) continue;
    options.push(content.slice(start, i));
    start = i + 1;
  }
  options.push(content.slice(start));
  return options;
}

function escapeRegExp(value: string): string {
  return value.replace(/[|\\{}()[\]^$+?.*]/g, "\\$&");
}

function toFacts(story: ParsedStoryFile): StorybookComponentFacts {
  const variants: StorybookVariant[] = [];
  const documentedProps: string[] = [];
  for (const [prop, argType] of Object.entries(story.argTypes)) {
    documentedProps.push(prop);
    if (Array.isArray(argType.options) && argType.options.length > 0) {
      variants.push({
        prop,
        options: [...argType.options],
        ...(argType.control ? { control: argType.control } : {}),
      });
    }
  }

  const importSpecifier = story.meta.componentImport;
  return {
    componentName: story.meta.componentName,
    ...(importSpecifier ? { importSpecifier } : {}),
    importIsPackage: isPackageImport(importSpecifier),
    ...(story.meta.title ? { title: story.meta.title } : {}),
    category: extractCategory(story.meta.title ?? ""),
    tags: story.meta.tags ?? [],
    ...(story.meta.description ? { description: story.meta.description } : {}),
    variants,
    documentedProps,
    storyCount: story.stories.length,
    storyNames: story.stories.map((s) => s.name),
    stories: story.stories.map((s) => ({ name: s.name, args: s.args })),
    confidence: story.confidence,
    storyFilePaths: [story.filePath],
  };
}

function mergeFacts(
  a: StorybookComponentFacts,
  b: StorybookComponentFacts
): StorybookComponentFacts {
  const variantsByProp = new Map<string, StorybookVariant>();
  for (const variant of [...a.variants, ...b.variants]) {
    const existing = variantsByProp.get(variant.prop);
    if (!existing) {
      variantsByProp.set(variant.prop, {
        prop: variant.prop,
        options: [...variant.options],
        ...(variant.control ? { control: variant.control } : {}),
      });
      continue;
    }
    existing.options = unique([...existing.options, ...variant.options]);
    if (!existing.control && variant.control) existing.control = variant.control;
  }

  return {
    componentName: a.componentName,
    importSpecifier: a.importSpecifier ?? b.importSpecifier,
    importIsPackage: a.importIsPackage || b.importIsPackage,
    title: a.title ?? b.title,
    category: a.category !== "components" ? a.category : b.category,
    tags: unique([...a.tags, ...b.tags]),
    description: a.description ?? b.description,
    variants: [...variantsByProp.values()].sort((x, y) => x.prop.localeCompare(y.prop)),
    documentedProps: unique([...a.documentedProps, ...b.documentedProps]),
    storyCount: a.storyCount + b.storyCount,
    storyNames: unique([...a.storyNames, ...b.storyNames]),
    stories: mergeStories(a.stories, b.stories),
    confidence: Math.max(a.confidence, b.confidence),
    storyFilePaths: unique([...a.storyFilePaths, ...b.storyFilePaths]),
  };
}

/** Merge concrete story states across files, first occurrence of a name wins. */
function mergeStories(a: StorybookStory[], b: StorybookStory[]): StorybookStory[] {
  const byName = new Map<string, StorybookStory>();
  for (const story of [...a, ...b]) {
    if (!byName.has(story.name)) byName.set(story.name, story);
  }
  return [...byName.values()];
}

/** A bare package import does not start with "." or "/". */
function isPackageImport(specifier?: string): boolean {
  if (!specifier) return false;
  return !specifier.startsWith(".") && !specifier.startsWith("/");
}

function unique<T>(values: T[]): T[] {
  return [...new Set(values)];
}
