import re
from dataclasses import dataclass

#
# The complete list of functions known to Excel can be found here:
# https://support.microsoft.com/en-us/office/excel-functions-alphabetical-b3944572-255d-4efb-bb96-c6d90033e188 
#

# Comprehensive list of dynamic array & modern functions introduced after Excel 2007
FUTURE_FUNCTIONS = {
    "ACOT", "ACOTH", "AGGREGATE", "ARABIC", "ARRAYTOTEXT", "BASE", "BETA.DIST", "BETA.INV",
    "BINOM.DIST", "BINOM.DIST.RANGE", "BINOM.INV", "BITAND", "BITLSHIFT", "BITOR", "BITRSHIFT",
    "BITXOR", "BYCOL", "BYROW", "CEILING.MATH", "CEILING.PRECISE", "CHISQ.DIST", "CHISQ.DIST.RT",
    "CHISQ.INV", "CHISQ.INV.RT", "CHISQ.TEST", "CHOOSECOLS", "CHOOSEROWS", "COMBINA", "CONCAT",
    "CONFIDENCE.NORM", "CONFIDENCE.T", "COPILOT", "COT", "COTH", "COVARIANCE.P", "COVARIANCE.S",
    "CSC", "CSCH", "DAYS", "DBCS", "DECIMAL", "DETECTLANGUAGE", "DROP", "ENCODEURL",
    "ERF.PRECISE", "ERFC.PRECISE", "EXPAND", "EXPON.DIST", "F.DIST", "F.DIST.RT", "F.INV",
    "F.INV.RT", "F.TEST", "FILTERXML", "FLOOR.MATH", "FLOOR.PRECISE", "FORECAST.ETS",
    "FORECAST.ETS.CONFINT", "FORECAST.ETS.SEASONALITY", "FORECAST.ETS.STAT", "FORECAST.LINEAR",
    "FORMULATEXT", "GAMMA", "GAMMA.DIST", "GAMMA.INV", "GAMMALN.PRECISE", "GAUSS", "GROUPBY",
    "HSTACK", "HYPGEOM.DIST", "IFNA", "IFS", "IMAGE", "IMCOSH", "IMCOT", "IMCSC", "IMCSCH",
    "IMSEC", "IMSECH", "IMSINH", "IMTAN", "ISFORMULA", "ISO.CEILING", "ISOMITTED", "ISOWEEKNUM",
    "LAMBDA", "LET", "LOGNORM.DIST", "LOGNORM.INV", "MAKEARRAY", "MAP", "MAXIFS", "MINIFS",
    "MODE.MULT", "MODE.SNGL", "MUNIT", "NEGBINOM.DIST", "NETWORKDAYS.INTL", "NORM.DIST",
    "NORM.INV", "NORM.S.DIST", "NORM.S.INV", "NUMBERVALUE", "PDURATION", "PERCENTILE.EXC",
    "PERCENTILE.INC", "PERCENTOF", "PERCENTRANK.EXC", "PERCENTRANK.INC", "PERMUTATIONA", "PHI",
    "PIVOTBY", "POISSON.DIST", "QUARTILE.EXC", "QUARTILE.INC", "RANDARRAY", "RANK.AVG",
    "RANK.EQ", "REDUCE", "REGEXEXTRACT", "REGEXREPLACE", "REGEXTEST", "RRI", "SCAN", "SEC",
    "SECH", "SEQUENCE", "SHEET", "SHEETS", "SINGLE", "SKEW.P", "SORTBY", "STDEV.P",
    "STDEV.S", "STOCKHISTORY", "SWITCH", "T.DIST", "T.DIST.2T", "T.DIST.RT", "T.INV",
    "T.INV.2T", "T.TEST", "TAKE", "TEXTAFTER", "TEXTBEFORE", "TEXTJOIN", "TEXTSPLIT", "TOCOL",
    "TOROW", "TRANSLATE", "TRIMRANGE", "UNICHAR", "UNICODE", "UNIQUE", "VALUETOTEXT", "VAR.P",
    "VAR.S", "VSTACK", "WEBSERVICE", "WEIBULL.DIST", "WORKDAY.INTL", "WRAPCOLS", "WRAPROWS",
    "XLOOKUP", "XMATCH", "XOR", "Z.TEST", "ANCHORARRAY"
}

