//! JavaScript base client and pattern factory generation.

use std::fmt::Write;

use crate::{
    ClientConstants, ClientMetadata, CohortConstants, GenericSyntax, IndexSetPattern,
    JavaScriptSyntax, StructuralPattern, camel_case_keys, format_json,
    generate_parameterized_field, to_camel_case,
};

/// Generate the base BrkClient class with HTTP functionality.
pub fn generate_base_client(output: &mut String) {
    writeln!(
        output,
        r#"/**
 * @typedef {{Object}} BrkClientOptions
 * @property {{string}} baseUrl - Base URL for the API
 * @property {{number}} [timeout] - Request timeout in milliseconds
 * @property {{string|boolean}} [cache] - Enable browser cache with default name (true), custom name (string), or disable (false). No effect in Node.js. Default: true
 */

const _isBrowser = typeof window !== 'undefined' && 'caches' in window;
const _runIdle = (/** @type {{VoidFunction}} */ fn) => (globalThis.requestIdleCallback ?? setTimeout)(fn);
const _defaultCacheName = '__BRK_CLIENT__';
/** @param {{*}} v */
const _addCamelGetters = (v) => {{
  if (Array.isArray(v)) {{ v.forEach(_addCamelGetters); return v; }}
  if (v && typeof v === 'object' && v.constructor === Object) {{
    for (const k in v) {{
      if (k.includes('_')) {{
        const c = k.replace(/_([a-z])/g, (_, l) => l.toUpperCase());
        if (!(c in v)) Object.defineProperty(v, c, {{ get() {{ return this[k]; }} }});
      }}
      _addCamelGetters(v[k]);
    }}
  }}
  return v;
}};

/**
 * @param {{string|boolean|undefined}} cache
 * @returns {{Promise<Cache | null>}}
 */
const _openCache = (cache) => {{
  if (!_isBrowser || cache === false) return Promise.resolve(null);
  const name = typeof cache === 'string' ? cache : _defaultCacheName;
  return caches.open(name).catch(() => null);
}};

/**
 * Custom error class for BRK client errors
 */
class BrkError extends Error {{
  /**
   * @param {{string}} message
   * @param {{number}} [status]
   */
  constructor(message, status) {{
    super(message);
    this.name = 'BrkError';
    this.status = status;
  }}
}}

// Date conversion constants and helpers
const _GENESIS = new Date(2009, 0, 3);  // day1 0, week1 0
const _DAY_ONE = new Date(2009, 0, 9);  // day1 1 (6 day gap after genesis)
const _MS_PER_DAY = 86400000;
const _MS_PER_WEEK = 7 * _MS_PER_DAY;
const _EPOCH_MS = 1230768000000;
const _DATE_INDEXES = new Set([
  'minute10', 'minute30',
  'hour1', 'hour4', 'hour12',
  'day1', 'day3', 'week1',
  'month1', 'month3', 'month6',
  'year1', 'year10',
]);

/** @param {{number}} months @returns {{globalThis.Date}} */
const _addMonths = (months) => new Date(2009, months, 1);

/**
 * Convert an index value to a Date for date-based indexes.
 * @param {{Index}} index - The index type
 * @param {{number}} i - The index value
 * @returns {{globalThis.Date}}
 */
function indexToDate(index, i) {{
  switch (index) {{
    case 'minute10': return new Date(_EPOCH_MS + i * 600000);
    case 'minute30': return new Date(_EPOCH_MS + i * 1800000);
    case 'hour1': return new Date(_EPOCH_MS + i * 3600000);
    case 'hour4': return new Date(_EPOCH_MS + i * 14400000);
    case 'hour12': return new Date(_EPOCH_MS + i * 43200000);
    case 'day1': return i === 0 ? _GENESIS : new Date(_DAY_ONE.getTime() + (i - 1) * _MS_PER_DAY);
    case 'day3': return new Date(_EPOCH_MS - 86400000 + i * 259200000);
    case 'week1': return new Date(_GENESIS.getTime() + i * _MS_PER_WEEK);
    case 'month1': return _addMonths(i);
    case 'month3': return _addMonths(i * 3);
    case 'month6': return _addMonths(i * 6);
    case 'year1': return new Date(2009 + i, 0, 1);
    case 'year10': return new Date(2009 + i * 10, 0, 1);
    default: throw new Error(`${{index}} is not a date-based index`);
  }}
}}

/**
 * Convert a Date to an index value for date-based indexes.
 * Returns the floor index (latest index whose date is <= the given date).
 * @param {{Index}} index - The index type
 * @param {{globalThis.Date}} d - The date to convert
 * @returns {{number}}
 */
function dateToIndex(index, d) {{
  const ms = d.getTime();
  switch (index) {{
    case 'minute10': return Math.floor((ms - _EPOCH_MS) / 600000);
    case 'minute30': return Math.floor((ms - _EPOCH_MS) / 1800000);
    case 'hour1': return Math.floor((ms - _EPOCH_MS) / 3600000);
    case 'hour4': return Math.floor((ms - _EPOCH_MS) / 14400000);
    case 'hour12': return Math.floor((ms - _EPOCH_MS) / 43200000);
    case 'day1': {{
      if (ms < _DAY_ONE.getTime()) return 0;
      return 1 + Math.floor((ms - _DAY_ONE.getTime()) / _MS_PER_DAY);
    }}
    case 'day3': return Math.floor((ms - _EPOCH_MS + 86400000) / 259200000);
    case 'week1': return Math.floor((ms - _GENESIS.getTime()) / _MS_PER_WEEK);
    case 'month1': return (d.getFullYear() - 2009) * 12 + d.getMonth();
    case 'month3': return (d.getFullYear() - 2009) * 4 + Math.floor(d.getMonth() / 3);
    case 'month6': return (d.getFullYear() - 2009) * 2 + Math.floor(d.getMonth() / 6);
    case 'year1': return d.getFullYear() - 2009;
    case 'year10': return Math.floor((d.getFullYear() - 2009) / 10);
    default: throw new Error(`${{index}} is not a date-based index`);
  }}
}}

/**
 * Wrap raw series data with helper methods.
 * @template T
 * @param {{SeriesData<T>}} raw - Raw JSON response
 * @returns {{DateSeriesData<T>}}
 */
function _wrapSeriesData(raw) {{
  const {{ index, start, end, data }} = raw;
  const _dateBased = _DATE_INDEXES.has(index);
  return /** @type {{DateSeriesData<T>}} */ ({{
    ...raw,
    isDateBased: _dateBased,
    indexes() {{
      /** @type {{number[]}} */
      const result = [];
      for (let i = start; i < end; i++) result.push(i);
      return result;
    }},
    keys() {{
      return this.indexes();
    }},
    entries() {{
      /** @type {{Array<[number, T]>}} */
      const result = [];
      for (let i = 0; i < data.length; i++) result.push([start + i, data[i]]);
      return result;
    }},
    toMap() {{
      /** @type {{Map<number, T>}} */
      const map = new Map();
      for (let i = 0; i < data.length; i++) map.set(start + i, data[i]);
      return map;
    }},
    *[Symbol.iterator]() {{
      for (let i = 0; i < data.length; i++) yield /** @type {{[number, T]}} */ ([start + i, data[i]]);
    }},
    // DateSeriesData methods (only meaningful for date-based indexes)
    dates() {{
      /** @type {{globalThis.Date[]}} */
      const result = [];
      for (let i = start; i < end; i++) result.push(indexToDate(index, i));
      return result;
    }},
    dateEntries() {{
      /** @type {{Array<[globalThis.Date, T]>}} */
      const result = [];
      for (let i = 0; i < data.length; i++) result.push([indexToDate(index, start + i), data[i]]);
      return result;
    }},
    toDateMap() {{
      /** @type {{Map<globalThis.Date, T>}} */
      const map = new Map();
      for (let i = 0; i < data.length; i++) map.set(indexToDate(index, start + i), data[i]);
      return map;
    }},
  }});
}}

/**
 * @template T
 * @typedef {{Object}} SeriesDataBase
 * @property {{number}} version - Version of the series data
 * @property {{Index}} index - The index type used for this query
 * @property {{string}} type - Value type (e.g. "f32", "u64", "Sats")
 * @property {{number}} total - Total number of data points
 * @property {{number}} start - Start index (inclusive)
 * @property {{number}} end - End index (exclusive)
 * @property {{string}} stamp - ISO 8601 timestamp of when the response was generated
 * @property {{T[]}} data - The series data
 * @property {{boolean}} isDateBased - Whether this series uses a date-based index
 * @property {{() => number[]}} indexes - Get index numbers
 * @property {{() => number[]}} keys - Get keys as index numbers (alias for indexes)
 * @property {{() => Array<[number, T]>}} entries - Get [index, value] pairs
 * @property {{() => Map<number, T>}} toMap - Convert to Map<index, value>
 */

/** @template T @typedef {{SeriesDataBase<T> & Iterable<[number, T]>}} SeriesData */

/**
 * @template T
 * @typedef {{Object}} DateSeriesDataExtras
 * @property {{() => globalThis.Date[]}} dates - Get dates for each data point
 * @property {{() => Array<[globalThis.Date, T]>}} dateEntries - Get [date, value] pairs
 * @property {{() => Map<globalThis.Date, T>}} toDateMap - Convert to Map<date, value>
 */

/** @template T @typedef {{SeriesData<T> & DateSeriesDataExtras<T>}} DateSeriesData */
/** @typedef {{SeriesData<any>}} AnySeriesData */

/** @template T @typedef {{(onfulfilled?: (value: SeriesData<T>) => any, onrejected?: (reason: Error) => never) => Promise<SeriesData<T>>}} Thenable */
/** @template T @typedef {{(onfulfilled?: (value: DateSeriesData<T>) => any, onrejected?: (reason: Error) => never) => Promise<DateSeriesData<T>>}} DateThenable */

/**
 * @template T
 * @typedef {{Object}} SeriesEndpoint
 * @property {{(index: number) => SingleItemBuilder<T>}} get - Get single item at index
 * @property {{(start?: number, end?: number) => RangeBuilder<T>}} slice - Slice by index
 * @property {{(n: number) => RangeBuilder<T>}} first - Get first n items
 * @property {{(n: number) => RangeBuilder<T>}} last - Get last n items
 * @property {{(n: number) => SkippedBuilder<T>}} skip - Skip first n items, chain with take()
 * @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch all data
 * @property {{() => Promise<string>}} fetchCsv - Fetch all data as CSV
 * @property {{Thenable<T>}} then - Thenable (await endpoint)
 * @property {{string}} path - The endpoint path
 */

/**
 * @template T
 * @typedef {{Object}} DateSeriesEndpoint
 * @property {{(index: number | globalThis.Date) => DateSingleItemBuilder<T>}} get - Get single item at index or Date
 * @property {{(start?: number | globalThis.Date, end?: number | globalThis.Date) => DateRangeBuilder<T>}} slice - Slice by index or Date
 * @property {{(n: number) => DateRangeBuilder<T>}} first - Get first n items
 * @property {{(n: number) => DateRangeBuilder<T>}} last - Get last n items
 * @property {{(n: number) => DateSkippedBuilder<T>}} skip - Skip first n items, chain with take()
 * @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch all data
 * @property {{() => Promise<string>}} fetchCsv - Fetch all data as CSV
 * @property {{DateThenable<T>}} then - Thenable (await endpoint)
 * @property {{string}} path - The endpoint path
 */

/** @typedef {{SeriesEndpoint<any>}} AnySeriesEndpoint */

/** @template T @typedef {{Object}} SingleItemBuilder
 * @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the item
 * @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
 * @property {{Thenable<T>}} then - Thenable
 */

/** @template T @typedef {{Object}} DateSingleItemBuilder
 * @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the item
 * @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
 * @property {{DateThenable<T>}} then - Thenable
 */

/** @template T @typedef {{Object}} SkippedBuilder
 * @property {{(n: number) => RangeBuilder<T>}} take - Take n items after skipped position
 * @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch from skipped position to end
 * @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
 * @property {{Thenable<T>}} then - Thenable
 */

/** @template T @typedef {{Object}} DateSkippedBuilder
 * @property {{(n: number) => DateRangeBuilder<T>}} take - Take n items after skipped position
 * @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch from skipped position to end
 * @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
 * @property {{DateThenable<T>}} then - Thenable
 */

/** @template T @typedef {{Object}} RangeBuilder
 * @property {{(onUpdate?: (value: SeriesData<T>) => void) => Promise<SeriesData<T>>}} fetch - Fetch the range
 * @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
 * @property {{Thenable<T>}} then - Thenable
 */

/** @template T @typedef {{Object}} DateRangeBuilder
 * @property {{(onUpdate?: (value: DateSeriesData<T>) => void) => Promise<DateSeriesData<T>>}} fetch - Fetch the range
 * @property {{() => Promise<string>}} fetchCsv - Fetch as CSV
 * @property {{DateThenable<T>}} then - Thenable
 */

/**
 * @template T
 * @typedef {{Object}} SeriesPattern
 * @property {{string}} name - The series name
 * @property {{Readonly<Partial<Record<Index, SeriesEndpoint<T>>>>}} by - Index endpoints as lazy getters
 * @property {{() => readonly Index[]}} indexes - Get the list of available indexes
 * @property {{(index: Index) => SeriesEndpoint<T>|undefined}} get - Get an endpoint for a specific index
 */

/** @typedef {{SeriesPattern<any>}} AnySeriesPattern */

/**
 * Create a series endpoint builder with typestate pattern.
 * @template T
 * @param {{BrkClientBase}} client
 * @param {{string}} name - The series vec name
 * @param {{Index}} index - The index name
 * @returns {{DateSeriesEndpoint<T>}}
 */
function _endpoint(client, name, index) {{
  const p = `/api/series/${{name}}/${{index}}`;

  /**
   * @param {{number}} [start]
   * @param {{number}} [end]
   * @param {{string}} [format]
   * @returns {{string}}
   */
  const buildPath = (start, end, format) => {{
    const params = new URLSearchParams();
    if (start !== undefined) params.set('start', String(start));
    if (end !== undefined) params.set('end', String(end));
    if (format) params.set('format', format);
    const query = params.toString();
    return query ? `${{p}}?${{query}}` : p;
  }};

  /**
   * @param {{number}} [start]
   * @param {{number}} [end]
   * @returns {{DateRangeBuilder<T>}}
   */
  const rangeBuilder = (start, end) => ({{
    fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(start, end), onUpdate); }},
    fetchCsv() {{ return client.getText(buildPath(start, end, 'csv')); }},
    then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
  }});

  /**
   * @param {{number}} idx
   * @returns {{DateSingleItemBuilder<T>}}
   */
  const singleItemBuilder = (idx) => ({{
    fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(idx, idx + 1), onUpdate); }},
    fetchCsv() {{ return client.getText(buildPath(idx, idx + 1, 'csv')); }},
    then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
  }});

  /**
   * @param {{number}} start
   * @returns {{DateSkippedBuilder<T>}}
   */
  const skippedBuilder = (start) => ({{
    take(n) {{ return rangeBuilder(start, start + n); }},
    fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(start, undefined), onUpdate); }},
    fetchCsv() {{ return client.getText(buildPath(start, undefined, 'csv')); }},
    then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
  }});

  /** @type {{DateSeriesEndpoint<T>}} */
  const endpoint = {{
    get(idx) {{ if (idx instanceof Date) idx = dateToIndex(index, idx); return singleItemBuilder(idx); }},
    slice(start, end) {{
      if (start instanceof Date) start = dateToIndex(index, start);
      if (end instanceof Date) end = dateToIndex(index, end);
      return rangeBuilder(start, end);
    }},
    first(n) {{ return rangeBuilder(undefined, n); }},
    last(n) {{ return n === 0 ? rangeBuilder(undefined, 0) : rangeBuilder(-n, undefined); }},
    skip(n) {{ return skippedBuilder(n); }},
    fetch(onUpdate) {{ return client._fetchSeriesData(buildPath(), onUpdate); }},
    fetchCsv() {{ return client.getText(buildPath(undefined, undefined, 'csv')); }},
    then(resolve, reject) {{ return this.fetch().then(resolve, reject); }},
    get path() {{ return p; }},
  }};

  return endpoint;
}}

