/* Pure accounting and calendar rules shared by Portfolio and DCA. */
(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.LwTools=api;})(typeof globalThis!=='undefined'?globalThis:this,()=>{
'use strict';
const DAY=86400000;
function date(value){if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(value)||!Number.isFinite(Date.parse(value+'T00:00:00Z'))||new Date(value+'T00:00:00Z').toISOString().slice(0,10)!==value)throw Error('Enter a valid calendar date.');return value;}
function number(value,label,max=1e12){if(!['string','number'].includes(typeof value)||String(value).trim()===''||!Number.isFinite(Number(value))||Number(value)<0||Number(value)>max)throw Error('Invalid '+label+'.');return Number(value);}
function transaction(row,index=0,today=new Date().toISOString().slice(0,10)){
 if(!row||typeof row!=='object')throw Error('Invalid transaction.');
 const amount=number(row.amount,'LTC amount',84000000),units=Math.round(amount*1e8);
 if(units<1||Math.abs(amount-units/1e8)>Number.EPSILON*Math.max(1,amount)*2)throw Error('LTC amounts must be positive, with at most 8 decimals.');
 const price=number(row.price??row.buyPrice,'USD price'),fee=number(row.fee??0,'USD fee'),type=row.type??'buy';
 if(!['buy','sell'].includes(type))throw Error('Transaction type must be buy or sell.');
 if(date(row.date)>today)throw Error('Future transactions are not supported.');
 if(typeof(row.notes??'')!=='string'||(row.notes||'').length>2000)throw Error('Notes must contain at most 2,000 characters.');
 return {id:String(row.id??'legacy-'+index),type,amount,price,fee,date:row.date,notes:row.notes||''};
}
function ledger(input,currentPrice=null){
 if(!Array.isArray(input)||input.length>5000)throw Error('Use a maximum of 5,000 transactions.');
 const rows=input.map((r,i)=>({...transaction(r,i),order:i})).sort((a,b)=>a.date.localeCompare(b.date)||a.order-b.order);
 if(new Set(rows.map(r=>r.id)).size!==rows.length)throw Error('Duplicate transaction identifiers.');
 let units=0,basis=0,realized=0,fees=0,purchases=0,proceeds=0;
 const history=[];
 for(const row of rows){
  const u=Math.round(row.amount*1e8),gross=row.amount*row.price;fees+=row.fee;let rowRealized=null,released=0;
  if(row.type==='buy'){units+=u;basis+=gross+row.fee;purchases+=gross+row.fee;}
  else{
   if(u>units)throw Error('Sale on '+row.date+' exceeds the LTC balance available on that date.');
   if(row.fee>gross)throw Error('A sale fee cannot exceed its proceeds.');
   released=basis*u/units;rowRealized=gross-row.fee-released;realized+=rowRealized;proceeds+=gross-row.fee;units-=u;basis-=released;
   if(!units)basis=0;
  }
  if(!Number.isSafeInteger(units)||units>84000000*1e8)throw Error('LTC balance is outside the supported range.');
  history.push({...row,balance:units/1e8,costBasis:released,realized:rowRealized,netCash:row.type==='buy'?-(gross+row.fee):gross-row.fee});
 }
 const amount=units/1e8,known=Number.isFinite(currentPrice)&&currentPrice>0,value=known?amount*currentPrice:null,unrealized=known?value-basis:null;
 return {amount,basis,average:amount?basis/amount:null,realized,unrealized,value,total:known?realized+unrealized:null,fees,purchases,proceeds,history};
}
const fingerprint=r=>JSON.stringify([r.type,r.date,r.amount,r.price,r.fee,r.notes]);
function merge(existing,incoming){
 const count=new Map();existing.map((r,i)=>transaction(r,i)).forEach(r=>count.set(fingerprint(r),(count.get(fingerprint(r))||0)+1));const added=[];let duplicates=0;
 incoming.map((r,i)=>transaction(r,i)).forEach(r=>{const k=fingerprint(r),n=count.get(k)||0;if(n){count.set(k,n-1);duplicates++;}else added.push(r);});
 return {added,duplicates};
}
function schedule(start,end,frequency){
 date(start);date(end);if(start>end)throw Error('Start date must be on or before the end date.');if(!['weekly','monthly'].includes(frequency))throw Error('Unknown purchase frequency.');
 const base=new Date(start+'T00:00:00Z'),result=[];
 for(let n=0;n<2000;n++){
  let d;if(frequency==='weekly')d=new Date(base.getTime()+n*7*DAY);
  else{const y=base.getUTCFullYear(),m=base.getUTCMonth()+n,last=new Date(Date.UTC(y,m+1,0)).getUTCDate();d=new Date(Date.UTC(y,m,Math.min(base.getUTCDate(),last)));}
  const key=d.toISOString().slice(0,10);if(key>end)return result;result.push(key);
 }throw Error('Selected period is too long.');
}
function history(input){
 if(!Array.isArray(input))throw Error('Invalid historical data.');const map=new Map();
 for(const row of input){const key=date(row.date),price=number(row.close,'historical close');if(price<=0)throw Error('Historical prices must be positive.');if(map.has(key))throw Error('Duplicate historical date.');map.set(key,price);}
 return map;
}
function simulate(input,{amount,frequency,start,end,fee=0,fixed=0,single=false}){
 amount=number(amount,'purchase budget');fee=number(fee,'percentage fee',99.999);fixed=number(fixed,'fixed fee');
 if(amount<=0||fixed>=amount)throw Error('Budget must be positive and exceed the fixed fee.');
 date(start);date(end);if(start>end)throw Error('Start date must be on or before the end date.');
 const prices=history(input),dates=single?[start]:schedule(start,end,frequency),missing=[...new Set([...dates,end].filter(d=>!prices.has(d)))];
 if(missing.length)throw Error('Missing daily close for '+missing.slice(0,4).join(', ')+(missing.length>4?' and '+(missing.length-4)+' more dates.':'')+' No prices were substituted.');
 let ltc=0,totalFees=0;const rows=dates.map((d,i)=>{const cost=fixed+(amount-fixed)*fee/100,bought=(amount-cost)/prices.get(d);ltc+=bought;totalFees+=cost;return {date:d,price:prices.get(d),fee:cost,bought,ltc,invested:(i+1)*amount,value:ltc*prices.get(d)};});
 const total=amount*rows.length,close=prices.get(end),value=ltc*close,lumpFee=fixed+(total-fixed)*fee/100,lumpLtc=(total-lumpFee)/prices.get(start),lumpValue=lumpLtc*close;
 return {rows,total,ltc,totalFees,value,pnl:value-total,returnPct:(value/total-1)*100,average:total/ltc,close,end,lumpFee,lumpLtc,lumpValue,lumpPnl:lumpValue-total};
}
return {date,number,transaction,ledger,merge,schedule,history,simulate};
});

