import dayjs from 'dayjs'
import relativeTimeDayjs from 'dayjs/plugin/relativeTime'
import timezoneDayjs from 'dayjs/plugin/timezone'
import utcDayjs from 'dayjs/plugin/utc'
import { Parser } from 'expr-eval'
import { AP_FUNCTIONS } from './function-registry'

dayjs.extend(relativeTimeDayjs)
dayjs.extend(timezoneDayjs)
dayjs.extend(utcDayjs)

// expr-eval's published `Values` type is `Record<string, number>` but the
// runtime accepts arrays, null, and mixed-type objects fine. Widen the
// `evaluate` overload via module augmentation so consumers don't need
// `@ts-expect-error` or `as` casts. Must be `interface` (not `type`) — only
// interface declarations merge with the third-party class declaration.
declare module 'expr-eval' {
    // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
    interface Parser {
        evaluate(expression: string, values: Record<string, unknown>): unknown
    }
}

// Parser is a module-private singleton — exposing it would let any consumer
// of @activepieces/shared mutate `parser.functions.X` and break formula
// evaluation process-wide. Use `evaluateRaw` instead.
const parser = new Parser()

export function evaluateRaw(expression: string, vars: Record<string, unknown>): unknown {
    return parser.evaluate(expression, vars)
}

parser.functions.combine = (a: unknown, b: unknown, sep: unknown = '') =>
    `${a ?? ''}${String(sep)}${b ?? ''}`