/**
 * Base HTTP client for making requests with caching support
 */
class BrkClientBase {{
  /**
   * @param {{BrkClientOptions|string}} options
   */
  constructor(options) {{
    const isString = typeof options === 'string';
    const rawUrl = isString ? options : options.baseUrl;
    this.baseUrl = rawUrl.endsWith('/') ? rawUrl.slice(0, -1) : rawUrl;
    this.timeout = isString ? 5000 : (options.timeout ?? 5000);
    /** @type {{Promise<Cache | null>}} */
    this._cachePromise = _openCache(isString ? undefined : options.cache);
    /** @type {{Cache | null}} */
    this._cache = null;
    this._cachePromise.then(c => this._cache = c);
  }}

  /**
   * @param {{string}} path
   * @returns {{Promise<Response>}}
   */
  async get(path) {{
    const url = `${{this.baseUrl}}${{path}}`;
    const res = await fetch(url, {{ signal: AbortSignal.timeout(this.timeout) }});
    if (!res.ok) throw new BrkError(`HTTP ${{res.status}}: ${{url}}`, res.status);
    return res;
  }}

  /**
   * Make a GET request - races cache vs network, first to resolve calls onUpdate
   * @template T
   * @param {{string}} path
   * @param {{(value: T) => void}} [onUpdate] - Called when data is available (may be called twice: cache then network)
   * @returns {{Promise<T>}}
   */
  async getJson(path, onUpdate) {{
    const url = `${{this.baseUrl}}${{path}}`;
    const cache = this._cache ?? await this._cachePromise;

    let resolved = false;
    /** @type {{Response | null}} */
    let cachedRes = null;

    // Race cache vs network - first to resolve calls onUpdate
    const cachePromise = cache?.match(url).then(async (res) => {{
      cachedRes = res ?? null;
      if (!res) return null;
      const json = _addCamelGetters(await res.json());
      if (!resolved && onUpdate) {{
        resolved = true;
        onUpdate(json);
      }}
      return json;
    }});

    const networkPromise = this.get(path).then(async (res) => {{
      const cloned = res.clone();
      const json = _addCamelGetters(await res.json());
      // Skip update if ETag matches and cache already delivered
      if (cachedRes?.headers.get('ETag') === res.headers.get('ETag')) {{
        if (!resolved && onUpdate) {{
          resolved = true;
          onUpdate(json);
        }}
        return json;
      }}
      resolved = true;
      if (onUpdate) {{
        onUpdate(json);
      }}
      if (cache) _runIdle(() => cache.put(url, cloned));
      return json;
    }});

    try {{
      return await networkPromise;
    }} catch (e) {{
      // Network failed - wait for cache
      const cachedJson = await cachePromise?.catch(() => null);
      if (cachedJson) return cachedJson;
      throw e;
    }}
  }}

