/*
  Depreciation calculation module: generates annual depreciation schedules based on asset, book, and method rules.
  The main entry point is annualSchedule(), which returns an array of { year, book, method, depreciation, accumulated } rows.
  Method rules can be defined in the rule catalog or directly on the book; built-in formulas include straight-line,
  declining balance, sum-of-years-digits, and rate tables. Special handling for Section 179, bonus depreciation,
  salvage value, and conventions is included.
*/

const { parseJson } = require('./db');
const { buildDepreciationMethods } = require('./rule-catalog');
const { getZone } = require('./services/zones');
const { getLimit: getSection179Limit } = require('./services/section179Limits');
const { getLimit: getSection280fLimit } = require('./services/section280fLimits');
const moment = require('moment');
console.log(`${moment().format()} ✅ Depreciation module loaded with dependencies: db, rule-catalog, zones, section179Limits`);

// Built-in method catalog, used as a fallback so codes like MF200/DB200 always resolve
// even if a particular rule set hasn't been re-expanded to include them.
let methodCatalog = null;
function catalog() {
  if (!methodCatalog) methodCatalog = buildDepreciationMethods();
  console.log(`${moment().format()} ✅ Depreciation method catalog initialized with ${methodCatalog.length} methods`);
  return methodCatalog;
}

// Round a number to 2 decimal places, returning a Number (not a string).
function money(value) {
  console.log(`${moment().format()} 💰 Formatting value ${value} as money`);
  return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
}

// Extract the year from a date string, defaulting to the current year when no value is provided.
function yearFromDate(value) {
  console.log(`${moment().format()} 🕒 Converting date ${value} to year`);
  if (!value) return new Date().getFullYear();
  return new Date(`${value}T00:00:00`).getFullYear();
}

// Extract the month from a date string, defaulting to January (1) when no value is provided. Months are 1-indexed here.
function monthFromDate(value) {
  console.log(`${moment().format()} 🕒 Converting date ${value} to month`);
  if (!value) return 1;
  return new Date(`${value}T00:00:00`).getMonth() + 1;
}

// Parse a value that may be JSON, returning the fallback when parsing fails or when given null/undefined.
function parseMaybeJson(value, fallback = null) {
  console.log(`${moment().format()} 📄 Parsing JSON value ${value}`);
  if (typeof value === 'string') return parseJson(value, fallback);
  return value ?? fallback;
}

// Business-use factor: the percentage of the asset's use that is business-related, applied as a multiplier to cost basis and salvage. Determined by the book's business_use_percent, falling back to the asset's business_use_percent, and defaulting to 100% when neither is set.
function businessUseFactor(asset, book) {
  console.log(`${moment().format()} 📊 Calculating business use factor for asset ${asset.id}`);
  return Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100;
}

// Cost basis before salvage: acquisition value less land, scaled by business-use %.
function grossBasis(asset, book) {
  const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
  const land = Number(asset.land_value || 0);
  console.log(`${moment().format()} 📈 Calculating gross basis for asset ${asset.id}: cost ${cost} - land ${land}`);
  return Math.max(0, (cost - land) * businessUseFactor(asset, book));
}

// Salvage reduces what standard methods recover, but MACRS ignores it (ignoreSalvage flag).
function salvageValue(asset, book, methodRule) {
  if (methodRule && methodRule.ignoreSalvage) return 0;
  console.log(`${moment().format()} 🛠️ Calculating salvage value for asset ${asset.id}`);
  return Math.max(0, Number(asset.salvage_value || 0) * businessUseFactor(asset, book));
}

// Section 179 amount: the lesser of the entered amount, the gross basis, and the applicable limit for the placed-in-service year.
function section179Amount(asset, book) {
  console.log(`${moment().format()} 📊 Calculating Section 179 amount for asset ${asset.id}`);
  return Math.min(grossBasis(asset, book), Number(book.section_179_amount || 0), section179Limit(asset, book));
}