parser.functions.uppercase = (s: unknown) => String(s ?? '').toUpperCase()
parser.functions.lowercase = (s: unknown) => String(s ?? '').toLowerCase()
parser.functions.titlecase = (s: unknown) =>
    String(s ?? '').replace(/\w\S*/g, (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
parser.functions.trim = (s: unknown) => String(s ?? '').trim()
parser.functions.prefix = (s: unknown, pfx: unknown) => `${String(pfx ?? '')}${String(s ?? '')}`
parser.functions.suffix = (s: unknown, sfx: unknown) => `${String(s ?? '')}${String(sfx ?? '')}`
parser.functions.replace = (s: unknown, from: unknown, to: unknown) =>
    String(s ?? '').split(String(from ?? '')).join(String(to ?? ''))
parser.functions.remove = (s: unknown, sub: unknown) =>
    String(s ?? '').split(String(sub ?? '')).join('')
parser.functions.first_n = (s: unknown, n: unknown) =>
    String(s ?? '').slice(0, Number(n))
parser.functions.last_n = (s: unknown, n: unknown) => {
    const str = String(s ?? '')
    const num = Number(n)
    return str.slice(Math.max(0, str.length - num))
}
parser.functions.truncate = (s: unknown, n: unknown) => {
    const str = String(s ?? '')
    const num = Number(n)
    return str.length > num ? str.slice(0, num) + '...' : str
}
parser.functions.split = (s: unknown, sep: unknown, idx: unknown) => {
    const parts = String(s ?? '').split(String(sep ?? ''))
    return parts[Number(idx)] ?? ''
}
parser.functions.extract_between = (s: unknown, start: unknown, end: unknown) => {
    const str = String(s ?? '')
    const startStr = String(start ?? '')
    const endStr = String(end ?? '')
    const si = str.indexOf(startStr)
    if (si === -1) return ''
    const ei = str.indexOf(endStr, si + startStr.length)
    if (ei === -1) return ''
    return str.slice(si + startStr.length, ei)
}
parser.functions.extract_email = (s: unknown) => {
    const match = String(s ?? '').match(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/)
    return match ? match[0] : ''
}
parser.functions.extract_url = (s: unknown) => {
    const match = String(s ?? '').match(/https?:\/\/[^\s]+/)
    return match ? match[0] : ''
}
parser.functions.length = (s: unknown) => String(s ?? '').length
parser.functions.contains = (s: unknown, sub: unknown) =>
    String(s ?? '').includes(String(sub ?? ''))
parser.functions.starts_with = (s: unknown, prefix: unknown) =>
    String(s ?? '').startsWith(String(prefix ?? ''))
parser.functions.ends_with = (s: unknown, suffix: unknown) =>
    String(s ?? '').endsWith(String(suffix ?? ''))
parser.functions.remove_spaces = (s: unknown) =>
    String(s ?? '').replace(/\s+/g, ' ').trim()
parser.functions.word_count = (s: unknown) =>
    String(s ?? '').trim().split(/\s+/).filter(Boolean).length
// Cap pad/repeat outputs so formulas can't OOM or hit V8's string-length
// ceiling (~2^29) by passing huge counts/lengths. 10k characters is plenty
// for every realistic use case; over that, behave like a no-op rather than
// throw a RangeError that would crash the flow step.
const MAX_PAD_OR_REPEAT_LENGTH = 10_000
parser.functions.pad_left = (s: unknown, len: unknown, char: unknown = ' ') => {
    const padChar = String(char ?? ' ')
    const target = Number(len)
    if (!Number.isFinite(target) || target > MAX_PAD_OR_REPEAT_LENGTH) return String(s ?? '')
    return String(s ?? '').padStart(target, padChar.length === 0 ? ' ' : padChar)
}
parser.functions.pad_right = (s: unknown, len: unknown, char: unknown = ' ') => {
    const padChar = String(char ?? ' ')
    const target = Number(len)
    if (!Number.isFinite(target) || target > MAX_PAD_OR_REPEAT_LENGTH) return String(s ?? '')
    return String(s ?? '').padEnd(target, padChar.length === 0 ? ' ' : padChar)
}
parser.functions.repeat = (s: unknown, count: unknown) => {
    const n = Math.floor(Number(count))
    if (!Number.isFinite(n) || n < 0) return ''
    if (n > MAX_PAD_OR_REPEAT_LENGTH) return ''
    return String(s ?? '').repeat(n)
}
parser.functions.reverse = (s: unknown) => [...String(s ?? '')].reverse().join('')
parser.functions.slug = (s: unknown) =>
    String(s ?? '')
        .toLowerCase()
        .normalize('NFKD')
        .replace(/[\u0300-\u036f]/g, '')
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '')

parser.functions.add = (a: unknown, b: unknown) => Number(a) + Number(b)
parser.functions.subtract = (a: unknown, b: unknown) => Number(a) - Number(b)
parser.functions.multiply = (a: unknown, b: unknown) => Number(a) * Number(b)
parser.functions.divide = (a: unknown, b: unknown) => {
    const divisor = Number(b)
    if (divisor === 0) throw new Error('Division by zero')
    return Number(a) / divisor
}
// expr-eval has a built-in 1-arg `round` that shadows our 2-arg version,
// so we alias to ap_round and rewrite in normalizeExpression
parser.functions.ap_round = (n: unknown, decimals: unknown = 0) =>
    Number(Number(n).toFixed(Number(decimals)))
parser.functions.round_up = (n: unknown) => Math.ceil(Number(n))
parser.functions.round_down = (n: unknown) => Math.floor(Number(n))
parser.functions.absolute = (n: unknown) => Math.abs(Number(n))
parser.functions.percentage = (n: unknown, total: unknown) => {
    const divisor = Number(total)
    if (divisor === 0) throw new Error('Division by zero')
    return (Number(n) / divisor) * 100
}
parser.functions.format_number = (n: unknown, decimals: unknown = 0) => {
    const d = Number(decimals)
    return Number(n).toLocaleString('en-US', {
        minimumFractionDigits: d,
        maximumFractionDigits: d,
    })
}
parser.functions.format_currency = (n: unknown, symbol: unknown = '$') =>
    `${String(symbol)}${Number(n).toFixed(2)}`
parser.functions.cents_to_dollars = (n: unknown) =>
    `$${(Number(n) / 100).toFixed(2)}`
parser.functions.min = (a: unknown, b: unknown) => Math.min(Number(a), Number(b))
parser.functions.max = (a: unknown, b: unknown) => Math.max(Number(a), Number(b))
parser.functions.to_number = (s: unknown) => Number(s)
parser.functions.random = () => Math.random()
parser.functions.random_int = (min: unknown, max: unknown) => {
    let lo = Number(min)
    let hi = Number(max)
    if (!Number.isFinite(lo) || !Number.isFinite(hi)) return NaN
    if (lo > hi) [lo, hi] = [hi, lo]
    return Math.floor(Math.random() * (Math.floor(hi) - Math.ceil(lo) + 1)) + Math.ceil(lo)
}
parser.functions.power = (base: unknown, exp: unknown) => Math.pow(Number(base), Number(exp))
parser.functions.sqrt = (n: unknown) => Math.sqrt(Number(n))
parser.functions.modulo = (a: unknown, b: unknown) => Number(a) % Number(b)
parser.functions.clamp = (v: unknown, lo: unknown, hi: unknown) =>
    Math.min(Math.max(Number(v), Number(lo)), Number(hi))
parser.functions.sign = (n: unknown) => {
    const s = Math.sign(Number(n))
    return Object.is(s, -0) ? 0 : s
}

parser.functions.format_date = (d: unknown, pattern: unknown = 'YYYY-MM-DD') => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.format(String(pattern)) : ''
}
parser.functions.format_date_long = (d: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    if (!parsed.isValid()) return ''
    return parsed.toDate().toLocaleDateString('en-US', {
        weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
    })
}
parser.functions.format_time = (d: unknown, pattern: unknown = 'HH:mm') => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.format(String(pattern)) : ''
}
parser.functions.relative_time = (d: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.fromNow() : ''
}
parser.functions.add_days = (d: unknown, n: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.add(Number(n), 'day').toISOString() : ''
}
parser.functions.subtract_days = (d: unknown, n: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.subtract(Number(n), 'day').toISOString() : ''
}
parser.functions.add_hours = (d: unknown, n: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.add(Number(n), 'hour').toISOString() : ''
}
parser.functions.days_between = (a: unknown, b: unknown) => {
    const da = dayjs(String(a ?? ''))
    const db = dayjs(String(b ?? ''))
    if (!da.isValid() || !db.isValid()) return ''
    return Math.round(Math.abs(db.diff(da, 'day', true)))
}
parser.functions.get_day = (d: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.date() : ''
}
parser.functions.get_month = (d: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.toDate().toLocaleDateString('en-US', { month: 'long' }) : ''
}
parser.functions.get_year = (d: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.year() : ''
}
parser.functions.get_day_of_week = (d: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.toDate().toLocaleDateString('en-US', { weekday: 'long' }) : ''
}
parser.functions.start_of_month = (d: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.startOf('month').toISOString() : ''
}
parser.functions.end_of_month = (d: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.endOf('month').toISOString() : ''
}
parser.functions.convert_timezone = (d: unknown, tz: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    if (!parsed.isValid()) return ''
    return parsed.tz(String(tz ?? 'UTC')).format()
}
parser.functions.now = () => new Date().toISOString()
parser.functions.today = () => dayjs().format('YYYY-MM-DD')
parser.functions.to_date = (d: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.toISOString() : ''
}
parser.functions.add_minutes = (d: unknown, n: unknown) => {
    const parsed = dayjs(String(d ?? ''))
    return parsed.isValid() ? parsed.add(Number(n), 'minute').toISOString() : ''
}
parser.functions.hours_between = (a: unknown, b: unknown) => {
    const da = dayjs(String(a ?? ''))
    const db = dayjs(String(b ?? ''))
    if (!da.isValid() || !db.isValid()) return ''
    // floor (not round): 8h 30min is "8 complete hours between", not 9.
    return Math.floor(Math.abs(db.diff(da, 'hour', true)))
}
// start_of_day / end_of_day / is_same_day snap to / compare against day
// boundaries, so they must use UTC parsing to be deterministic across server
// timezones. Without `.utc()`, a UTC+8 server would shift a Z-suffixed
// timestamp into local time first, then snap to local midnight, which both
// disagrees with the doc examples and makes flow output depend on host TZ.
parser.functions.start_of_day = (d: unknown) => {
    const parsed = dayjs.utc(String(d ?? ''))
    return parsed.isValid() ? parsed.startOf('day').toISOString() : ''
}
parser.functions.end_of_day = (d: unknown) => {
    const parsed = dayjs.utc(String(d ?? ''))
    return parsed.isValid() ? parsed.endOf('day').toISOString() : ''
}
parser.functions.is_before = (a: unknown, b: unknown) => {
    const da = dayjs(String(a ?? ''))
    const db = dayjs(String(b ?? ''))
    if (!da.isValid() || !db.isValid()) return false
    return da.isBefore(db)
}
parser.functions.is_after = (a: unknown, b: unknown) => {
    const da = dayjs(String(a ?? ''))
    const db = dayjs(String(b ?? ''))
    if (!da.isValid() || !db.isValid()) return false
    return da.isAfter(db)
}
parser.functions.is_same_day = (a: unknown, b: unknown) => {
    const da = dayjs.utc(String(a ?? ''))
    const db = dayjs.utc(String(b ?? ''))
    if (!da.isValid() || !db.isValid()) return false
    return da.isSame(db, 'day')
}