  /**
   * Make a GET request and return raw text (for CSV responses)
   * @param {{string}} path
   * @returns {{Promise<string>}}
   */
  async getText(path) {{
    const res = await this.get(path);
    return res.text();
  }}

  /**
   * Fetch series data and wrap with helper methods (internal)
   * @template T
   * @param {{string}} path
   * @param {{(value: DateSeriesData<T>) => void}} [onUpdate]
   * @returns {{Promise<DateSeriesData<T>>}}
   */
  async _fetchSeriesData(path, onUpdate) {{
    const wrappedOnUpdate = onUpdate ? (/** @type {{SeriesData<T>}} */ raw) => onUpdate(_wrapSeriesData(raw)) : undefined;
    const raw = await this.getJson(path, wrappedOnUpdate);
    return _wrapSeriesData(raw);
  }}
}}

/**
 * Build series name with suffix.
 * @param {{string}} acc - Accumulated prefix
 * @param {{string}} s - Series suffix
 * @returns {{string}}
 */
const _m = (acc, s) => s ? (acc ? `${{acc}}_${{s}}` : s) : acc;

/**
 * Build series name with prefix.
 * @param {{string}} prefix - Prefix to prepend
 * @param {{string}} acc - Accumulated name
 * @returns {{string}}
 */
const _p = (prefix, acc) => acc ? `${{prefix}}_${{acc}}` : prefix;