// Enterprise/empowerment-zone Section 179 treatment for the federal book: an increased dollar
// limit and a reduced (e.g. 50%) share of cost counting toward the phaseout threshold. Applies
// only when the asset is flagged for such a zone and placed in service within the date window.
function zoneSection179(asset, book) {
  if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') {
    // Note: the zone treatment applies only to the federal book, and only when the asset is flagged for a zone; the UI enforces this but we check again just in case.
    console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 179 treatment`);
    return null;
  }
  const zone = getZone(asset.special_zone);
  if (!zone) {
    // Note: this should never happen since the UI only allows selecting valid zones, but guard against it just in case.
    console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 179 treatment`);
    return null;
  }
  const increase = Number(zone.section_179_increase || 0);
  const costFactor = Number(zone.section_179_cost_factor || 0) || 1;
  const thresholdIncrease = Number(zone.section_179_threshold_increase || 0);
  if (!increase && costFactor === 1 && !thresholdIncrease){
    // Note: if the zone has no configured increase, cost factor, or threshold increase, then it provides no actual benefit for Section 179 purposes.
    console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 179 treatment`);
    return null;
  }
  // The §179 increase can have its own (often shorter) window than the §168 allowance; fall back
  // to the zone's main placed-in-service window when no §179-specific window is set.
  const start = zone.section_179_pis_start || zone.pis_start;
  const end = zone.section_179_pis_end || zone.pis_end;
  const pis = asset.in_service_date || asset.acquired_date || '';
  if (pis && start && pis < start) {
    // Note: the placed-in-service date can be on either the in_service_date or acquired_date field, depending on the asset; check both just in case.
    console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 179 treatment`);
    return null;
  }
  if (pis && end && pis > end) {
    // Note: the placed-in-service date can be on either the in_service_date or acquired_date field, depending on the asset; check both just in case.
    console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 179 treatment`);
    return null;
  }
  console.log(`${moment().format()} 🏢 Asset ${asset.id} qualifies for zone Section 179 treatment: increase ${increase}, cost factor ${costFactor}, threshold increase ${thresholdIncrease}`);
  return { increase, costFactor, thresholdIncrease };
}

// Applicable §179 dollar cap for an asset's placed-in-service year: the configured base limit
// (plus any enterprise-zone increase) less the dollar-for-dollar investment phaseout. Returns
// Infinity when no limit is configured for the year, leaving the entered §179 amount uncapped.
function section179Limit(asset, book) {
  console.log(`${moment().format()} 📊 Calculating Section 179 limit for asset ${asset.id}`);
  const pisYear = yearFromDate(asset.in_service_date || asset.acquired_date);
  const limitRow = getSection179Limit(pisYear);
  if (!limitRow) {
    // Note: this can happen when the placed-in-service year is far in the future and no limit has been configured for it; in that case, we assume no limit applies rather than trying to extrapolate based on past years.
    console.log(`${moment().format()} 📊 Asset ${asset.id} has no Section 179 limit configured for year ${pisYear}`);
    return Infinity;
  }
  let limit = Number(limitRow.base_limit) || 0;
  let threshold = Number(limitRow.phaseout_threshold) || 0;
  let costFactor = 1;
  const zone = zoneSection179(asset, book);
  if (zone) {
    // Note: the zone increase and cost factor can be applied even when the asset doesn't qualify for the full zone allowance (e.g. for §168) because they are based solely on the placed-in-service date and zone flag, whereas the allowance can also be gated on the book's bonus percentage.
    console.log(`${moment().format()} 🏢 Asset ${asset.id} qualifies for zone Section 179 treatment`);
    limit += zone.increase;
    costFactor = zone.costFactor;
    // GO Zone-style rule: raise the phaseout threshold by the lesser of the cap and the cost.
    if (zone.thresholdIncrease > 0) threshold += Math.min(zone.thresholdIncrease, grossBasis(asset, book));
  }
  if (threshold > 0) {
    // Note: the phaseout applies to the gross basis multiplied by the cost factor, which allows for a more generous limit when the zone's cost factor is less than 1 (e.g. 50% for GO Zone) even if the asset's cost is high.
    console.log(`${moment().format()} 📊 Calculating Section 179 phaseout for asset ${asset.id}: limit ${limit}, threshold ${threshold}, cost factor ${costFactor}`);
    const investment = grossBasis(asset, book) * costFactor;
    limit -= Math.max(0, investment - threshold);
  }
  console.log(`${moment().format()} 📊 Final Section 179 limit for asset ${asset.id} is ${limit}`);
  return Math.max(0, limit);
}

// Special-zone §168 allowance (e.g. New York Liberty Zone): applies to the federal tax book
// when the asset is flagged for a zone and its placed-in-service date is within the window.
// Returns the allowance % (used as the §168 bonus) or 0.
function zoneAllowance(asset, book) {
  console.log(`${moment().format()} 📊 Calculating zone allowance for asset ${asset.id}`);
  if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') {
    // Note: the zone allowance applies only to the federal book, and only when the asset is flagged for a zone; the UI enforces this but we check again just in case.
    console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 168 treatment`);
    return 0;
  }
  const zone = getZone(asset.special_zone);
  if (!zone || !Number(zone.allowance_percent)) {
    // Note: this should never happen since the UI only allows selecting valid zones with configured allowances, but guard against it just in case.
    console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 168 treatment`);
    return 0;
  }
  const pis = asset.in_service_date || asset.acquired_date || '';
  if (pis && zone.pis_start && pis < zone.pis_start) {
    // Note: the placed-in-service date can be on either the in_service_date or acquired_date field, depending on the asset; check both just in case.
    console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 168 treatment`);
    return 0;
  }
  if (pis && zone.pis_end && pis > zone.pis_end) {
    // Note: the placed-in-service date can be on either the in_service_date or acquired_date field, depending on the asset; check both just in case.
    console.log(`${moment().format()} 🏢 Asset ${asset.id} does not qualify for zone Section 168 treatment`);
    return 0;
  }
  console.log(`${moment().format()} 📊 Zone allowance for asset ${asset.id} is ${Number(zone.allowance_percent)}`);
  return Number(zone.allowance_percent);
}

