import {template,type Cell} from './template-data.ts'; export type Overrides=Record; export class FormulaError extends Error{} type Node={kind:'number';n:number}|{kind:'ref';sheet?:string;cell:string}|{kind:'range';a:Node;b:Node}|{kind:'unary';op:string;a:Node}|{kind:'op';op:string;a:Node;b:Node}|{kind:'call';name:string;args:Node[]}; const trees=new Map(); function parse(formula:string):Node{ if(trees.has(formula))return trees.get(formula)!; const tokens=formula.slice(1).match(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+|(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][+-]?\d+)?|[A-Z][A-Z0-9_.]*|[()+\-*/^,:]/g)??[]; if(tokens.join('').replace(/\s+/g,'')!==formula.slice(1).replace(/\s+/g,''))throw new FormulaError('지원하지 않는 수식: '+formula); let i=0; const expression=(min=0):Node=>{let a=atom();while(i{const t=tokens[i++];if(!t)throw new FormulaError('수식 끝 오류'); if(t==='+'||t==='-')return {kind:'unary',op:t,a:atom()}; if(t==='('){const a=expression();if(tokens[i++]!==')')throw new FormulaError('괄호 오류');return a;} if(/^\d|^\./.test(t))return {kind:'number',n:Number(t)}; if(t.includes('!')||/^\$?[A-Z]+\$?\d+$/.test(t)){const split=t.lastIndexOf('!');let a:Node={kind:'ref',sheet:split>=0?t.slice(1,split-1).replace(/''/g,"'"):undefined,cell:t.slice(split+1).replace(/\$/g,'')};if(tokens[i]===':'){i++;a={kind:'range',a,b:atom()};}return a;} if(tokens[i++]!=='(')throw new FormulaError('함수 오류: '+t);const args:Node[]=[];if(tokens[i]!==')'){args.push(expression());while(tokens[i]===','){i++;args.push(expression());}} if(tokens[i++]!==')')throw new FormulaError('함수 괄호 오류: '+t);return {kind:'call',name:t,args}; }; const result=expression();if(i!==tokens.length)throw new FormulaError('수식 토큰 잔여: '+formula);trees.set(formula,result);return result; } const col=(s:string)=>[...s].reduce((n,c)=>n*26+c.charCodeAt(0)-64,0); const letters=(v:number):string=>v?letters(Math.floor((v-1)/26))+String.fromCharCode(65+(v-1)%26):''; export const cellKey=(sheet:string,cell:string)=>sheet+'!'+cell; export class FormulaBook{ cache=new Map();active=new Set(); constructor(public overrides:Overrides={},public data:Record>=template,public formulas:Record={}){} cell(sheet:string,cell:string):number|string{const k=cellKey(sheet,cell);if(this.cache.has(k))return this.cache.get(k)!;if(this.active.has(k))throw new FormulaError('순환참조 '+k);this.active.add(k); try{const raw=Object.hasOwn(this.overrides,k)?this.overrides[k]:this.formulas[k]??this.data[sheet]?.[cell]?.value;if(raw===null)throw new FormulaError('필수입력 누락 '+k);const value=typeof raw==='string'&&raw.startsWith('=')&&raw.length>1?this.evaluate(parse(raw),sheet):raw??0; if(typeof value==='number'&&!Number.isFinite(value))throw new FormulaError('정의역/0 나누기 '+k);const result=typeof value==='boolean'?Number(value):value as number|string;this.cache.set(k,result);return result; }finally{this.active.delete(k);}} private numeric(v:unknown):number{const n=typeof v==='number'?v:Number(v);if(!Number.isFinite(n))throw new FormulaError('숫자 변환 불가 '+v);return n;} private evaluate(n:Node,sheet:string):number|number[]{ if(n.kind==='number')return n.n; if(n.kind==='ref')return this.numeric(this.cell(n.sheet??sheet,n.cell)); if(n.kind==='range'){if(n.a.kind!=='ref'||n.b.kind!=='ref')throw new FormulaError('범위 오류');const a=n.a.cell.match(/([A-Z]+)(\d+)/)!,b=n.b.cell.match(/([A-Z]+)(\d+)/)!;const out:number[]=[];for(let r=+a[2];r<=+b[2];r++)for(let c=col(a[1]);c<=col(b[1]);c++)out.push(this.numeric(this.cell(n.a.sheet??sheet,letters(c)+r)));return out;} if(n.kind==='unary')return (n.op==='-'?-1:1)*this.numeric(this.evaluate(n.a,sheet)); if(n.kind==='op'){const a=this.numeric(this.evaluate(n.a,sheet)),b=this.numeric(this.evaluate(n.b,sheet));return n.op==='+'?a+b:n.op==='-'?a-b:n.op==='*'?a*b:n.op==='/'?a/b:a**b;} const args=n.args.flatMap(a=>this.evaluate(a,sheet));const f:Recordnumber>={SUM:()=>args.reduce((a,b)=>a+b,0),MAX:()=>Math.max(...args),MIN:()=>Math.min(...args),SQRT:()=>Math.sqrt(args[0]),PI:()=>Math.PI,ASIN:()=>Math.asin(args[0]),SIN:()=>Math.sin(args[0])};if(!f[n.name])throw new FormulaError('지원하지 않는 함수 '+n.name);return f[n.name](); } substitution(sheet:string,formula:string):string{return formula.replace(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g,t=>{const p=t.lastIndexOf('!');try{return String(this.cell(p>=0?t.slice(1,p-1).replace(/''/g,"'"):sheet,t.slice(p+1).replace(/\$/g,'')));}catch{return '미입력';}});} references(sheet:string,cell:string):string[]{const result=new Set(),visiting=new Set();const walk=(s:string,c:string)=>{const key=cellKey(s,c);if(visiting.has(key))return;visiting.add(key);result.add(key);if(Object.hasOwn(this.overrides,key))return;const f=this.formulas[key]??this.data[s]?.[c]?.value;if(typeof f!=='string'||(!f.startsWith('=')||f.length===1))return; for(const t of f.slice(1).match(/'(?:[^']|'')+'!\$?[A-Z]+\$?\d+|\$?[A-Z]+\$?\d+/g)??[]){const p=t.lastIndexOf('!');walk(p>=0?t.slice(1,p-1).replace(/''/g,"'"):s,t.slice(p+1).replace(/\$/g,''));}};walk(sheet,cell);return [...result];} }