# Functions that require the _xlfn._xlws. prefix (worksheet-scoped future functions)
# Source: https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xlsx/
WORKSHEET_FUNCTIONS = {
    "FILTER", "SORT", "PY"
}

# General native legacy functions. If they aren't executed via '()', they are evaluated as _xleta.
STANDARD_FUNCTIONS = {
    # Core math & trig
    "ABS", "ACOS", "ACOSH", "ASIN", "ASINH", "ATAN", "ATAN2", "ATANH", "CEILING", "COMBIN",
    "COS", "COSH", "DEGREES", "EVEN", "EXP", "FACT", "FACTDOUBLE", "GCD", "INT", "LCM",
    "LN", "LOG", "LOG10", "MDETERM", "MINVERSE", "MMULT", "MOD", "MROUND", "MULTINOMIAL",
    "ODD", "PI", "POWER", "PRODUCT", "QUOTIENT", "RADIANS", "RAND", "RANDBETWEEN", "ROMAN",
    "ROUND", "ROUNDDOWN", "ROUNDUP", "SERIESSUM", "SIGN", "SIN", "SINH", "SQRT", "SQRTPI",
    "SUM", "SUMIF", "SUMIFS", "SUMPRODUCT", "SUMSQ", "SUMX2MY2", "SUMX2PY2", "SUMXMY2",
    "TAN", "TANH", "TRUNC",
    # Statistical
    "AVEDEV", "AVERAGE", "AVERAGEA", "AVERAGEIF", "AVERAGEIFS", "CORREL", "COUNT", "COUNTA",
    "COUNTBLANK", "COUNTIF", "COUNTIFS", "DEVSQ", "FISHER", "FISHERINV", "FREQUENCY",
    "GEOMEAN", "GROWTH", "HARMEAN", "INTERCEPT", "KURT", "LARGE", "LINEST", "LOGEST",
    "MAX", "MAXA", "MEDIAN", "MIN", "MINA", "NORMINV", "PEARSON", "PERCENTILE", "PERCENTRANK",
    "PERMUT", "PROB", "QUARTILE", "RANK", "RSQ", "SKEW", "SLOPE", "SMALL", "STANDARDIZE",
    "STDEVA", "STDEVP", "STDEVPA", "STEYX", "TREND", "TRIMMEAN", "VAR", "VARA", "VARP",
    "VARPA",
    # Logical
    "AND", "FALSE", "IF", "IFERROR", "NOT", "OR", "TRUE",
    # Text
    "ASC", "BAHTTEXT", "CHAR", "CLEAN", "CODE", "CONCATENATE", "DOLLAR", "EXACT", "FIND",
    "FIXED", "JIS", "LEFT", "LEN", "LOWER", "MID", "PHONETIC", "PROPER", "REPLACE", "REPT",
    "RIGHT", "SEARCH", "SUBSTITUTE", "T", "TEXT", "TRIM", "UPPER", "VALUE",
    # Date & time
    "DATE", "DATEDIF", "DATEVALUE", "DAY", "DAYS360", "EDATE", "EOMONTH", "HOUR", "MINUTE",
    "MONTH", "NETWORKDAYS", "NOW", "SECOND", "TIME", "TIMEVALUE", "TODAY", "WEEKDAY",
    "WEEKNUM", "WORKDAY", "YEAR", "YEARFRAC",
    # Lookup & reference
    "ADDRESS", "AREAS", "CHOOSE", "COLUMN", "COLUMNS", "GETPIVOTDATA", "HLOOKUP", "HYPERLINK",
    "INDEX", "INDIRECT", "LOOKUP", "MATCH", "OFFSET", "ROW", "ROWS", "RTD", "TRANSPOSE",
    "VLOOKUP",
    # Information
    "CELL", "ERROR.TYPE", "INFO", "ISBLANK", "ISERR", "ISERROR", "ISEVEN", "ISLOGICAL",
    "ISNA", "ISNONTEXT", "ISNUMBER", "ISODD", "ISREF", "ISTEXT", "N", "NA", "TYPE",
    # Financial
    "ACCRINT", "ACCRINTM", "AMORDEGRC", "AMORLINC", "COUPDAYBS", "COUPDAYS", "COUPDAYSNC",
    "COUPNCD", "COUPNUM", "COUPPCD", "CUMIPMT", "CUMPRINC", "DB", "DDB", "DISC", "DOLLARDE",
    "DOLLARFR", "DURATION", "EFFECT", "FV", "FVSCHEDULE", "INTRATE", "IPMT", "IRR", "ISPMT",
    "MDURATION", "MIRR", "NOMINAL", "NPER", "NPV", "ODDFPRICE", "ODDFYIELD", "ODDLPRICE",
    "ODDLYIELD", "PMT", "PPMT", "PRICE", "PRICEDISC", "PRICEMAT", "PV", "RATE", "RECEIVED",
    "SLN", "SYD", "TBILLEQ", "TBILLPRICE", "TBILLYIELD", "VDB", "XIRR", "XNPV", "YIELD",
    "YIELDDISC", "YIELDMAT",
    # Engineering
    "BESSELI", "BESSELJ", "BESSELK", "BESSELY", "BIN2DEC", "BIN2HEX", "BIN2OCT", "COMPLEX",
    "CONVERT", "DEC2BIN", "DEC2HEX", "DEC2OCT", "DELTA", "ERF", "ERFC", "GESTEP", "HEX2BIN",
    "HEX2DEC", "HEX2OCT", "IMABS", "IMAGINARY", "IMARGUMENT", "IMCONJUGATE", "IMCOS",
    "IMDIV", "IMEXP", "IMLN", "IMLOG10", "IMLOG2", "IMPOWER", "IMPRODUCT", "IMREAL",
    "IMSIN", "IMSQRT", "IMSUB", "IMSUM", "OCT2BIN", "OCT2DEC", "OCT2HEX",
    # Database
    "DAVERAGE", "DCOUNT", "DCOUNTA", "DGET", "DMAX", "DMIN", "DPRODUCT", "DSTDEV",
    "DSTDEVP", "DSUM", "DVAR", "DVARP",
    # Cube
    "CUBEKPIMEMBER", "CUBEMEMBER", "CUBEMEMBERPROPERTY", "CUBERANKEDMEMBER", "CUBESET",
    "CUBESETCOUNT", "CUBEVALUE",
    # Subtotal / misc
    "SUBTOTAL",
}