parser.functions.filter_list = (list: unknown, field: unknown, value: unknown) =>
    toArray(list).filter(
        (item) =>
            typeof item === 'object' &&
            item !== null &&
            // Loose equality is intentional: formula args arrive as strings from text
            // input, while item fields are typed (`{age: 25}`). `===` would silently
            // return zero matches when filtering numeric fields with string args.
            // Same rationale applies to is_equal, switch, if_null below.
            (item as Record<string, unknown>)[String(field)] == value,
    )
parser.functions.sort_list = (list: unknown, field: unknown, order: unknown = 'asc') => {
    const arr = [...toArray(list)]
    const fieldName = String(field)
    const ord = String(order)
    return arr.sort((a, b) => {
        const av = readField(a, fieldName)
        const bv = readField(b, fieldName)
        if (av == null && bv == null) return 0
        if (av == null) return 1
        if (bv == null) return -1
        const cmp = av < bv ? -1 : av > bv ? 1 : 0
        return ord === 'desc' ? -cmp : cmp
    })
}
parser.functions.pluck = (list: unknown, field: unknown) =>
    toArray(list).map((item) =>
        typeof item === 'object' && item !== null
            ? (item as Record<string, unknown>)[String(field)]
            : undefined,
    )
parser.functions.join_list = (list: unknown, sep: unknown = ',') =>
    toArray(list).join(String(sep))