// Section 168 (bonus) allowance, applied after Section 179.
function bonusAmount(asset, book) {
  // Note: the bonus percentage can be set either on the book itself or inherited from the zone when the book has no explicit bonus; check both places just in case.
  console.log(`${moment().format()} 📊 Calculating bonus amount for asset ${asset.id}`);
  const after179 = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book));
  return after179 * (Number(book.bonus_percent || 0) / 100);
}

// Total amount recoverable over the asset's life (the schedule cap): cost basis less salvage.
function recoverableBasis(asset, book, methodRule) {
  return Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, methodRule));
}

// Amount a straight-line / SYD method depreciates: net of Section 179, bonus, and salvage.
function depreciableBasis(asset, book, methodRule) {
  console.log(`${moment().format()} 📊 Calculating depreciable basis for asset ${asset.id}`);
  const result = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book) - salvageValue(asset, book, methodRule));
  console.log(`${moment().format()} 📊 Depreciable basis for asset ${asset.id} is ${result}`);
  return result;
}

// Cost basis net of salvage (no 179/bonus) — retained for reports and disposals.
function totalDepreciableBasis(asset, book) {
  console.log(`${moment().format()} 📊 Calculating total depreciable basis for asset ${asset.id}`);
  const result = Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, null));
  console.log(`${moment().format()} 📊 Total depreciable basis for asset ${asset.id} is ${result}`);
  return result;
}

// Is this MACRS personal property (eligible for the mid-quarter 40% test)? Real property uses the
// mid-month convention and is excluded; only otherwise-half-year MACRS property can switch to mid-quarter.
function isMidQuarterEligible(book, methodRule) {
  return Boolean(methodRule) && methodRule.family === 'MACRS'
    && (methodRule.defaultConvention || book.convention) === 'half_year';
}

// Unadjusted depreciable basis used by the mid-quarter 40% test: post-§179, pre-bonus (IRS Pub 946).
function midQuarterBasis(asset, book) {
  return Math.max(0, grossBasis(asset, book) - section179Amount(asset, book));
}

// §280F: listed property used 50% or less for qualified business use must be depreciated under ADS
// (straight-line) and may not claim §179 or the special (bonus) allowance. Applies to MACRS tax books.
function listedAdsRequired(asset, book, methodRule) {
  if (!asset.listed_property || !methodRule || methodRule.family !== 'MACRS') return false;
  const use = businessUseFactor(asset, book);
  return use > 0 && use <= 0.5;
}

// §280F passenger-automobile annual depreciation cap for a given recovery year (0-based index), reduced
// by business-use percentage. Returns Infinity when the asset isn't a flagged passenger auto or no cap
// table is configured for the placed-in-service year (leaving depreciation uncapped).
function section280fCap(asset, book, index) {
  if (!asset.passenger_auto) return Infinity;
  const row = getSection280fLimit(yearFromDate(asset.in_service_date || asset.acquired_date));
  if (!row) return Infinity;
  let base;
  if (index <= 0) base = bonusAmount(asset, book) > 0 ? row.year1_bonus : row.year1_no_bonus;
  else if (index === 1) base = row.year2;
  else if (index === 2) base = row.year3;
  else base = row.year4plus;
  return Math.max(0, Number(base) || 0) * businessUseFactor(asset, book);
}

// §280F recapture preview: if a listed/passenger asset's qualified business use drops to ≤50% in
// `year`, the excess of accelerated depreciation actually claimed (through the prior year) over what ADS
// straight-line would have allowed is recaptured as ordinary income. Computed from the asset as it
// currently stands (i.e., its current business use is treated as the prior-years' use).
function section280fRecapture(asset, book, ruleSet, year) {
  if (!asset.listed_property && !asset.passenger_auto) return { applies: false, recapture: 0 };
  const through = Number(year) - 1;
  const accel = annualSchedule(asset, book, ruleSet, { startYear: through, endYear: through, ignoreListedAds: true })[0] || {};
  const adsBook = { ...book, depreciation_method: 'straight_line', section_179_amount: 0, bonus_percent: 0 };
  const ads = annualSchedule(asset, adsBook, ruleSet, { startYear: through, endYear: through })[0] || {};
  const acceleratedAccum = money(Number(accel.accumulated || 0));
  const adsAccum = money(Number(ads.accumulated || 0));
  return {
    applies: true,
    conversion_year: Number(year),
    accelerated_accumulated: acceleratedAccum,
    ads_accumulated: adsAccum,
    recapture: Math.max(0, money(acceleratedAccum - adsAccum))
  };
}

