/**
 * Bitable grouping + conditional formatting utilities (U5 / R4).
 *
 * Pure functions — no Vue, no store, no I/O. Safe to unit-test in isolation.
 *
 * Three concerns:
 *   1. Conditional format rule matching (7 operators, first-match-wins)
 *   2. Grouping tree construction (nested levels + number-field aggregation)
 *   3. Color key → CSS var mapping (8 colors → --bitable-cf-<key>-{bg,fg})
 *
 * The 8-color palette + token names are defined in bitable-tokens.css.
 * Adding a color requires a token there + a `ColorKey` entry here + a
 * `ColorKey` Literal in `agentkit.bitable.view_config` (Python).
 */

import type { IBitableField, IBitableRecord } from '@/api/bitable'

// ---------------------------------------------------------------------------
// Types — mirror src/agentkit/bitable/view_config.py (kept in sync by tests)
// ---------------------------------------------------------------------------

export type GroupDirection = 'asc' | 'desc'

export type ConditionalOperator =
  | 'equals'
  | 'not-equals'
  | 'contains'
  | 'is-empty'
  | 'greater-than'
  | 'less-than'
  | 'between'

export type ColorKey =
  | 'red'
  | 'orange'
  | 'yellow'
  | 'green'
  | 'blue'
  | 'purple'
  | 'gray'
  | 'neutral'

export interface GroupByItem {
  field_id: string
  direction: GroupDirection
}

export interface ConditionalFormatRule {
  field_id: string
  operator: ConditionalOperator
  value: string
  color_key: ColorKey
  /** Default true for WCAG 1.4.1 — color alone is not enough. */
  bold: boolean
  /** Toggle rule without deleting it. Disabled rules never match. */
  enabled: boolean
}

/**
 * View config sub-shape consumed by U5. The full View.config is
 * `Record<string, unknown>` (other keys: filters / sort / hidden_fields);
 * U5 only owns `group_by` and `conditional_formatting`.
 */
export interface ViewConfigU5 {
  group_by?: GroupByItem[]
  conditional_formatting?: ConditionalFormatRule[]
}

/**
 * Aggregated values for a group's number-type fields.
 * `sum` / `avg` are omitted (undefined) for non-number fields or empty groups.
 */
export interface GroupAggregation {
  count: number
  sum?: number
  avg?: number
}

/**
 * A node in the grouping tree. Root-level nodes correspond to distinct
 * values of the first group_by field; their `children` correspond to the
 * second group_by field, and so on. Leaf nodes (depth === group_by.length)
 * hold the actual records.
 */