KNOWN_FUNCTIONS = FUTURE_FUNCTIONS.union(STANDARD_FUNCTIONS).union(WORKSHEET_FUNCTIONS)

# Regex matching an external workbook reference embedded in an identifier:
# Group 1: base_url_or_path (optional path/url prefix before the last /)
# Group 2: file_name (e.g. "Data.xlsx")
# Group 3: sheet_name (e.g. "Sheet1")
_EXT_WB_IN_BRACKETS = re.compile(
    r"^(?:\[([^\]]+)\]([A-Za-z0-9_ ]+)!|'(?:.*[/\\])?\[([^\]]+)\]([A-Za-z0-9_ ]+)'!)")

# Lexer generator built from BNF definitions
TOKEN_REGEX = re.compile(
    r'(?P<STRING>"(?:""|[^"])*")|'
    r'(?P<NUMBER>(?:\d+\.\d+|\.\d+|\d+)(?:[eE][+-]?\d+)?)|'
    r"(?P<IDENTIFIER>(?:(?:'[^']+'|(?:\[[^\]]+\])?[A-Za-z_][A-Za-z0-9_.]*)!)?\$?[A-Za-z_\\][A-Za-z0-9_\.\$\\]*)|"
    r'(?P<OP_POSTFIX>#)|'
    r'(?P<LPAREN>\()|'
    r'(?P<RPAREN>\))|'
    r'(?P<COMMA>,)|'
    r'(?P<ASSIGN>=)|'
    r'(?P<OP_BIN>[+\-*/^<>:&]+)|'
    r'(?P<WS>[ \t\n\r]+)|'
    r'(?P<MISC>.)'
)

@dataclass
class ExternalWorkbook:
    id: int
    file_name: str
    base_url_or_path: str | None
    