"#
    )
    .unwrap();
}

/// Generate static constants for the BrkClient class.
pub fn generate_static_constants(output: &mut String) {
    let constants = ClientConstants::collect();

    // VERSION, INDEXES, POOL_ID_TO_POOL_NAME
    writeln!(output, "  VERSION = \"{}\";\n", constants.version).unwrap();
    write_static_const(output, "INDEXES", &format_json(&constants.indexes));
    write_static_const(
        output,
        "POOL_ID_TO_POOL_NAME",
        &format_json(&constants.pool_map),
    );

    // Cohort constants with camelCase keys
    for (name, value) in CohortConstants::all() {
        write_static_const(output, name, &format_json(&camel_case_keys(value)));
    }

    // Helper methods
    writeln!(
        output,
        r#"  /**
   * Convert an index value to a Date for date-based indexes.
   * @param {{Index}} index - The index type
   * @param {{number}} i - The index value
   * @returns {{globalThis.Date}}
   */
  indexToDate(index, i) {{
    return indexToDate(index, i);
  }}

  /**
   * Convert a Date to an index value for date-based indexes.
   * @param {{Index}} index - The index type
   * @param {{globalThis.Date}} d - The date to convert
   * @returns {{number}}
   */
  dateToIndex(index, d) {{
    return dateToIndex(index, d);
  }}

"#
    )
    .unwrap();
}