// Determine the applicable depreciation convention. MACRS mandates the mid-quarter convention when more
// than 40% of the year's MACRS personal-property basis is placed in service in the last quarter (IRS
// Pub 946). The engine determines this automatically and substitutes it for otherwise-half-year property.
function conventionFor(book, methodRule, asset) {
  let convention = methodRule.defaultConvention || book.convention || 'none';
  if (convention === 'half_year' && asset && isMidQuarterEligible(book, methodRule)) {
    const pisYear = yearFromDate(asset.in_service_date || asset.acquired_date);
    // Lazy require avoids a load-time cycle (the service reads back basis helpers from this module).
    if (require('./services/midQuarter').applies(asset.entity_id, book.book_type, pisYear)) {
      convention = 'mid_quarter';
    }
  }
  return convention;
}

// Calculate the year fraction for a given year index based on the specified convention. The index is 0-based, so 0 corresponds to the placed-in-service year. Returns a fraction between 0 and 1 representing how much of the year's depreciation should be taken in that year, or null when the convention doesn't specify an explicit fraction for that year (e.g. for mid-month, which only has a special rule for the first year).
function yearFraction(index, convention, asset) {
  console.log(`${moment().format()} 🕒 Calculating year fraction for index ${index}, convention ${convention}, asset ${asset.id}`);
  if (index < 0) {
    // Note: negative indices are invalid, but just in case, return 0 rather than trying to apply the convention logic.
    console.log(`${moment().format()} 🕒 Index is negative for asset ${asset.id}`);
    return 0;
  }
  const month = monthFromDate(asset.in_service_date || asset.acquired_date);
  const quarter = Math.ceil(month / 3);

  if (convention === 'none') {
    // Note: the 'none' convention means no special first-year treatment, so the fraction is always 1 for valid indices.
    console.log(`${moment().format()} 🕒 Using 'none' convention for asset ${asset.id}`);
    return 1;
  }
  if (convention === 'full_month') {
    // Note: the 'full_month' convention treats the placed-in-service month as a full month, so the first-year fraction is based on the number of months remaining in the year including the placed-in-service month; subsequent years have no special treatment, so return null to indicate that the default fraction of 1 should be used.
    console.log(`${moment().format()} 🕒 Using 'full_month' convention for asset ${asset.id}`);
    const first = (13 - month) / 12;
    if (index === 0) {
      // Note: for the first year, return the calculated fraction; for subsequent years, return null to indicate that the default fraction of 1 should be used, since the 'full_month' convention only has special treatment for the first year.
      console.log(`${moment().format()} 🕒 First year fraction for asset ${asset.id} is ${first}`);
      return first;
    }
    // Note: the 'full_month' convention has no special treatment for subsequent years, so return null to indicate that the default fraction of 1 should be used.
    console.log(`${moment().format()} 🕒 Using 'full_month' convention for asset ${asset.id}`);
    return null;
  }
  if (convention === 'mid_month') {
    // Note: the 'mid_month' convention treats the placed-in-service month as a half month, so the first-year fraction is based on the number of months remaining in the year including the placed-in-service month, but with a 0.5 month reduction; subsequent years have no special treatment, so return null to indicate that the default fraction of 1 should be used.
    console.log(`${moment().format()} 🕒 Using 'mid_month' convention for asset ${asset.id}`);
    const first = (12 - month + 0.5) / 12;
    if (index === 0) {
      // Note: for the first year, return the calculated fraction; for subsequent years, return null to indicate that the default fraction of 1 should be used, since the 'mid_month' convention only has special treatment for the first year.
      console.log(`${moment().format()} 🕒 First year fraction for asset ${asset.id} is ${first}`);
      return first;
    }
    // Note: the 'mid_month' convention has no special treatment for subsequent years, so return null to indicate that the default fraction of 1 should be used.
    console.log(`${moment().format()} 🕒 Using 'mid_month' convention for asset ${asset.id}`);
    return null;
  }
  if (convention === 'mid_quarter') {
    // Note: the 'mid_quarter' convention treats the placed-in-service quarter as a half quarter, so the first-year fraction is based on the number of quarters remaining in the year including the placed-in-service quarter, but with a 0.5 quarter reduction; subsequent years have no special treatment, so return null to indicate that the default fraction of 1 should be used.
    const firstByQuarter = { 1: 0.875, 2: 0.625, 3: 0.375, 4: 0.125 };
    if (index === 0) {
      // Note: for the first year, return the calculated fraction based on the placed-in-service quarter; for subsequent years, return null to indicate that the default fraction of 1 should be used, since the 'mid_quarter' convention only has special treatment for the first year.
      console.log(`${moment().format()} 🕒 First year fraction for asset ${asset.id} is ${firstByQuarter[quarter] || 0.5}`);
      return firstByQuarter[quarter] || 0.5;
    }
    return null;
  }
  if (convention === 'half_year') {
    // Note: the 'half_year' convention treats the placed-in-service period as a half year, so the first-year fraction is always 0.5; subsequent years have no special treatment, so return null to indicate that the default fraction of 1 should be used.
    if (index === 0) {
      // Note: for the first year, return the fixed fraction of 0.5; for subsequent years, return null to indicate that the default fraction of 1 should be used, since the 'half_year' convention only has special treatment for the first year.
      console.log(`${moment().format()} 🕒 First year fraction for asset ${asset.id} is 0.5`);
      return 0.5;
    }
    return null;
  }
  return 1;
}