class FormulaCompiler:
    def __init__(self, text, external_workbooks: list[ExternalWorkbook] | None = None):
        self.tokens = [(m.lastgroup, m.group(m.lastgroup)) for m in TOKEN_REGEX.finditer(text)]
        self.pos = 0
        # Each entry: {'name': str, 'locals': set[str], 'arg_index': int}
        # Tracks scope for LET and LAMBDA to emit _xlpm. prefixes.
        self.call_stack: list[dict] = []
        # List of external workbooks that have been referenced in the formula.
        self.external_workbooks: list[ExternalWorkbook] = external_workbooks or []

    def _resolve_external_workbook(self, file_name: str, base_url_or_path: str | None) -> ExternalWorkbook:
        """Resolves an external workbook to an ExternalWorkbook object. If the external workbook doest not exist yet, it will be created."""
        max_id = 0
        for external_workbook in self.external_workbooks:
            if base_url_or_path is None and external_workbook.file_name == file_name:
                return external_workbook
            if external_workbook.base_url_or_path == base_url_or_path and external_workbook.file_name == file_name:
                return external_workbook
            max_id = max(max_id, external_workbook.id)
        external_workbook = ExternalWorkbook(max_id + 1, file_name, base_url_or_path)
        self.external_workbooks.append(external_workbook)
        return external_workbook


    def peek(self, skip_ws=True):
        """Looks ahead without advancing the stream."""
        p = self.pos
        while p < len(self.tokens):
            if skip_ws and self.tokens[p][0] == 'WS':
                p += 1
            else:
                return self.tokens[p]
        return ('EOF', '')

    def get_ws(self):
        """Extracts exact whitespace streams to maintain code formatting rules."""
        ws = ""
        while self.pos < len(self.tokens) and self.tokens[self.pos][0] == 'WS':
            ws += self.tokens[self.pos][1]
            self.pos += 1
        return ws

    def advance(self):
        t = self.tokens[self.pos]
        self.pos += 1
        return t

    def parse_all(self):
        res = self.get_ws()
        # Internal OOXML formulas generally drop the leading '=' assignment operator
        if self.peek(False)[0] == 'ASSIGN':
            self.advance()
            res += self.get_ws()

        res += self.parse_expr()
        return res

    def parse_expr(self, array_depth: int = 0):
        """Parse an expression.

        array_depth > 0 means we are inside a {} array literal.  In that context,
        commas are array-element separators (passed through verbatim) rather than
        argument separators (which terminate expression parsing).  This keeps the
        LET arg_index count accurate across arguments whose values are array literals
        like {1} or {1,2}.
        """
        res = ""
        while True:
            ws = self.get_ws()
            res += ws

            tok = self.peek(skip_ws=False)
            if tok[0] in ('EOF', 'RPAREN'):
                break
            # Array-brace close — exit so the caller can consume '}'
            if tok[0] == 'MISC' and tok[1] == '}':
                break
            # A comma at the top-level (array_depth == 0) is an argument separator — stop here.
            # A comma deeper inside a {} literal is an element separator — pass through.
            if tok[0] == 'COMMA':
                if array_depth == 0:
                    break
                # Inside array literal: emit the comma and continue
                res += self.advance()[1]
                continue

            if tok[0] == 'MISC' and tok[1] == '@':
                # Implicit intersection: @<expr> → _xlfn.SINGLE(<expr>)
                self.advance()  # consume '@'
                inner_ws = self.get_ws()
                # Use parse_expr so we capture full range expressions like B1:B10
                inner = self.parse_expr()
                res += f"_xlfn.SINGLE({inner_ws}{inner})"
            elif tok[0] == 'MISC' and tok[1] == '{':
                # Opening brace — enter array literal context
                res += self.advance()[1]
                res += self.parse_expr(array_depth + 1)
                if self.peek(False)[1] == '}':
                    res += self.advance()[1]
            elif tok[0] in ('IDENTIFIER', 'LPAREN', 'NUMBER', 'STRING'):
                res += self.parse_primary()
            elif tok[0] in ('OP_BIN', 'ASSIGN', 'MISC', 'OP_POSTFIX'):
                res += self.advance()[1]
            else:
                res += self.advance()[1]
        return res

    # ------------------------------------------------------------------
    # Helpers for LET / LAMBDA parameter (_xlpm.) tracking
    # ------------------------------------------------------------------

    def _is_let_declaration(self) -> bool:
        """True when the current identifier is a variable *name* in LET/LAMBDA.

        LET(name1, value1, name2, value2, …, body)
          → even arg-index positions (0, 2, 4, …) are name declarations.
        LAMBDA(param1, param2, …, body)
          → we reuse the same even-index heuristic (params come in odd count
            before the body, but arg_index 0 is always a param slot).
        """
        if not self.call_stack:
            return False
        top = self.call_stack[-1]
        return top['name'] in ('LET', 'LAMBDA') and top['arg_index'] % 2 == 0

    def _resolve_ident(self, ident: str) -> str:
        """Return _xlpm.<ident> if it was declared as a local in an enclosing scope."""
        for scope in reversed(self.call_stack):
            if ident in scope['locals']:
                return f"_xlpm.{ident}"
        return ident

    # ------------------------------------------------------------------

    def parse_primary(self):
        tok = self.peek(False)

        # 1. Parenthesised sub-expression
        if tok[0] == 'LPAREN':
            res = self.advance()[1]  # Consume '('
            res += self.parse_expr()
            if self.peek(False)[0] == 'RPAREN':
                res += self.advance()[1]

        # 2. Literals evaluate uninterrupted
        elif tok[0] in ('NUMBER', 'STRING'):
            res = self.advance()[1]

        # 3. Contextual evaluation of Identifiers, Methods, and ETA / _xlpm lambdas
        elif tok[0] == 'IDENTIFIER':
            ident = self.advance()[1]
            next_tok = self.peek(True)

            # Check for external workbook reference patterns:
            #   [File.xlsx]Sheet!cell  or  'path/[File.xlsx]Sheet'!cell
            m_ext = _EXT_WB_IN_BRACKETS.match(ident)
            if m_ext:
                # Groups: (file_bare, sheet_bare) or (file_quoted, sheet_quoted)
                file_name = m_ext.group(1) or m_ext.group(3)
                sheet_name = m_ext.group(2) or m_ext.group(4)
                cell_ref = ident[m_ext.end():]  # the part after the '!'
                ext_wb = self._resolve_external_workbook(file_name, None)
                # Emit [N]Sheet!cell or '[N]Sheet'!cell if sheet has spaces
                if ' ' in sheet_name:
                    res = f"'[{ext_wb.id}]{sheet_name}'!{cell_ref}"
                else:
                    res = f"[{ext_wb.id}]{sheet_name}!{cell_ref}"
                return res

            # Avoid Eta tagging external sheet calls (e.g. Sheet1!SUM)
            has_prefix = '!' in ident
            base_ident = ident.split('!')[-1].upper()

            if next_tok[0] == 'LPAREN':
                # ---- Function call ----
                if not has_prefix and base_ident in WORKSHEET_FUNCTIONS:
                    res = f"_xlfn._xlws.{ident}"
                elif not has_prefix and base_ident in FUTURE_FUNCTIONS:
                    res = f"_xlfn.{ident}"
                else:
                    res = ident
                res += self.get_ws()
                res += self.advance()[1]  # Consume attached '('

                # Push a new scope for LET/LAMBDA to track declared locals
                is_scoped = base_ident in ('LET', 'LAMBDA')
                if is_scoped:
                    self.call_stack.append({'name': base_ident, 'locals': set(), 'arg_index': 0})

                # Consume comma-separated arguments recursively
                while True:
                    res += self.parse_expr()  # each arg resets to array_depth=0
                    t = self.peek(False)
                    if t[0] == 'COMMA':
                        if is_scoped:
                            self.call_stack[-1]['arg_index'] += 1
                        res += self.advance()[1]
                    elif t[0] == 'RPAREN':
                        res += self.advance()[1]
                        break
                    else:
                        break

                if is_scoped:
                    self.call_stack.pop()
            else:
                # ---- Bare identifier (not a call) ----
                if not has_prefix and base_ident in KNOWN_FUNCTIONS:
                    # Standard or future function used as a lambda (eta form) → _xleta.
                    res = f"_xleta.{ident}"
                elif self.call_stack and self._is_let_declaration():
                    # Declaring a new LET/LAMBDA parameter name → _xlpm.
                    self.call_stack[-1]['locals'].add(ident)
                    res = f"_xlpm.{ident}"
                else:
                    # Use of a previously declared local variable → _xlpm. (if in scope)
                    res = self._resolve_ident(ident)
        else:
            return self.advance()[1]

        # 4. Postfix spill operator: `foo#` → `_xlfn.ANCHORARRAY(foo)`
        ws_post = self.get_ws()
        if self.peek(False)[0] == 'OP_POSTFIX' and self.peek(False)[1] == '#':
            self.advance()
            res = f"_xlfn.ANCHORARRAY({res}{ws_post})"
        else:
            res += ws_post

        return res


def compile_formula(formula: str) -> str:
    """Entry point taking a surface-level formula to return an internal OOXML equivalent."""
    return FormulaCompiler(formula).parse_all()