parser.functions.first_item = (list: unknown) => toArray(list)[0]
parser.functions.last_item = (list: unknown) => {
    const arr = toArray(list)
    return arr[arr.length - 1]
}
parser.functions.item_at = (list: unknown, idx: unknown) =>
    toArray(list)[Number(idx)]
parser.functions.count = (list: unknown) => toArray(list).length
parser.functions.sum = (list: unknown, field: unknown) => {
    return toArray(list).reduce<number>((acc, item) => {
        const v =
            typeof item === 'object' && item !== null
                ? Number((item as Record<string, unknown>)[String(field)])
                : 0
        return acc + (isNaN(v) ? 0 : v)
    }, 0)
}
parser.functions.average = (list: unknown, field: unknown) => {
    const arr = toArray(list)
    if (!arr.length) return 0
    const total = arr.reduce<number>((acc, item) => {
        const v =
            typeof item === 'object' && item !== null
                ? Number((item as Record<string, unknown>)[String(field)])
                : 0
        return acc + (isNaN(v) ? 0 : v)
    }, 0)
    return total / arr.length
}
parser.functions.max_in_list = (list: unknown, field: unknown) => {
    const nums = toNumericFieldValues(list, field)
    return nums.length === 0 ? null : Math.max(...nums)
}
parser.functions.min_in_list = (list: unknown, field: unknown) => {
    const nums = toNumericFieldValues(list, field)
    return nums.length === 0 ? null : Math.min(...nums)
}
parser.functions.deduplicate = (list: unknown, field: unknown) => {
    const seen = new Set<unknown>()
    return toArray(list).filter((item) => {
        const key =
            typeof item === 'object' && item !== null
                ? (item as Record<string, unknown>)[String(field)]
                : item
        if (seen.has(key)) return false
        seen.add(key)
        return true
    })
}
parser.functions.flatten = (list: unknown) => toArray(list).flat()
parser.functions.split_text_to_list = (s: unknown, sep: unknown = ',') =>
    String(s ?? '').split(String(sep)).map((x) => x.trim())