// Determine the fraction of the year's depreciation to take for a given year index, based on the convention's rules. For conventions with explicit fractions for each year (e.g. 'full_month', 'mid_month', 'mid_quarter', 'half_year'), use those fractions for the first year and return 1 for subsequent years. For conventions without explicit fractions for subsequent years (e.g. 'full_month', 'mid_month'), return null for subsequent years to indicate that the default fraction of 1 should be used. For conventions with no special treatment (e.g. 'none'), return 1 for all years. Additionally, for years beyond the asset's useful life, return 0 to indicate that no depreciation should be taken.
function fractionForIndex(index, convention, asset, totalYears) {
  console.log(`${moment().format()} 🕒 Calculating fraction for index ${index}, convention ${convention}, asset ${asset.id}, totalYears ${totalYears}`);
  const first = yearFraction(0, convention, asset);
  const explicit = yearFraction(index, convention, asset);
  if (explicit !== null){
    // Note: when the convention provides an explicit fraction for the given index, use it directly; this covers the first year for conventions like 'full_month' and 'mid_month', as well as all years for a convention like 'half_year'.
    console.log(`${moment().format()} 🕒 Using explicit fraction for asset ${asset.id}`);
    return explicit;
  }
  const finalIndex = Math.ceil(totalYears + (1 - first)) - 1;
  if (index > finalIndex){ 
    // Note: when the index exceeds the final index for the asset's useful life (adjusted for the first year's fraction), return 0 to indicate that no depreciation should be taken; this ensures that we don't apply depreciation in years beyond the asset's recoverable life, even if the schedule extends further.
    console.log(`${moment().format()} 🕒 Index exceeds final index for asset ${asset.id}`);
    return 0};
  if (index === finalIndex) {
    // Note: when the index is exactly the final index for the asset's useful life (adjusted for the first year's fraction), return a fraction that represents the remaining portion of the year after accounting for the first year's fraction; this ensures that we take the appropriate amount of depreciation in the final year, which may be a partial year depending on the convention.
    console.log(`${moment().format()} 🕒 Using final index fraction for asset ${asset.id}`);
    return Math.max(0, 1 - first);
  }
  console.log(`${moment().format()} 🕒 Using default fraction for asset ${asset.id}`);
  return 1;
}

// Straight-line depreciation: (basis / total years) * convention fraction. The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods.
function straightLine(asset, book, index, totalYears, methodRule) {
  console.log(`${moment().format()} 🕒 Calculating straight-line depreciation for asset ${asset.id}, index ${index}, totalYears ${totalYears}`);
  const basis = depreciableBasis(asset, book, methodRule);
  console.log(`${moment().format()} 🕒 Depreciable basis for straight-line calculation is ${basis} for asset ${asset.id}`);
  return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule, asset), asset, totalYears);
}

// Sum-of-years-digits depreciation: (basis * (remaining years / sum of years)) * convention fraction. The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods.
function sumOfYearsDigits(asset, book, index, totalYears, methodRule) {
  console.log(`${moment().format()} 🕒 Calculating sum-of-years-digits depreciation for asset ${asset.id}, index ${index}, totalYears ${totalYears}`);
  const basis = depreciableBasis(asset, book, methodRule);
  const denominator = (totalYears * (totalYears + 1)) / 2;
  console.log(`${moment().format()} 🕒 Depreciable basis for sum-of-years-digits calculation is ${basis} for asset ${asset.id}`);
  return basis * ((totalYears - index) / denominator);
}