export interface GroupNode {
  /** Field value that defines this group (e.g. "已完成"). Empty string for null/undefined values. */
  key: string
  /** Field id of the group_by level this node represents. */
  fieldId: string
  /** Sort direction of this level (for label display / future sort-aware rendering). */
  direction: GroupDirection
  /** Depth in the tree (0 = root level). */
  depth: number
  /** Records belonging to this group (leaf-level) or all descendant records (intermediate). */
  records: IBitableRecord[]
  /** Child groups for the next group_by level. Empty for leaf nodes. */
  children: GroupNode[]
  /** Number-field aggregations for this group's records (SUM/AVG). Keyed by field id. */
  aggregations: Record<string, GroupAggregation>
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/** Max 3 group_by levels (matches Feishu / Twenty UX + backend MAX_GROUP_BY_FIELDS). */
export const MAX_GROUP_BY_FIELDS = 3

/** Color key → CSS var name. Background variant (paired with dark text). */
const COLOR_KEY_TO_BG_VAR: Record<ColorKey, string> = {
  red: 'var(--bitable-cf-red-bg)',
  orange: 'var(--bitable-cf-orange-bg)',
  yellow: 'var(--bitable-cf-yellow-bg)',
  green: 'var(--bitable-cf-green-bg)',
  blue: 'var(--bitable-cf-blue-bg)',
  purple: 'var(--bitable-cf-purple-bg)',
  gray: 'var(--bitable-cf-gray-bg)',
  neutral: 'var(--bitable-cf-neutral-bg)',
}

/** Color key → CSS var name. Foreground variant (text/border on light bg). */
const COLOR_KEY_TO_FG_VAR: Record<ColorKey, string> = {
  red: 'var(--bitable-cf-red-fg)',
  orange: 'var(--bitable-cf-orange-fg)',
  yellow: 'var(--bitable-cf-yellow-fg)',
  green: 'var(--bitable-cf-green-fg)',
  blue: 'var(--bitable-cf-blue-fg)',
  purple: 'var(--bitable-cf-purple-fg)',
  gray: 'var(--bitable-cf-gray-fg)',
  neutral: 'var(--bitable-cf-neutral-fg)',
}

/** All 8 color keys — used by the editor to render swatches. */
export const ALL_COLOR_KEYS: ColorKey[] = [
  'red',
  'orange',
  'yellow',
  'green',
  'blue',
  'purple',
  'gray',
  'neutral',
]

/** All 7 operators — used by the editor to populate the operator dropdown. */
export const ALL_OPERATORS: ConditionalOperator[] = [
  'equals',
  'not-equals',
  'contains',
  'is-empty',
  'greater-than',
  'less-than',
  'between',
]

/** Operator display labels (Chinese). */
export const OPERATOR_LABELS: Record<ConditionalOperator, string> = {
  equals: '等于',
  'not-equals': '不等于',
  contains: '包含',
  'is-empty': '为空',
  'greater-than': '大于',
  'less-than': '小于',
  between: '介于',
}

/** Color display labels (Chinese). */
export const COLOR_KEY_LABELS: Record<ColorKey, string> = {
  red: '红色',
  orange: '橙色',
  yellow: '黄色',
  green: '绿色',
  blue: '蓝色',
  purple: '紫色',
  gray: '灰色',
  neutral: '中性',
}

// ---------------------------------------------------------------------------
// Conditional format rule matching
// ---------------------------------------------------------------------------

/**
 * Map a color key to its CSS variable for cell background.
 *
 * Returns the empty string for unknown keys (defensive — the validator
 * rejects unknown keys at the API, but a stale local config might still
 * hold a retired key. Empty string falls back to default cell bg.)
 */
export function colorKeyToBgCssVar(colorKey: string): string {
  return COLOR_KEY_TO_BG_VAR[colorKey as ColorKey] ?? ''
}

/** Map a color key to its CSS variable for text/border (foreground variant). */
export function colorKeyToFgCssVar(colorKey: string): string {
  return COLOR_KEY_TO_FG_VAR[colorKey as ColorKey] ?? ''
}

/**
 * Compare a cell value against a single rule.
 *
 * Type coercion:
 *   - numbers (field_type === 'number' or runtime number) compared numerically
 *     for greater-than / less-than / between
 *   - everything else compared as strings
 *   - `is-empty` matches null / undefined / '' / [] (empty array for multiselect)
 *
 * `between` parses `value` as "min,max" (comma-separated). Whitespace tolerated.
 */
export function matchConditionalFormatRule(
  value: unknown,
  rule: ConditionalFormatRule,
): boolean {
  if (!rule.enabled) return false

  switch (rule.operator) {
    case 'is-empty':
      return isEmptyValue(value)

    case 'equals':
      return stringifyValue(value) === rule.value

    case 'not-equals':
      return stringifyValue(value) !== rule.value

    case 'contains':
      return stringifyValue(value).includes(rule.value)

    case 'greater-than':
      return compareNumeric(value, rule.value) > 0

    case 'less-than':
      return compareNumeric(value, rule.value) < 0

    case 'between': {
      const [minStr, maxStr] = parseBetweenRange(rule.value)
      if (minStr == null || maxStr == null) return false
      const min = Number(minStr)
      const max = Number(maxStr)
      const v = toNumber(value)
      if (v == null || !Number.isFinite(v)) return false
      return v >= min && v <= max
    }

    default:
      return false
  }
}

/**
 * Find the first matching rule for a cell value.
 *
 * Rules are evaluated in array order — first match wins (matches the
 * spec's "首条匹配 wins" contract and the user's reordering intent).
 *
 * Returns null if no rule matches or the rule list is empty.
 */
export function findFirstMatchingRule(
  value: unknown,
  rules: ConditionalFormatRule[] | undefined,
): ConditionalFormatRule | null {
  return rules?.find((rule) => matchConditionalFormatRule(value, rule)) ?? null
}

// ---------------------------------------------------------------------------
// Grouping tree construction
// ---------------------------------------------------------------------------

/**
 * Build the nested grouping tree from records.
 *
 * - Records are bucketed by their value of the first group_by field,
 *   then recursively by each subsequent field.
 * - Direction (asc/desc) is recorded on each node for label rendering;
 *   node ordering within a level follows direction (asc: empty-key last).
 * - Leaf nodes (depth === group_by.length) hold the actual records.
 * - Aggregations are computed for every node (count + sum/avg for number fields).
 *
 * Returns an empty array if group_by is empty or records is empty.
 */
export function computeGroupingLevels(
  records: IBitableRecord[],
  groupBy: GroupByItem[] | undefined,
  fields: IBitableField[] = [],
): GroupNode[] {
  if (!groupBy || groupBy.length === 0 || records.length === 0) return []

  const numberFieldIds = new Set(
    fields.filter((f) => f.field_type === 'number').map((f) => f.id),
  )

  return buildGroupLevel(records, groupBy, 0, numberFieldIds)
}

function buildGroupLevel(
  records: IBitableRecord[],
  groupBy: GroupByItem[],
  depth: number,
  numberFieldIds: Set<string>,
): GroupNode[] {
  const item = groupBy[depth]
  if (!item) {
    // Should not happen — leaf level is depth === groupBy.length, handled below.
    return []
  }

  // Bucket records by their value of this field.
  const buckets = new Map<string, IBitableRecord[]>()
  for (const rec of records) {
    const key = stringifyValue(rec.values[item.field_id])
    const existing = buckets.get(key)
    if (existing) {
      existing.push(rec)
    } else {
      buckets.set(key, [rec])
    }
  }

  // Build nodes, applying direction sort.
  const keys = Array.from(buckets.keys())
  sortGroupKeys(keys, item.direction)

  return keys.map((key) => {
    const groupRecords = buckets.get(key)!
    const isLeaf = depth + 1 === groupBy.length
    const children = isLeaf
      ? []
      : buildGroupLevel(groupRecords, groupBy, depth + 1, numberFieldIds)
    return {
      key,
      fieldId: item.field_id,
      direction: item.direction,
      depth,
      records: groupRecords,
      children,
      aggregations: aggregateGroup(groupRecords, numberFieldIds),
    }
  })
}

/**
 * Sort group keys within a level by direction.
 *
 * Ascending: numeric values first (sorted numerically), then strings
 * (sorted lexicographically), empty string last.
 * Descending: reverse of the above (empty string still last — empty is
 * always last regardless of direction, matches Feishu UX).
 */
function sortGroupKeys(keys: string[], direction: GroupDirection): void {
  const numericKeys: { value: number; key: string }[] = []
  const stringKeys: string[] = []
  for (const k of keys) {
    if (k === '') continue
    const n = Number(k)
    // Number.isFinite excludes NaN/Infinity; '' already skipped above.
    if (Number.isFinite(n)) {
      numericKeys.push({ value: n, key: k })
    } else {
      stringKeys.push(k)
    }
  }
  numericKeys.sort((a, b) => a.value - b.value)
  stringKeys.sort((a, b) => a.localeCompare(b))

  const ordered = [
    ...numericKeys.map((n) => n.key),
    ...stringKeys,
  ]
  // Empty-key group is always last (regardless of direction).
  if (keys.includes('')) ordered.push('')

  if (direction === 'desc') {
    // Reverse non-empty keys, keep empty at the end.
    const emptyTail = ordered[ordered.length - 1] === '' ? ordered.pop() : undefined
    ordered.reverse()
    if (emptyTail !== undefined) ordered.push(emptyTail)
  }

  // Mutate in place (caller passes the keys array).
  keys.length = 0
  keys.push(...ordered)
}

/**
 * Compute count + sum/avg for every number field in the group.
 *
 * Sum/avg are computed only for number-type fields (per the spec). Other
 * field types only contribute to count. Null/undefined values are skipped
 * in the numeric aggregation but still counted in the record count.
 */
export function aggregateGroup(
  records: IBitableRecord[],
  numberFieldIds: Set<string>,
): Record<string, GroupAggregation> {
  const result: Record<string, GroupAggregation> = {}
  if (records.length === 0) return result

  for (const fieldId of numberFieldIds) {
    let sum = 0
    let count = 0
    for (const rec of records) {
      const v = toNumber(rec.values[fieldId])
      if (v != null && Number.isFinite(v)) {
        sum += v
        count++
      }
    }
    if (count > 0) {
      result[fieldId] = {
        count: records.length,
        sum,
        avg: sum / count,
      }
    } else {
      result[fieldId] = { count: records.length }
    }
  }
  return result
}

// ---------------------------------------------------------------------------
// Config feature flags
// ---------------------------------------------------------------------------

export function isGroupingEnabled(config: ViewConfigU5 | undefined): boolean {
  return !!config && Array.isArray(config.group_by) && config.group_by.length > 0
}

export function isConditionalFormattingEnabled(
  config: ViewConfigU5 | undefined,
): boolean {
  return (
    !!config &&
    Array.isArray(config.conditional_formatting) &&
    config.conditional_formatting.length > 0
  )
}

// ---------------------------------------------------------------------------
// Coercion helpers (internal)
// ---------------------------------------------------------------------------

function isEmptyValue(v: unknown): boolean {
  if (v == null) return true
  if (typeof v === 'string') return v === ''
  if (Array.isArray(v)) return v.length === 0
  return false
}

function stringifyValue(v: unknown): string {
  if (v == null) return ''
  if (typeof v === 'string') return v
  if (typeof v === 'number' || typeof v === 'boolean') return String(v)
  if (Array.isArray(v)) return v.map(stringifyValue).join(', ')
  // ponytail: objects (e.g. attachment metadata) — JSON-stringify for equals/contains.
  // Ceiling: deep object equality is not supported; users should use is-empty for objects.
  try {
    return JSON.stringify(v)
  } catch {
    return String(v)
  }
}

function toNumber(v: unknown): number | null {
  if (typeof v === 'number') return Number.isFinite(v) ? v : null
  if (typeof v === 'string') {
    const trimmed = v.trim()
    if (trimmed === '') return null
    const n = Number(trimmed)
    return !Number.isNaN(n) && Number.isFinite(n) ? n : null
  }
  return null
}

function compareNumeric(value: unknown, ruleValue: string): number {
  const a = toNumber(value)
  const b = toNumber(ruleValue)
  if (a == null || b == null) return 0 // non-numeric → no match for gt/lt
  return a - b
}

/**
 * Parse a "min,max" range string for the `between` operator.
 *
 * Tolerates whitespace around the comma. Returns [null, null] if the
 * format is wrong or either side is non-numeric.
 */
function parseBetweenRange(value: string): [string | null, string | null] {
  const parts = value.split(',')
  if (parts.length !== 2) return [null, null]
  const minStr = parts[0].trim()
  const maxStr = parts[1].trim()
  if (minStr === '' || maxStr === '') return [null, null]
  if (Number.isNaN(Number(minStr)) || Number.isNaN(Number(maxStr))) return [null, null]
  return [minStr, maxStr]
}

// ---------------------------------------------------------------------------
// ponytail self-check: run a smoke check that the matcher + grouper work on
// a tiny fixture. Loaded eagerly on import in dev builds to catch logic
// regressions early. Trivial logic — no framework, no fixtures.
// ---------------------------------------------------------------------------

function _selfCheck(): void {
  // Conditional format — equals matches
  const r1 = matchConditionalFormatRule('done', {
    field_id: 'f1',
    operator: 'equals',
    value: 'done',
    color_key: 'green',
    bold: true,
    enabled: true,
  })
  if (!r1) throw new Error('groupingRulesUtils self-check: equals failed')

  // First-match-wins
  const rules: ConditionalFormatRule[] = [
    {
      field_id: 'f1',
      operator: 'equals',
      value: 'done',
      color_key: 'green',
      bold: true,
      enabled: true,
    },
    {
      field_id: 'f1',
      operator: 'equals',
      value: 'done',
      color_key: 'red',
      bold: true,
      enabled: true,
    },
  ]
  const first = findFirstMatchingRule('done', rules)
  if (first?.color_key !== 'green') {
    throw new Error('groupingRulesUtils self-check: first-match-wins failed')
  }

  // Disabled rule never matches
  const disabledMatch = matchConditionalFormatRule('done', {
    field_id: 'f1',
    operator: 'equals',
    value: 'done',
    color_key: 'green',
    bold: true,
    enabled: false,
  })
  if (disabledMatch) {
    throw new Error('groupingRulesUtils self-check: disabled rule matched')
  }

  // Color var mapping
  if (colorKeyToBgCssVar('red') !== 'var(--bitable-cf-red-bg)') {
    throw new Error('groupingRulesUtils self-check: colorKeyToBgCssVar failed')
  }
}

// Run the self-check once on module load. Wrapped in try/catch so a
// production failure doesn't crash the whole app — the error is logged.
if (typeof window !== 'undefined') {
  try {
    _selfCheck()
  } catch (e) {
    // eslint-disable-next-line no-console
    console.error('[groupingRulesUtils] self-check failed:', e)
  }
}