parser.functions.reverse_list = (list: unknown) => [...toArray(list)].reverse()
parser.functions.contains_item = (list: unknown, value: unknown) =>
    // Loose equality consistent with filter_list — formula args arrive as strings
    // while list items may be typed.
    toArray(list).some((item) => item == value)

// `if` intentionally NOT registered as a JS function. Eager arg evaluation
// would break short-circuit semantics (e.g. `if(is_empty(x); "safe"; divide(x; 0))`
// would throw when x is empty). `rewriteLazyIf` transforms `if(c; a; b)` into
// expr-eval's lazy ternary `((c) ? (a) : (b))` before evaluation.
parser.functions.if_empty = (val: unknown, fallback: unknown) =>
    val === '' || val == null || val === 'undefined' ? fallback : val
parser.functions.if_null = (val: unknown, fallback: unknown) =>
    val == null || val === 'undefined' ? fallback : val
parser.functions.switch = (...args: unknown[]) => {
    const [val, ...pairs] = args
    for (let i = 0; i + 1 < pairs.length; i += 2) {
        if (pairs[i] == val) return pairs[i + 1]
    }
    return pairs.length % 2 === 1 ? pairs[pairs.length - 1] : ''
}
parser.functions.is_empty = (val: unknown) => val === '' || val == null
parser.functions.is_not_empty = (val: unknown) => val !== '' && val != null
parser.functions.is_equal = (a: unknown, b: unknown) => a == b
// and/or/not are reserved operators in expr-eval — register under prefixed names
// and normalizeExpression() rewrites them in the expression before evaluation
parser.functions.ap_and = (a: unknown, b: unknown) => Boolean(a) && Boolean(b)
parser.functions.ap_or = (a: unknown, b: unknown) => Boolean(a) || Boolean(b)
parser.functions.ap_not = (a: unknown) => !a
parser.functions.coalesce = (...args: unknown[]) =>
    args.find((a) => a !== '' && a != null) ?? ''
parser.functions.is_number = (v: unknown) => typeof v === 'number' && !Number.isNaN(v)
parser.functions.is_list = (v: unknown) => Array.isArray(v)

// After every impl is registered above, wrap any function whose registry entry
// declares `argCompatibility.defaultArgs` so older saved flows that were saved
// before a new arg was added keep working at runtime instead of throwing a
// "wrong number of arguments" error.
for (const fn of AP_FUNCTIONS) {
    const defaults = fn.argCompatibility?.defaultArgs
    if (!defaults || defaults.length === 0) continue
    const impl = parser.functions[fn.name] as ((...args: unknown[]) => unknown) | undefined
    if (!impl) continue
    parser.functions[fn.name] = (...args: unknown[]) => {
        const padded = [...args]
        for (let i = padded.length; i < fn.minArgs; i++) {
            padded.push(defaults[i - args.length] ?? defaults[defaults.length - 1])
        }
        return impl(...padded)
    }
}

function toArray(value: unknown): unknown[] {
    if (Array.isArray(value)) return value
    if (value == null) return []
    return [value]
}

function readField(item: unknown, field: string): unknown {
    if (item !== null && typeof item === 'object') {
        return (item as Record<string, unknown>)[field]
    }
    return undefined
}

function toNumericFieldValues(list: unknown, field: unknown): number[] {
    const fieldName = String(field)
    const result: number[] = []
    for (const item of toArray(list)) {
        const raw = readField(item, fieldName)
        if (raw == null) continue
        const num = Number(raw)
        if (Number.isFinite(num)) result.push(num)
    }
    return result
}

export const MONTH_NAMES = [
    'January', 'February', 'March', 'April', 'May', 'June',
    'July', 'August', 'September', 'October', 'November', 'December',
]

export const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