// Declining balance depreciation: (book value * (multiplier / total years)) * convention fraction, with an optional switch to straight-line when that becomes more favorable. The book value is net of Section 179 and bonus, but not salvage (since salvage doesn't reduce the amount recoverable through declining balance methods, except for methods with the ignoreSalvage flag).
function decliningBalance(asset, book, index, totalYears, multiplier, methodRule) {
  // The declining-balance rate applies to the full (post-179/bonus) book value; depreciation
  // is floored at salvage. MACRS methods set salvage to 0 via ignoreSalvage.
  console.log(`${moment().format()} 🕒 Calculating declining balance depreciation for asset ${asset.id}, index ${index}, totalYears ${totalYears}, multiplier ${multiplier}`);
  const start = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book));
  const salvage = salvageValue(asset, book, methodRule);
  let bookValue = start;
  let elapsed = 0;
  const convention = conventionFor(book, methodRule, asset);

  for (let i = 0; i <= index; i += 1) {
    // Note: for each year up to and including the target index, calculate the declining balance amount based on the current book value, multiplier, total years, and convention fraction; if the method has a switchToStraightLine flag, also calculate the straight-line amount based on the remaining recoverable basis and remaining life, and use whichever is greater; then reduce the book value by the selected amount and accumulate the elapsed years based on the convention fraction. For the target index, return the selected amount; for prior indices, just update the book value and elapsed years for the next iteration. If we exceed the total years before reaching the target index, return 0 since no depreciation should be taken.
    console.log(`${moment().format()} 🕒 Declining balance loop for asset ${asset.id}, iteration ${i}, book value ${bookValue}, elapsed ${elapsed}`);
    const fraction = fractionForIndex(i, convention, asset, totalYears);
    const recoverable = Math.max(0, bookValue - salvage);
    if (fraction <= 0 || recoverable <= 0) {
      // Note: if the convention fraction is zero or negative (which can happen for years beyond the asset's useful life, depending on the convention) or if the recoverable amount is zero or negative (which can happen when salvage is high relative to the remaining book value), then no depreciation should be taken for this year or any subsequent years, so return 0.
      console.log(`${moment().format()} 🕒 Fraction or recoverable is zero for asset ${asset.id}`);
      return 0;
    }

    const dbAmount = bookValue * (multiplier / totalYears) * fraction;
    const remainingLife = Math.max(0.0001, totalYears - elapsed);
    const slAmount = (recoverable / remainingLife) * fraction;
    const selected = methodRule.switchToStraightLine ? Math.max(dbAmount, slAmount) : dbAmount;
    const amount = Math.min(recoverable, selected);

    if (i === index) {
      // Note: for the target index, return the calculated amount; for prior indices, just update the book value and elapsed years for the next iteration. If we exceed the total years before reaching the target index, return 0 since no depreciation should be taken.
      console.log(`${moment().format()} 🕒 Returning declining balance amount for asset ${asset.id}: ${amount}`);
      return amount;
    }
    bookValue -= amount;
    elapsed += fraction;
  }
  console.log(`${moment().format()} 🕒 Index exceeds total years for asset ${asset.id}`);
  return 0;
}

// Rate table depreciation: (basis * rate). The basis is net of Section 179, bonus, and salvage, since those reduce the amount recoverable through standard depreciation methods. The rate is determined by the method rule's rates array based on the year index, with an optional fallback to straight-line when no rates are provided.
function rateTable(asset, book, index, methodRule) {
  console.log(`${moment().format()} 🕒 Calculating rate table depreciation for asset ${asset.id}, index ${index}`);
  const basis = depreciableBasis(asset, book, methodRule);
  const rates = methodRule.rates || [];
  if (!rates.length && methodRule.fallbackFormula === 'straight_line') {
    // Note: when the method rule has no rates defined but specifies a fallback to straight-line, use the straight-line formula with the total years determined by the method rule's lifeMonths or the book's useful_life_months; this allows for flexible handling of cases where a rate table is desired but specific rates haven't been configured, while still providing a reasonable default calculation.
    console.log(`${moment().format()} 🕒 No rates found for asset ${asset.id}, falling back to straight-line calculation`);
    return straightLine(asset, book, index, Math.max(1, Math.ceil((methodRule.lifeMonths || book.useful_life_months || 60) / 12)), methodRule);
  }
  console.log(`${moment().format()} 🕒 Using rate ${Number(rates[index] || 0)} for asset ${asset.id}`);
  return basis * Number(rates[index] || 0);
}

// Retrieve the method rule for a given code from the provided rule set, falling back to the built-in catalog and a default rule when not found. The rule set can be a JSON string or an object; if it's a string, it will be parsed as JSON. The method rule is determined by looking for a method with the matching code in the rule set's methods array, then in the built-in catalog; if no matching method is found, a default rule with the given code and formula is returned.
function getMethodRule(ruleSet, code) {
  console.log(`${moment().format()} 🕵️ Retrieving method rule for code ${code} from rule set`);
  const rules = typeof ruleSet === 'string' ? parseJson(ruleSet, {}) : ruleSet || {};
  console.log(`${moment().format()} 🕵️ Method rules retrieved: ${JSON.stringify(rules)}`);
  return (rules.methods || []).find((method) => method.code === code)
    || catalog().find((method) => method.code === code)
    || { code, formula: code, label: code };
}