fn indent_json_const(json: &str) -> String {
    json.lines()
        .enumerate()
        .map(|(i, line)| {
            if i == 0 {
                line.to_string()
            } else {
                format!("  {}", line)
            }
        })
        .collect::<Vec<_>>()
        .join("\n")
}

fn write_static_const(output: &mut String, name: &str, json: &str) {
    writeln!(
        output,
        "  {} = /** @type {{const}} */ ({});\n",
        name,
        indent_json_const(json)
    )
    .unwrap();
}

/// Generate index accessor factory functions.
pub fn generate_index_accessors(output: &mut String, patterns: &[IndexSetPattern]) {
    if patterns.is_empty() {
        return;
    }

    writeln!(output, "// Index group constants and factory\n").unwrap();

    // Generate index array constants (e.g., _i1 = ["day1", "height"])
    for (i, pattern) in patterns.iter().enumerate() {
        write!(output, "const _i{} = /** @type {{const}} */ ([", i + 1).unwrap();
        for (j, index) in pattern.indexes.iter().enumerate() {
            if j > 0 {
                write!(output, ", ").unwrap();
            }
            write!(output, "\"{}\"", index.name()).unwrap();
        }
        writeln!(output, "]);").unwrap();
    }
    writeln!(output).unwrap();

    // Generate ONE generic series pattern factory
    writeln!(
        output,
        r#"/**
 * Generic series pattern factory.
 * @template T
 * @param {{BrkClientBase}} client
 * @param {{string}} name - The series vec name
 * @param {{readonly Index[]}} indexes - The supported indexes
 */
function _mp(client, name, indexes) {{
  const by = {{}};
  for (const idx of indexes) {{
    Object.defineProperty(by, idx, {{
      get() {{ return _endpoint(client, name, idx); }},
      enumerable: true,
      configurable: true
    }});
  }}
  return {{
    name,
    by,
    /** @returns {{readonly Index[]}} */
    indexes() {{ return indexes; }},
    /** @param {{Index}} index @returns {{SeriesEndpoint<T>|undefined}} */
    get(index) {{ return indexes.includes(index) ? _endpoint(client, name, index) : undefined; }}
  }};
}}
"#
    )
    .unwrap();

    // Generate typedefs and thin wrapper functions
    for (i, pattern) in patterns.iter().enumerate() {
        // Generate typedef for type safety
        let by_fields: Vec<String> = pattern
            .indexes
            .iter()
            .map(|idx| {
                let builder = if idx.is_date_based() {
                    "DateSeriesEndpoint"
                } else {
                    "SeriesEndpoint"
                };
                format!("readonly {}: {}<T>", idx.name(), builder)
            })
            .collect();
        let by_type = format!("{{ {} }}", by_fields.join(", "));

        writeln!(
            output,
            "/** @template T @typedef {{{{ name: string, by: {}, indexes: () => readonly Index[], get: (index: Index) => SeriesEndpoint<T>|undefined }}}} {} */",
            by_type, pattern.name
        )
        .unwrap();

        // Generate thin wrapper that calls the generic factory
        writeln!(
            output,
            "/** @template T @param {{BrkClientBase}} client @param {{string}} name @returns {{{}<T>}} */",
            pattern.name
        )
        .unwrap();
        writeln!(
            output,
            "function create{}(client, name) {{ return /** @type {{{}<T>}} */ (_mp(client, name, _i{})); }}",
            pattern.name,
            pattern.name,
            i + 1
        )
        .unwrap();
    }
    writeln!(output).unwrap();
}