// Generate an annual depreciation schedule for a given asset and book based on the applicable method rule, conventions, and special treatments. The schedule includes rows for each year from the placed-in-service year to the end year (which is determined by the useful life), with columns for the year, book type, method code and label, depreciation amount, accumulated depreciation, and net book value. The calculation applies Section 179 and bonus amounts in the first year, uses the method rule's formula to calculate depreciation for each year, and ensures that depreciation does not exceed the recoverable basis of the asset.
function annualSchedule(asset, book, ruleSet, options = {}) {
  console.log(`${moment().format()} 🕒 Generating annual depreciation schedule for asset ${asset.id}, book ${book.id}`);
  let methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
  // §280F: listed property at ≤50% qualified business use is forced to ADS straight-line with no §179
  // or bonus allowance (the ADS recovery period uses the asset's configured life). `ignoreListedAds`
  // computes the as-if-accelerated path used by the §280F recapture preview.
  if (!options.ignoreListedAds && listedAdsRequired(asset, book, methodRule)) {
    book = { ...book, section_179_amount: 0, bonus_percent: 0 };
    methodRule = { ...methodRule, formula: 'straight_line', label: `${methodRule.label || methodRule.code} (ADS — listed ≤50% use)` };
  }
  // Apply a special-zone §168 allowance when the book has no explicit bonus of its own.
  if (!(Number(book.bonus_percent) > 0)) {
    // Note: the zone allowance can provide a significant boost to the first-year depreciation through the bonus, so we check for it and apply it as the book's bonus percentage when the book doesn't have its own configured bonus; this allows assets in qualified zones to get the appropriate benefit even when using a book that doesn't have an explicit bonus configured, while still allowing for more flexible handling of cases where a zone allowance is desired but specific books haven't been configured with a bonus percentage.
    console.log(`${moment().format()} 🏢 Checking for zone allowance for asset ${asset.id} since book has no explicit bonus`);
    const allowance = zoneAllowance(asset, book);
    if (allowance > 0) book = { ...book, bonus_percent: allowance };
  }
  const usefulLifeMonths = Number(methodRule.lifeMonths || book.useful_life_months || asset.useful_life_months || 60);
  const totalYears = Math.max(1, Math.ceil(usefulLifeMonths / 12));
  const placedInServiceYear = yearFromDate(asset.in_service_date || asset.acquired_date);
  const startYear = Number(options.startYear || placedInServiceYear);
  const endYear = Number(options.endYear || startYear + totalYears + 1);
  const manual = parseMaybeJson(book.manual_depreciation, {});
  const rows = [];
  let accumulated = Number(asset.prior_depreciation || 0);

  for (let year = placedInServiceYear; year <= endYear; year += 1) {
    // Note: for each year from the placed-in-service year to the end year, calculate the depreciation based on the method rule's formula, applying Section 179 and bonus amounts in the first year, and ensuring that we don't exceed the recoverable basis of the asset; then add a row to the schedule with the year, book type, method code and label, depreciation amount, accumulated depreciation, and net book value. The loop continues through the end year to allow for cases where depreciation extends beyond the useful life, but the fractionForIndex function will return 0 for years beyond the recoverable life to ensure that no depreciation is taken in those years.
    console.log(`${moment().format()} 🕒 Calculating depreciation for asset ${asset.id}, year ${year}`);
    const index = year - placedInServiceYear;
    let depreciation = 0;
    if (index >= 0) {
      // Note: we only calculate depreciation for years on or after the placed-in-service year, but we still loop through the end year to allow for cases where depreciation extends beyond the useful life; the fractionForIndex function will return 0 for years beyond the recoverable life to ensure that no depreciation is taken in those years.
      if (index === 0) {
        // Note: in the first year, apply Section 179 and bonus amounts before calculating the method depreciation, since those reduce the amount recoverable through standard depreciation methods; in subsequent years, those amounts have already been accounted for and should not be applied again.
        depreciation += section179Amount(asset, book) + bonusAmount(asset, book);
      }

      console.log(`${moment().format()} 🕒 Evaluating method rule for asset ${asset.id}, year ${year}`);
      if (methodRule.formula === 'rate_table') {
        // Note: for the rate table formula, we look up the rate for the current year index and apply it to the depreciable basis; if no rates are defined but a fallback to straight-line is specified, we use the straight-line formula instead with the total years determined by the method rule's lifeMonths or the book's useful_life_months. This allows for flexible handling of cases where a rate table is desired but specific rates haven't been configured, while still providing a reasonable default calculation.
        console.log(`${moment().format()} 🕒 Using rate table formula for asset ${asset.id}, year ${year}`);
        depreciation += rateTable(asset, book, index, methodRule);
      } else if (methodRule.formula === 'sum_of_years_digits') {
        // Note: for the sum-of-years-digits formula, we calculate the sum of the years based on the total years of useful life, and apply the formula to the depreciable basis; this allows for accelerated depreciation in the earlier years of the asset's life, while still ensuring that we don't exceed the recoverable basis.
        console.log(`${moment().format()} 🕒 Using sum-of-years-digits formula for asset ${asset.id}, year ${year}`);
        depreciation += sumOfYearsDigits(asset, book, index, totalYears, methodRule);
      } else if (methodRule.formula === 'declining_balance' || methodRule.formula === 'macrs_declining_balance') {
        // Note: for the declining balance formula, we apply the formula to the post-Section 179/bonus book value, with an optional switch to straight-line when that becomes more favorable; for MACRS declining balance, we also set salvage to 0 via the ignoreSalvage flag since MACRS doesn't allow salvage to reduce the amount recoverable through declining balance methods. This allows for accelerated depreciation in the earlier years of the asset's life, while still ensuring that we don't exceed the recoverable basis and providing appropriate handling for MACRS methods.
        console.log(`${moment().format()} 🕒 Using declining balance formula for asset ${asset.id}, year ${year}`);
        depreciation += decliningBalance(asset, book, index, totalYears, Number(methodRule.rateMultiplier || 2), methodRule);
      } else if (methodRule.formula === 'acrs_alternate_straight_line') {
        // Note: the ACRS alternate straight-line formula is a special case that applies a straight-line calculation with the half-year convention regardless of the book's actual convention; this allows for accurate handling of assets that fall under the ACRS rules, which have specific requirements for the depreciation schedule.
        console.log(`${moment().format()} 🕒 Using ACRS alternate straight-line formula for asset ${asset.id}, year ${year}`);
        depreciation += straightLine(asset, book, index, totalYears, { ...methodRule, defaultConvention: 'half_year' });
      } else if (methodRule.formula === 'manual') {
        // Note: for the manual formula, we look up the depreciation amount for the current year in the book's manual_depreciation field, which is expected to be a JSON object with year keys and depreciation amount values; this allows for completely custom depreciation schedules to be entered on the book, while still providing a fallback to straight-line when no manual amounts are provided.
        console.log(`${moment().format()} 🕒 Using manual formula for asset ${asset.id}, year ${year}`);
        depreciation = Number(manual[year] || 0);
      } else {
        // Note: for any other formula (including straight-line), we apply the straight-line calculation to the depreciable basis; this serves as a reasonable default for methods that don't have specific logic implemented, while still ensuring that we don't exceed the recoverable basis.
        console.log(`${moment().format()} 🕒 Using straight-line formula for asset ${asset.id}, year ${year}`);
        depreciation += straightLine(asset, book, index, totalYears, methodRule);
      }
    }

    // §280F passenger-automobile annual cap: a luxury auto recovers no more than the yearly limit, and
    // continues recovering remaining basis at the cap after the normal recovery period ends.
    if (asset.passenger_auto && index >= 0) {
      const cap280f = section280fCap(asset, book, index);
      const remaining = Math.max(0, recoverableBasis(asset, book, methodRule) - accumulated);
      let allowed = Math.min(depreciation, cap280f);
      if (allowed <= 0 && remaining > 0) allowed = Math.min(cap280f, remaining);
      depreciation = allowed;
    }
    const cap = recoverableBasis(asset, book, methodRule);
    depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated)));
    accumulated += depreciation;

    if (year >= startYear) {
      // Note: we only include rows for years on or after the specified start year, but we still loop through the end year to allow for cases where depreciation extends beyond the useful life; the fractionForIndex function will return 0 for years beyond the recoverable life to ensure that no depreciation is taken in those years, and we will cap the depreciation at the recoverable basis to ensure that we don't exceed it even if the schedule extends further.
      console.log(`${moment().format()} 🕒 Adding schedule row for asset ${asset.id}, year ${year}: depreciation ${depreciation}, accumulated ${accumulated}`);
      rows.push({
        year,
        book: book.book_type,
        method: methodRule.code,
        methodLabel: methodRule.label || methodRule.code,
        depreciation: money(depreciation),
        accumulated: money(accumulated),
        netBookValue: money(Math.max(0, Number(book.cost ?? asset.acquisition_cost ?? 0) - accumulated))
      });
    }
  }
  console.log(`${moment().format()} 🕒 Completed annual schedule for asset ${asset.id} with ${rows.length} rows`);
  return rows;
}

// Convert an annual depreciation schedule to a monthly schedule by dividing each year's depreciation by 12 and creating 12 rows for each year with the corresponding month numbers. This allows for more granular reporting and analysis of depreciation on a monthly basis, while still based on the annual calculations.
function monthlyFromAnnual(row) {
  console.log(`${moment().format()} 🕒 Converting annual schedule row to monthly for year ${row.year}, book ${row.book}`);
  const monthly = money(row.depreciation / 12);
  console.log(`${moment().format()} 🕒 Monthly depreciation for asset ${row.book} in year ${row.year} is ${monthly}`);
  return Array.from({ length: 12 }, (_, index) => ({
    month: index + 1,
    year: row.year,
    book: row.book,
    depreciation: monthly
  }));
}

// Format a number as money with two decimal places. This is used to ensure consistent formatting of depreciation amounts in the schedule, making it easier to read and analyze the results.
function money(value) {
  return Number(Number(value || 0).toFixed(2));
}

// Export the functions for use in other modules. This allows the depreciation logic to be organized in a single module and reused across different parts of the application, such as in API endpoints or report generation.
module.exports = {
  annualSchedule,
  depreciableBasis,
  totalDepreciableBasis,
  getMethodRule,
  isMidQuarterEligible,
  midQuarterBasis,
  monthFromDate,
  monthlyFromAnnual,
  money,
  section280fRecapture,
  yearFromDate
};