/// Generate structural pattern factory functions.
pub fn generate_structural_patterns(
    output: &mut String,
    patterns: &[StructuralPattern],
    metadata: &ClientMetadata,
) {
    if patterns.is_empty() {
        return;
    }

    writeln!(output, "// Reusable structural pattern factories\n").unwrap();

    for pattern in patterns {
        // Generate typedef
        writeln!(output, "/**").unwrap();
        if pattern.is_generic {
            writeln!(output, " * @template T").unwrap();
        }
        writeln!(output, " * @typedef {{Object}} {}", pattern.name).unwrap();
        for field in &pattern.fields {
            let js_type = metadata.field_type_annotation(
                field,
                pattern.is_generic,
                None,
                GenericSyntax::JAVASCRIPT,
            );
            writeln!(
                output,
                " * @property {{{}}} {}",
                js_type,
                to_camel_case(&field.name)
            )
            .unwrap();
        }
        writeln!(output, " */\n").unwrap();

        // Skip factory for non-parameterizable patterns (inlined at tree level)
        if !metadata.is_parameterizable(&pattern.name) {
            continue;
        }

        writeln!(output, "/**").unwrap();
        writeln!(output, " * Create a {} pattern node", pattern.name).unwrap();
        if pattern.is_generic {
            writeln!(output, " * @template T").unwrap();
        }
        writeln!(output, " * @param {{BrkClientBase}} client").unwrap();
        writeln!(output, " * @param {{string}} acc - Accumulated series name").unwrap();
        if pattern.is_templated() {
            writeln!(output, " * @param {{string}} disc - Discriminator suffix").unwrap();
        }
        let return_type = if pattern.is_generic {
            format!("{}<T>", pattern.name)
        } else {
            pattern.name.clone()
        };
        writeln!(output, " * @returns {{{}}}", return_type).unwrap();
        writeln!(output, " */").unwrap();

        if pattern.is_templated() {
            writeln!(
                output,
                "function create{}(client, acc, disc) {{",
                pattern.name
            )
            .unwrap();
        } else {
            writeln!(output, "function create{}(client, acc) {{", pattern.name).unwrap();
        }
        writeln!(output, "  return {{").unwrap();

        let syntax = JavaScriptSyntax;
        for field in &pattern.fields {
            generate_parameterized_field(output, &syntax, field, pattern, metadata, "    ");
        }

        writeln!(output, "  }};").unwrap();
        writeln!(output, "}}\n").unwrap();
    }
}
