'use strict'
const cfg = require('./config')
const { log, json, parseBody, erpFetch } = require('./helpers')
const erp = require('./erp')
const { signJwt, verifyJwt } = require('./magic-link')

// Lazy-loaded flow runtime. Kept local so the require() cost isn't paid on
// modules that don't trigger flows.
let _flowRuntime
function _fireFlowTrigger (event, ctx) {
  if (!_flowRuntime) _flowRuntime = require('./flow-runtime')
  return _flowRuntime.dispatchEvent(event, ctx)
}

// Résidentiel:
//   - Pénalité = valeur résiduelle des avantages non compensés
//   - Chaque mois d'abonnement "reconnaît" (compense) benefit_value / duration_months
//   - Ex: Installation 288$ gratuite sur 24 mois → 12$/mois compensé
//   - Résiliation au mois 18 → 6 × 12$ = 72$ de pénalité
//
// Commercial:
//   - Pénalité = toutes les mensualités restantes au contrat
//   - Ex: 79.95$/mois × 6 mois restants = 479.70$

function calculateTerminationFee (contract) {
  const now = new Date()
  const start = new Date(contract.start_date)
  const monthsElapsed = Math.max(0, monthsBetween(start, now))
  const monthsRemaining = Math.max(0, (contract.duration_months || 24) - monthsElapsed)

  const benefits = (contract.benefits || []).map(b => {
    const benefitValue = (b.regular_price || 0) - (b.granted_price || 0)
    const monthlyRecognition = benefitValue / (contract.duration_months || 24)
    const recognized = monthlyRecognition * monthsElapsed
    const remaining = Math.max(0, benefitValue - recognized)
    return {
      description: b.description,
      regular_price: b.regular_price,
      granted_price: b.granted_price,
      benefit_value: round2(benefitValue),
      monthly_recognition: round2(monthlyRecognition),
      months_recognized: monthsElapsed,
      remaining_value: round2(remaining),
    }
  })

  const totalBenefitRemaining = round2(benefits.reduce((s, b) => s + b.remaining_value, 0))
  const monthlyRate = contract.monthly_rate || 0

  let fee = 0
  let feeBreakdown = {}

  if (contract.contract_type === 'Commercial') {
    // Commercial: all remaining monthly payments
    fee = round2(monthsRemaining * monthlyRate)
    feeBreakdown = {
      type: 'commercial',
      months_remaining: monthsRemaining,
      monthly_rate: monthlyRate,
      termination_fee_remaining: fee,
      termination_fee_benefits: 0,
      termination_fee_total: fee,
    }
  } else {
    // Résidentiel (CRTC 2026-43) : AUCUN clawback de promotion. On ne facture
    // que le SOLDE de l'installation financée — un service déjà rendu, donc
    // facturable. Les promotions/carte-cadeau ne sont JAMAIS récupérées.
    const installFee = contract.install_fee || 0
    const installRemaining = round2(installFee * monthsRemaining / (contract.duration_months || 24))
    fee = installRemaining
    feeBreakdown = {
      type: 'residential',
      months_remaining: monthsRemaining,
      monthly_rate: monthlyRate,
      install_fee: installFee,
      install_remaining: installRemaining,
      termination_fee_benefits: 0,
      termination_fee_remaining: installRemaining,
      termination_fee_total: installRemaining,
    }
  }

  return {
    months_elapsed: monthsElapsed,
    months_remaining: monthsRemaining,
    benefits,
    total_benefit_remaining: totalBenefitRemaining,
    ...feeBreakdown,
  }
}

function generateInvoiceNote (contract) {
  const benefits = contract.benefits || []
  const lines = []
  for (const b of benefits) {
    const benefitValue = (b.regular_price || 0) - (b.granted_price || 0)
    if (benefitValue <= 0) continue
    const monthly = round2(benefitValue / (contract.duration_months || 24))
    lines.push(
      `${b.description} ${b.granted_price || 0}$ sur entente de ${contract.duration_months || 24} mois. ` +
      `(Prix régulier ${b.regular_price}$) Chaque mois d'abonnement reconnaît ${monthly}$ de compensé.`
    )
  }
  return lines.join('\n')
}

function generateContractToken (contractName, customer, phone, ttlHours = 168) {
  return signJwt({
    sub: customer,
    doc: contractName,
    type: 'contract_accept',
    phone: phone || '',
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + ttlHours * 3600,
  })
}

function generateContractLink (contractName, customer, phone) {
  const token = generateContractToken(contractName, customer, phone)
  return `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/contract/accept/${token}`
}

async function handle (req, res, method, path) {
  // GET /contract/list?customer=CUST-001
  if (path === '/contract/list' && method === 'GET') {
    const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
    const customer = url.searchParams.get('customer')
    const filters = customer ? [['customer', '=', customer]] : []
    filters.push(['status', '!=', 'Brouillon'])

    const params = new URLSearchParams({
      fields: JSON.stringify(['name', 'customer', 'customer_name', 'contract_type', 'status',
        'start_date', 'end_date', 'duration_months', 'monthly_rate', 'total_benefit_value',
        'total_remaining_value', 'months_elapsed', 'months_remaining']),
      filters: JSON.stringify(filters),
      limit_page_length: 200,
      order_by: 'start_date desc',
    })
    const r = await erpFetch('/api/resource/Service%20Contract?' + params)
    return json(res, 200, { contracts: r.status === 200 ? (r.data?.data || []) : [] })
  }

  // GET /contract/detail/CTR-00001
  if (path.startsWith('/contract/detail/') && method === 'GET') {
    const name = decodeURIComponent(path.replace('/contract/detail/', ''))
    const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(name)}`)
    if (r.status !== 200) return json(res, 404, { error: 'Contract not found' })
    return json(res, 200, { contract: r.data.data })
  }

  // POST /contract/create — create a new contract
  if (path === '/contract/create' && method === 'POST') {
    const body = await parseBody(req)
    if (!body.customer || !body.duration_months) {
      return json(res, 400, { error: 'customer and duration_months required' })
    }

    // Build benefits with calculated fields
    const duration = body.duration_months || 24
    const benefits = (body.benefits || []).map(b => ({
      description: b.description || '',
      regular_price: b.regular_price || 0,
      granted_price: b.granted_price || 0,
      benefit_value: round2((b.regular_price || 0) - (b.granted_price || 0)),
      monthly_recognition: round2(((b.regular_price || 0) - (b.granted_price || 0)) / duration),
      months_recognized: 0,
      remaining_value: round2((b.regular_price || 0) - (b.granted_price || 0)),
    }))

    const totalBenefitValue = round2(benefits.reduce((s, b) => s + b.benefit_value, 0))

    const startDate = body.start_date || new Date().toISOString().slice(0, 10)
    const endDate = addMonths(startDate, duration)

    // Generate invoice note
    const invoiceNote = generateInvoiceNote({
      benefits: body.benefits || [],
      duration_months: duration,
    })

    const payload = {
      customer: body.customer,
      contract_type: body.contract_type || 'Résidentiel',
      status: 'Brouillon',
      start_date: startDate,
      end_date: endDate,
      duration_months: duration,
      monthly_rate: body.monthly_rate || 0,
      monthly_regular: body.monthly_regular || 0,
      install_fee: body.install_fee || 0,
      install_regular: body.install_regular || 0,
      service_location: body.service_location || '',
      quotation: body.quotation || '',
      subscription: body.subscription || '',
      benefits,
      total_benefit_value: totalBenefitValue,
      total_remaining_value: totalBenefitValue,
      months_elapsed: 0,
      months_remaining: duration,
      invoice_note: invoiceNote,
      acceptance_method: body.acceptance_method || '',
      docuseal_template_id: body.docuseal_template_id || 0,
    }

    const r = await erpFetch('/api/resource/Service%20Contract', {
      method: 'POST',
      body: JSON.stringify(payload),
    })

    if (r.status !== 200) {
      return json(res, 500, { error: 'Failed to create contract', detail: r.data })
    }

    log(`Contract created: ${r.data.data.name} for ${body.customer}`)
    return json(res, 200, { ok: true, contract: r.data.data })
  }

  // POST /contract/calculate-termination — preview termination fee without saving
  if (path === '/contract/calculate-termination' && method === 'POST') {
    const body = await parseBody(req)
    if (!body.name) return json(res, 400, { error: 'contract name required' })

    const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`)
    if (r.status !== 200) return json(res, 404, { error: 'Contract not found' })

    const contract = r.data.data
    const calc = calculateTerminationFee(contract)
    return json(res, 200, { contract_name: body.name, ...calc })
  }

  // POST /contract/terminate — execute termination + create invoice
  if (path === '/contract/terminate' && method === 'POST') {
    const body = await parseBody(req)
    if (!body.name) return json(res, 400, { error: 'contract name required' })

    const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`)
    if (r.status !== 200) return json(res, 404, { error: 'Contract not found' })

    const contract = r.data.data
    if (contract.status === 'Résilié' || contract.status === 'Complété') {
      return json(res, 400, { error: 'Contract already terminated' })
    }

    const calc = calculateTerminationFee(contract)
    const today = new Date().toISOString().slice(0, 10)

    // Update contract with termination data
    const updateData = {
      status: 'Résilié',
      terminated_at: today,
      termination_reason: body.reason || '',
      termination_fee_benefits: calc.termination_fee_benefits,
      termination_fee_remaining: calc.termination_fee_remaining,
      termination_fee_total: calc.termination_fee_total,
      months_elapsed: calc.months_elapsed,
      months_remaining: calc.months_remaining,
    }

    // Update benefits rows with current recognition
    if (contract.benefits && contract.benefits.length) {
      updateData.benefits = calc.benefits.map((b, i) => ({
        ...contract.benefits[i],
        months_recognized: b.months_recognized,
        remaining_value: b.remaining_value,
        benefit_value: b.benefit_value,
        monthly_recognition: b.monthly_recognition,
      }))
      updateData.total_remaining_value = calc.total_benefit_remaining
    }

    await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`, {
      method: 'PUT',
      body: JSON.stringify(updateData),
    })

    // Create termination invoice if fee > 0
    let invoiceName = null
    if (calc.termination_fee_total > 0 && body.create_invoice !== false) {
      invoiceName = await createTerminationInvoice(contract, calc, body.reason)
      if (invoiceName) {
        await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`, {
          method: 'PUT',
          body: JSON.stringify({ termination_invoice: invoiceName }),
        })
      }
    }

    log(`Contract ${body.name} terminated. Fee: ${calc.termination_fee_total}$. Invoice: ${invoiceName || 'none'}`)
    return json(res, 200, {
      ok: true,
      ...calc,
      termination_invoice: invoiceName,
    })
  }

  // POST /contract/send — send contract for acceptance (DocuSeal or JWT SMS)
  if (path === '/contract/send' && method === 'POST') {
    const body = await parseBody(req)
    if (!body.name) return json(res, 400, { error: 'contract name required' })

    const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`)
    if (r.status !== 200) return json(res, 404, { error: 'Contract not found' })

    const contract = r.data.data
    const phone = body.phone
    const email = body.email

    let result = {}

    // Try DocuSeal first if configured
    if (body.use_docuseal && cfg.DOCUSEAL_URL && cfg.DOCUSEAL_API_KEY) {
      const { createDocuSealSubmission } = require('./acceptance')
      const acceptLink = generateContractLink(body.name, contract.customer, phone)
      const ds = await createDocuSealSubmission({
        templateId: contract.docuseal_template_id || body.docuseal_template_id || 1,
        email: email || '',
        name: contract.customer_name || contract.customer,
        phone: phone || '',
        values: {
          'Nom': contract.customer_name || '',
          'Contrat': body.name,
          'Mensualité': `${contract.monthly_rate}$`,
          'Durée': `${contract.duration_months} mois`,
          'Avantages': (contract.benefits || []).map(b => b.description).join(', '),
        },
        completedRedirectUrl: acceptLink + '?signed=1',
      })

      if (ds) {
        await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`, {
          method: 'PUT',
          body: JSON.stringify({
            status: 'Envoyé',
            acceptance_method: 'DocuSeal',
            docuseal_submission_id: ds.submissionId,
          }),
        })
        result = { method: 'docuseal', sign_url: ds.signUrl, submission_id: ds.submissionId }
      }
    }

    // Fallback: JWT SMS acceptance
    if (!result.method && phone) {
      const link = generateContractLink(body.name, contract.customer, phone)
      const { sendSmsInternal } = require('./twilio')
      const note = (contract.benefits || []).map(b =>
        `${b.description}: ${b.granted_price || 0}$ (rég. ${b.regular_price}$)`
      ).join('\n')

      const msg = `Gigafibre — Entente de service\n` +
        `${contract.monthly_rate}$/mois × ${contract.duration_months} mois\n` +
        (note ? note + '\n' : '') +
        `Accepter: ${link}`

      const sid = await sendSmsInternal(phone, msg)

      await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`, {
        method: 'PUT',
        body: JSON.stringify({ status: 'Envoyé', acceptance_method: 'JWT SMS' }),
      })
      result = { method: 'sms', sent_to: phone, sms_sid: sid, accept_link: link }
    }

    if (!result.method) {
      return json(res, 400, { error: 'No phone or DocuSeal configured for sending' })
    }

    log(`Contract ${body.name} sent via ${result.method}`)
    return json(res, 200, { ok: true, ...result })
  }

  // GET /contract/accept/:token — accept contract via JWT
  if (path.startsWith('/contract/accept/') && method === 'GET') {
    const token = path.replace('/contract/accept/', '')
    const payload = verifyJwt(token)
    if (!payload || payload.type !== 'contract_accept') {
      return json(res, 401, { error: 'Lien expiré ou invalide.' })
    }

    // Render simple acceptance page
    const contractName = payload.doc
    const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(contractName)}`)
    if (r.status !== 200) return json(res, 404, { error: 'Contrat introuvable' })
    const contract = r.data.data

    const html = renderAcceptancePage(contract, token)
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
    return res.end(html)
  }

  // POST /contract/confirm — confirm acceptance
  if (path === '/contract/confirm' && method === 'POST') {
    const body = await parseBody(req)
    if (!body.token) return json(res, 400, { error: 'token required' })

    const payload = verifyJwt(body.token)
    if (!payload || payload.type !== 'contract_accept') {
      return json(res, 401, { error: 'Lien expiré' })
    }

    const contractName = payload.doc
    const ip = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || ''
    const ua = req.headers['user-agent'] || ''
    const now = new Date().toISOString()

    await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(contractName)}`, {
      method: 'PUT',
      body: JSON.stringify({
        status: 'Actif',
        signed_at: now,
        signed_by: payload.sub,
        signature_proof: `IP: ${ip}\nUser-Agent: ${ua}\nDate: ${now}\nPhone: ${payload.phone || ''}`,
      }),
    })

    log(`Contract ${contractName} accepted by ${payload.sub}`)

    // Snapshot the contract NOW (before async chain-building) so the
    // post-sign acknowledgment SMS has everything it needs regardless of
    // what happens later.
    let signedContract = null
    try {
      const snap = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(contractName)}`)
      if (snap.status === 200) signedContract = snap.data?.data || null
    } catch { /* non-fatal */ }

    // Fire flow trigger: on_contract_signed — then guarantee tasks exist.
    //
    // Two layers:
    //   1. Flow Runtime (configurable) — an admin-defined Flow Template with
    //      trigger_event='on_contract_signed' can fan out Issue + Dispatch Job
    //      chains however they want.
    //   2. Built-in install chain (always, as a safety net) — creates one
    //      master Issue + the fiber_install 4-job chain, idempotent per
    //      contract (keyed on the Issue subject containing contractName).
    //
    // CRITICAL: we used to skip (2) when (1) ran at least one template, which
    // looked safe BUT silently broke signed contracts if the matched template
    // was disabled/broken/misconfigured (CTR-00008 did exactly that — a
    // stale FT-00005 "handled" the event, produced nothing, and we thought
    // we were done). So now we ALWAYS run (2); the idempotency guard inside
    // _createBuiltInInstallChain (look up existing Issue) means a healthy
    // flow template wins naturally — the built-in path short-circuits as
    // soon as it sees an Issue already linked to the contract.
    //
    // This runs in the background — the HTTP response returns right away.
    ;(async () => {
      try {
        const results = await _fireFlowTrigger('on_contract_signed', {
          doctype: 'Service Contract',
          docname: contractName,
          customer: payload.sub,
          variables: {
            contract_type: payload.contract_type,
            signed_at: now,
          },
        })
        const ranCount = Array.isArray(results) ? results.length : 0
        if (ranCount > 0) {
          log(`[contract] ${contractName}: Flow Runtime dispatched ${ranCount} template(s) — will verify outcome via built-in safety net`)
        } else {
          log(`[contract] ${contractName}: no active Flow Template for on_contract_signed — built-in install chain will run`)
        }
        // Always attempt the built-in chain. If a Flow Template already
        // produced an Issue for this contract, the idempotency check inside
        // _createBuiltInInstallChain will short-circuit with a log line.
        const chainResult = await _createBuiltInInstallChain(contractName, payload)

        // Post-sign acknowledgment SMS (bon de commande confirmation).
        // We fire this ONLY when we actually created the chain — if a
        // previous run already built it (idempotent skip), the customer
        // was already notified then and we don't double-send.
        if (chainResult?.created) {
          // Resolve address for the SMS body if the chain built it
          let address = ''
          if (signedContract?.service_location) {
            try {
              const locR = await erpFetch(`/api/resource/Service%20Location/${encodeURIComponent(signedContract.service_location)}`)
              if (locR.status === 200 && locR.data?.data) {
                const loc = locR.data.data
                address = [loc.address_line_1, loc.city].filter(Boolean).join(', ')
              }
            } catch { /* non-fatal */ }
          }
          await _sendPostSignAcknowledgment(contractName, signedContract, { address })
        }
      } catch (e) {
        log(`[contract] ${contractName}: on_contract_signed automation failed: ${e.message}`)
      }
    })()

    return json(res, 200, { ok: true, contract: contractName })
  }

  // GET /contract/audit-orphans[?fix=1]
  // Audits the contract ↔ subscription integrity. Reports:
  //   - subs_without_contract: Service Subscriptions where service_contract is empty
  //   - subs_dangling_contract: service_contract is set but the contract is gone
  //   - contracts_without_sub: Service Contract is Actif but has no service_subscription
  // When ?fix=1:
  //   - subs_without_contract → try to repair via customer + service_location match
  //     against a signed Service Contract; set service_contract on the sub and
  //     service_subscription on the contract if both sides are empty.
  //   - subs_dangling_contract → mark sub status='Annulé' + append an orphan note.
  //   - contracts_without_sub → no auto-fix (needs human review; report only).
  if (path === '/contract/audit-orphans' && method === 'GET') {
    const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
    const fix = url.searchParams.get('fix') === '1'
    return json(res, 200, await _auditOrphans(fix))
  }

  return json(res, 404, { error: 'Contract endpoint not found' })
}

// ─────────────────────────────────────────────────────────────────────────────
// Subscription orphan audit
// ─────────────────────────────────────────────────────────────────────────────
// The contract → sub relationship is bi-directional:
//   Service Contract.service_subscription       (custom Link)
//   Service Subscription.service_contract       (custom Link)
// Both are populated at signing (contracts.js:_createBuiltInInstallChain).
// This audit walks both directions and reports any asymmetry.
async function _auditOrphans (applyFixes = false) {
  const report = {
    checked_at: new Date().toISOString(),
    subs_without_contract: [],   // sub has no service_contract link (post-legacy)
    subs_dangling_contract: [],  // service_contract points at a deleted contract
    contracts_without_sub: [],   // contract is Actif but has no sub linked
    legacy_subs_count: 0,        // pre-contract-system migration — normal, not drift
    fixed: [],                   // actions taken when applyFixes=true
  }

  // Legacy-migration cutoff: subs created before this timestamp came from the
  // legacy PHP/MariaDB import (batch-inserted 2026-03-29 19:38:51). They're
  // real customer service records that pre-date the contract system, so
  // "service_contract = empty" is expected and not a drift signal.
  const LEGACY_CUTOFF = '2026-03-30T00:00:00Z'

  // 1. All Service Subscriptions (excluding Annulé — those are already dead).
  //    Pull in batches to avoid the 500-row cap.
  const subs = []
  let start = 0
  for (let page = 0; page < 20; page++) {   // cap at 10k rows defensively
    const rows = await erp.list('Service Subscription', {
      filters: [['status', '!=', 'Annulé']],
      fields: ['name', 'customer', 'service_location', 'service_contract', 'status', 'plan_name', 'creation'],
      limit: 500, start,
    })
    subs.push(...rows)
    if (rows.length < 500) break
    start += 500
  }

  // 2. Resolve which contracts actually exist (for the dangling-link check)
  const suspectContractNames = [...new Set(subs.map(s => s.service_contract).filter(Boolean))]
  const contractExists = {}
  for (const cn of suspectContractNames) {
    const c = await erp.get('Service Contract', cn)
    contractExists[cn] = !!c
  }

  // 3. Bucket the subs, skipping legacy-migration rows from the "orphan" buckets
  for (const sub of subs) {
    const isLegacy = new Date(sub.creation) < new Date(LEGACY_CUTOFF)
    if (!sub.service_contract) {
      if (isLegacy) { report.legacy_subs_count++; continue }
      report.subs_without_contract.push({ name: sub.name, customer: sub.customer, service_location: sub.service_location, status: sub.status, created: sub.creation })
    } else if (!contractExists[sub.service_contract]) {
      report.subs_dangling_contract.push({ name: sub.name, service_contract: sub.service_contract, customer: sub.customer, status: sub.status })
    }
  }

  // 3. Contracts that should have a sub but don't (Actif status, has monthly_rate > 0)
  const contracts = await erp.list('Service Contract', {
    filters: [['status', '=', 'Actif']],
    fields: ['name', 'customer', 'service_location', 'service_subscription', 'monthly_rate'],
    limit: 500,
  })
  for (const c of contracts) {
    if (Number(c.monthly_rate) === 0) continue   // no sub needed
    if (!c.service_subscription) {
      report.contracts_without_sub.push({ name: c.name, customer: c.customer, service_location: c.service_location, monthly_rate: c.monthly_rate })
    }
  }

  // 4. Apply fixes
  if (applyFixes) {
    // 4a. subs_without_contract: try to recover from customer+service_location
    for (const row of report.subs_without_contract) {
      const match = contracts.find(c => c.customer === row.customer && c.service_location === row.service_location && !c.service_subscription)
      if (!match) continue
      await erp.update('Service Subscription', row.name, { service_contract: match.name })
      await erp.update('Service Contract', match.name, { service_subscription: row.name })
      report.fixed.push({ action: 'linked', sub: row.name, contract: match.name })
      // Prevent reusing this contract for another sub
      match.service_subscription = row.name
    }
    // 4b. subs_dangling_contract: contract is gone → mark sub Annulé
    for (const row of report.subs_dangling_contract) {
      await erp.update('Service Subscription', row.name, {
        status: 'Annulé',
        cancellation_date: new Date().toISOString().slice(0, 10),
        cancellation_reason: `Contrat lié ${row.service_contract} supprimé — abonnement marqué orphelin par audit du ${new Date().toISOString().slice(0, 10)}.`,
      })
      report.fixed.push({ action: 'cancelled_orphan', sub: row.name, dead_contract: row.service_contract })
    }
  }

  report.summary = {
    subs_without_contract: report.subs_without_contract.length,
    subs_dangling_contract: report.subs_dangling_contract.length,
    contracts_without_sub: report.contracts_without_sub.length,
    legacy_subs: report.legacy_subs_count,
    fixed: report.fixed.length,
  }
  return report
}

// ─────────────────────────────────────────────────────────────────────────────
// Built-in install chain — fallback when no Flow Template handles the signing
// ─────────────────────────────────────────────────────────────────────────────
//
// Pulls the full contract doc to resolve service_location + address, then
// creates:
//   1. One Issue (master ticket) referencing the contract, visible in ERPNext
//      Issue list for traceability. Links to customer.
//   2. N Dispatch Jobs from project-templates.js::fiber_install — the first
//      is born "open", rest are "On Hold" until their parent completes (chain
//      walk via dispatch.unblockDependents).
//
// Idempotency: we tag the Issue with the contract name in subject. If we
// detect an existing Issue for this contract+customer, we skip to avoid
// duplicate chains on retried webhooks.
async function _createBuiltInInstallChain (contractName, payload) {
  // 1. Fetch full contract to get service_location, address, customer_name
  const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(contractName)}`)
  if (r.status !== 200 || !r.data?.data) {
    log(`[contract] built-in chain skipped — contract ${contractName} not readable`)
    return
  }
  const contract = r.data.data
  const customer = contract.customer || payload.sub
  const serviceLocation = contract.service_location || ''
  const customerName = contract.customer_name || customer
  const contractType = contract.contract_type || payload.contract_type || 'Résidentiel'

  // Resolve address for field techs (Dispatch Job needs a geocodable address)
  let address = ''
  if (serviceLocation) {
    try {
      const locR = await erpFetch(`/api/resource/Service%20Location/${encodeURIComponent(serviceLocation)}`)
      if (locR.status === 200 && locR.data?.data) {
        const loc = locR.data.data
        address = [loc.address_line_1, loc.address_line_2, loc.city, loc.province, loc.postal_code]
          .filter(Boolean).join(', ')
      }
    } catch (e) { log(`[contract] address lookup failed: ${e.message}`) }
  }

  // 2. Idempotency check — did we already build a chain for this contract?
  try {
    const dupFilters = JSON.stringify([['subject', 'like', `%${contractName}%`], ['customer', '=', customer]])
    const dup = await erpFetch(`/api/resource/Issue?filters=${encodeURIComponent(dupFilters)}&limit_page_length=1`)
    if (dup.status === 200 && Array.isArray(dup.data?.data) && dup.data.data.length) {
      log(`[contract] ${contractName}: chain already exists (Issue ${dup.data.data[0].name}) — skipping`)
      return { created: false, issue: dup.data.data[0].name, jobs: [], reason: 'already_exists' }
    }
  } catch { /* non-fatal — proceed to create */ }

  // 3. Create master Issue
  const issuePayload = {
    doctype: 'Issue',
    subject: `Activation contrat ${contractName} — ${customerName}`,
    description:
      `Contrat ${contractName} accepté le ${new Date(payload.signed_at || Date.now()).toLocaleString('fr-CA')}.\n` +
      `Type: ${contractType}\n` +
      `Client: ${customerName} (${customer})\n` +
      (serviceLocation ? `Emplacement: ${serviceLocation}\n` : '') +
      (address ? `Adresse: ${address}\n` : '') +
      `\nCe ticket regroupe les tâches d'activation créées automatiquement.`,
    priority: 'Medium',
    issue_type: 'Activation',
    status: 'Open',
    customer,
  }
  let issueName = ''
  try {
    const ir = await erpFetch('/api/resource/Issue', {
      method: 'POST',
      body: JSON.stringify(issuePayload),
    })
    if (ir.status === 200 || ir.status === 201) {
      issueName = ir.data?.data?.name || ''
      log(`[contract] ${contractName}: master Issue ${issueName} created`)
    } else {
      log(`[contract] ${contractName}: Issue creation returned ${ir.status} — continuing with jobs only`)
    }
  } catch (e) {
    log(`[contract] ${contractName}: Issue creation failed: ${e.message} — continuing with jobs only`)
  }

  // 4. Pick the install template. Order of precedence:
  //    (a) contract.install_template if set explicitly
  //    (b) contract_type → template mapping (Test-Simple, Test-Parallèle, etc.)
  //    (c) fiber_install as universal default
  const { getTemplateSteps, chooseTemplate } = require('./project-templates')
  const templateId = chooseTemplate(contract)
  const steps = getTemplateSteps(templateId)
  if (!steps.length) {
    log(`[contract] ${contractName}: no "${templateId}" template — chain aborted`)
    return { created: false, issue: issueName, jobs: [], reason: 'no_template' }
  }
  log(`[contract] ${contractName}: using template "${templateId}" (${steps.length} step${steps.length > 1 ? 's' : ''})`)

  const { createDeferredJobs } = require('./acceptance')
  // scheduled_date defaults to today so the jobs land on TODAY's dispatch
  // board. Dispatcher reschedules per capacity. Without this, jobs had
  // scheduled_date=null and disappeared from the dispatch queue.
  const today = new Date().toISOString().slice(0, 10)
  const ctx = {
    customer,
    service_location: serviceLocation,
    address,
    issue: issueName,
    scheduled_date: today,
    order_source: 'Contract',
  }
  let jobs = []
  try {
    jobs = await createDeferredJobs(steps, ctx, contractName)
    log(`[contract] ${contractName}: created ${jobs.length} chained Dispatch Job(s) under Issue ${issueName || '(none)'}`)
  } catch (e) {
    log(`[contract] ${contractName}: chained job creation failed: ${e.message}`)
  }

  // 5. Create the pending Service Subscription.
  //    Without this, activateSubscriptionForJob() on the last-job-completion
  //    finds nothing to activate and no prorated invoice is ever emitted —
  //    this was the CTR-00008 failure mode. The sub is born 'En attente' and
  //    gets flipped to 'Actif' + prorated by dispatch when the chain finishes.
  let subscriptionName = ''
  if (serviceLocation && contract.monthly_rate && Number(contract.monthly_rate) !== 0) {
    try {
      const subPayload = {
        customer,
        customer_name: customerName,
        service_location: serviceLocation,
        service_contract: contractName,   // reverse link — lets us detect orphans
        status: 'En attente',
        service_category: _inferServiceCategory(contract),
        plan_name: await _inferPlanName(contract),
        monthly_price: Number(contract.monthly_rate),
        billing_cycle: 'Mensuel',
        contract_duration: Number(contract.duration_months || 0),
        start_date: today,
        notes: `Créé automatiquement à la signature du contrat ${contractName}`,
      }
      const subRes = await erp.create('Service Subscription', subPayload)
      if (subRes.ok && subRes.name) {
        subscriptionName = subRes.name
        log(`[contract] ${contractName}: Service Subscription ${subscriptionName} (En attente) created`)
        // Link it back on the contract via our custom field. (The stock
        // `subscription` field on Service Contract is a Link to the built-in
        // ERPNext Subscription doctype — a different thing. We added a custom
        // `service_subscription` Link field that points at our doctype.)
        const linkRes = await erp.update('Service Contract', contractName, { service_subscription: subscriptionName })
        if (!linkRes.ok) log(`[contract] ${contractName}: back-link failed — ${linkRes.error || 'unknown'}`)
      } else {
        log(`[contract] ${contractName}: subscription creation failed — ${subRes.error || 'unknown'}`)
      }
    } catch (e) {
      log(`[contract] ${contractName}: subscription creation threw — ${e.message}`)
    }
  } else if (!serviceLocation) {
    log(`[contract] ${contractName}: no service_location — skipping subscription creation`)
  } else {
    log(`[contract] ${contractName}: no monthly_rate — skipping subscription creation`)
  }

  return { created: true, issue: issueName, jobs, subscription: subscriptionName, template: templateId, scheduled_date: today }
}

// ── Helpers to derive subscription metadata from a Service Contract ─────────
// The Service Contract carries monthly_rate + contract_type but not a specific
// plan name or service category, so we infer from free-text markers. Keep this
// conservative — for contracts we don't recognize, default to 'Internet' since
// that's our primary product.
function _inferServiceCategory (contract) {
  const hay = `${contract.contract_type || ''} ${contract.invoice_note || ''}`.toLowerCase()
  if (/iptv|t[ée]l[ée]|cha[îi]ne/.test(hay))    return 'IPTV'
  if (/voip|t[ée]l[ée]phon|ligne|pbx/.test(hay)) return 'VoIP'
  if (/h[ée]bergement|hosting|cloud|email/.test(hay)) return 'Hébergement'
  return 'Internet'
}

async function _inferPlanName (contract) {
  // Priority 1: the source Quotation's first positive-rate item. That's
  // where the actual product lives ("Internet Megafibre 80 Mbps", etc.) —
  // what the customer signed for, what we want on the invoice line.
  if (contract.quotation) {
    try {
      const q = await erp.get('Quotation', contract.quotation)
      const items = q && Array.isArray(q.items) ? q.items : []
      // First item with rate > 0 is the service (skip negative-rate rabais rows)
      const plan = items.find(i => Number(i.rate) > 0)
      if (plan && (plan.item_name || plan.item_code)) {
        return String(plan.item_name || plan.item_code).slice(0, 140)
      }
    } catch { /* fall through to benefit / generic */ }
  }
  // Priority 2: a benefit row description (legacy contracts without a
  // quotation link sometimes captured the plan there).
  const firstBenefit = (contract.benefits || [])[0]
  if (firstBenefit?.description) return String(firstBenefit.description).slice(0, 140)
  // Last resort: build a generic label from the contract type + rate.
  return `${contract.contract_type || 'Service'} ${contract.monthly_rate}$/mois`
}

// ─────────────────────────────────────────────────────────────────────────────
// Post-sign acknowledgment SMS — customer gets a confirmation that the order
// (bon de commande) was received and what happens next.
// ─────────────────────────────────────────────────────────────────────────────
async function _sendPostSignAcknowledgment (contractName, contract, extras = {}) {
  if (!contract) return
  const customer = contract.customer
  if (!customer) return

  // Resolve phone. Priority: contract.customer_phone → Customer.cell_phone
  //                         → Customer.mobile_no. Our legacy-migrated
  // Customer records use `cell_phone` (not `mobile_no`, which is Frappe's
  // default). Check both so we work for old + new records.
  let phone = contract.customer_phone || ''
  let customerName = contract.customer_name || customer
  let email = ''
  if (!phone || !customerName) {
    try {
      const cr = await erpFetch(`/api/resource/Customer/${encodeURIComponent(customer)}?fields=${encodeURIComponent(JSON.stringify(['customer_name','mobile_no','cell_phone','email_id','email_billing']))}`)
      const d = cr.data?.data || {}
      phone = phone || d.cell_phone || d.mobile_no || ''
      email = d.email_billing || d.email_id || ''
      customerName = customerName || d.customer_name || customer
    } catch { /* non-fatal */ }
  }
  if (!phone) {
    log(`[contract] ${contractName}: no phone on file — skipping acknowledgment SMS`)
    return
  }

  const monthly = contract.monthly_rate ? `${contract.monthly_rate}$/mois` : ''
  const duration = contract.duration_months ? ` × ${contract.duration_months} mois` : ''
  const planLine = monthly ? `${monthly}${duration}` : ''
  const address = extras.address || ''
  const firstName = String(customerName).split(/\s+/)[0] || ''

  // Short SMS body — Twilio caps at 1600 chars but single-segment (<160) is
  // cheapest and renders best. Keep essentials only.
  const msg = [
    `Gigafibre — Bon de commande ${contractName} ✓`,
    `Merci ${firstName}, votre commande est reçue.`,
    planLine ? `Service: ${planLine}` : '',
    address ? `Adresse: ${address}` : '',
    `Notre équipe planifie votre installation et vous contactera sous peu.`,
    `Support: 438-231-3838`,
  ].filter(Boolean).join('\n')

  try {
    const { sendSmsInternal } = require('./twilio')
    const sid = await sendSmsInternal(phone, msg)
    log(`[contract] ${contractName}: acknowledgment SMS sent to ${phone} (${sid})`)
  } catch (e) {
    log(`[contract] ${contractName}: acknowledgment SMS failed: ${e.message}`)
  }
}

async function createTerminationInvoice (contract, calc, reason) {
  const items = []
  const today = new Date().toISOString().slice(0, 10)

  if (contract.contract_type === 'Commercial') {
    items.push({
      item_name: 'Frais de résiliation anticipée',
      description: `Résiliation anticipée du contrat ${contract.name}. ` +
        `${calc.months_remaining} mois restants × ${contract.monthly_rate}$/mois.` +
        (reason ? ` Raison: ${reason}` : ''),
      qty: calc.months_remaining,
      rate: contract.monthly_rate,
      income_account: '', // will use default
    })
  } else {
    // Résidentiel : solde de l'installation financée (service rendu) — PAS une pénalité.
    if (calc.install_remaining > 0) {
      items.push({
        item_name: "Solde de l'installation (financement restant)",
        description: `Installation ${calc.install_fee}$ financée sur ${contract.duration_months || 24} mois. ` +
          `Solde restant après ${calc.months_elapsed} mois : ${calc.install_remaining}$. ` +
          `Service déjà réalisé — aucune pénalité de résiliation, aucune promotion récupérée.`,
        qty: 1,
        rate: calc.install_remaining,
      })
    }
  }

  if (!items.length) return null

  const payload = {
    customer: contract.customer,
    posting_date: today,
    due_date: today,
    items,
    remarks: `Facture de résiliation anticipée — Contrat ${contract.name}`,
  }

  try {
    const r = await erpFetch('/api/resource/Sales%20Invoice', {
      method: 'POST',
      body: JSON.stringify(payload),
    })
    if (r.status === 200 && r.data?.data) {
      return r.data.data.name
    }
  } catch (e) {
    log('Termination invoice creation failed:', e.message)
  }
  return null
}

function renderAcceptancePage (contract, token) {
  const benefitRows = (contract.benefits || []).map(b => {
    return `<tr>
      <td>${b.description}</td>
      <td class="r"><s style="color:#9ca3af">${b.regular_price || 0} $</s> → <strong>${b.granted_price || 0} $</strong></td>
    </tr>`
  }).join('')

  const duration = contract.duration_months || 24
  const totalBenefit = (contract.benefits || []).reduce(
    (s, b) => s + ((b.regular_price || 0) - (b.granted_price || 0)), 0,
  )

  const TARGO_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35" width="130" height="28"><path fill="#019547" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path fill="#019547" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path fill="#019547" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path fill="#019547" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path fill="#019547" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>`

  const isCommercial = contract.contract_type === 'Commercial'
  const installFee = contract.install_fee || 0
  const installRegular = contract.install_regular || 0
  const installMonthly = installFee ? round2(installFee / duration) : 0
  const monthlyRegular = contract.monthly_regular || 0
  const mr = monthlyRegular > (contract.monthly_rate || 0)

  return `<!DOCTYPE html><html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Entente de service — TARGO</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f5f7fa;color:#1a1a1a;padding:16px;line-height:1.5}
.card{background:#fff;border-radius:14px;padding:24px;max-width:560px;margin:0 auto;box-shadow:0 4px 16px rgba(0,0,0,.06)}
.brand{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:2px solid #019547;margin-bottom:18px}
.brand .num{margin-left:auto;font-size:12px;color:#888;text-align:right}
h1{font-size:20px;font-weight:800;color:#019547;margin-bottom:4px;letter-spacing:.2px}
.sub{color:#6b7280;font-size:13px;margin-bottom:18px}

.field{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #f1f5f9}
.field:last-child{border-bottom:none}
.field .label{color:#6b7280;font-size:13px}
.field .value{font-weight:600;font-size:14px;text-align:right}

h3{font-size:14px;color:#019547;margin-top:18px;margin-bottom:8px;font-weight:700;text-transform:uppercase;letter-spacing:.3px}

table{width:100%;border-collapse:collapse;margin:6px 0 10px 0;font-size:12.5px}
th{text-align:left;padding:6px 8px;background:#f4fff6;color:#00733a;font-weight:700;border-bottom:2px solid #019547}
th.r,td.r{text-align:right}
td{padding:6px 8px;border-bottom:1px solid #f1f5f9}
tfoot td{font-weight:700;color:#00733a;background:#f4fff6;border-top:1px solid #019547;border-bottom:none}

.callout{background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:12px 14px;margin:14px 0;font-size:13px;line-height:1.55}
.callout .head{color:#b45309;font-weight:700;margin-bottom:4px}

.legal{font-size:12px;color:#6b7280;margin-top:14px;line-height:1.55}
.legal a{color:#019547;text-decoration:none;font-weight:600}

.btn{display:block;width:100%;padding:16px;background:#019547;color:#fff;border:none;border-radius:12px;font-size:16px;font-weight:700;cursor:pointer;margin-top:18px;letter-spacing:.3px}
.btn:active{background:#01733a}
.btn:disabled{background:#9ca3af;cursor:wait}

.ok{text-align:center;padding:40px 20px}
.ok .check{width:64px;height:64px;border-radius:50%;background:#019547;color:#fff;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:32px;font-weight:700}
.ok h2{color:#019547;margin-bottom:8px;font-size:22px}
.ok p{color:#4b5563;font-size:14px}
#result{display:none}
</style></head><body>

<div class="card" id="contract">
  <div class="brand">
    ${TARGO_LOGO_SVG}
    <div class="num">Référence<br><strong>${contract.name || ''}</strong></div>
  </div>

  <h1>Récapitulatif de votre service</h1>
  <div class="sub">Un résumé clair des modalités convenues — rien de plus.</div>

  <div class="intro" style="background:#f4fff6;border:1px solid #bde5cb;border-radius:10px;padding:12px 14px;margin-bottom:16px;font-size:13px;color:#00733a;line-height:1.55">
    Voici un récapitulatif de votre service Gigafibre. Il reprend ce que nous avons convenu ensemble pour que tout soit clair des deux côtés. Aucune surprise — tout est écrit noir sur blanc ci-dessous.
  </div>

  <div class="field"><span class="label">Client</span><span class="value">${escapeHtml(contract.customer_name || contract.customer || '')}</span></div>
  <div class="field"><span class="label">Mensualité</span><span class="value">${mr ? `<s style="color:#9ca3af;font-weight:400">${monthlyRegular} $</s> ` : ''}${contract.monthly_rate || 0} $/mois <span style="color:#9ca3af;font-weight:400;font-size:12px">(+taxes)</span></span></div>
  <div class="field"><span class="label">Durée</span><span class="value">${duration} mois</span></div>
  <div class="field"><span class="label">Début prévu</span><span class="value">${contract.start_date || 'À déterminer'}</span></div>

  ${installFee ? `
  <h3>Détail mensuel</h3>
  <table>
    <tbody>
      <tr><td>Forfait${mr ? ` (<s style="color:#9ca3af">${monthlyRegular} $</s>)` : ''}</td><td class="r">${contract.monthly_rate || 0} $</td></tr>
      <tr><td>Installation ${installRegular > installFee ? `(<s style="color:#9ca3af">${installRegular} $</s> ${installFee} $)` : `(${installFee} $)`} — financement ${duration} mois</td><td class="r">+${installMonthly} $</td></tr>
      <tr><td>Crédit installation nouveau client (${duration} mois)</td><td class="r" style="color:#019547">−${installMonthly} $</td></tr>
    </tbody>
    <tfoot><tr><td>Total mensuel</td><td class="r">${contract.monthly_rate || 0} $</td></tr></tfoot>
  </table>
  <div style="font-size:12px;color:#6b7280;margin:-2px 0 4px">Votre <strong>installation${installRegular > installFee ? ` (valeur ${installRegular} $ → ${installFee} $)` : ''}</strong> est <strong>financée et créditée chaque mois pendant ${duration} mois</strong> — 0 $ pour vous tant que vous demeurez client. En cas de départ avant ${duration} mois, seul le <strong>solde restant de l'installation</strong> (service déjà réalisé) demeure dû, sans pénalité.</div>` : ''}

  ${benefitRows ? `
  <h3>Promotions appliquées</h3>
  <table>
    <tbody>${benefitRows}</tbody>
    <tfoot>
      <tr><td>Valeur des promotions (non récupérables)</td><td class="r">${round2(totalBenefit)} $</td></tr>
    </tfoot>
  </table>` : ''}

  <div class="callout">
    <div class="head">Changement avant ${duration} mois ?</div>
    ${isCommercial
      ? `Les mensualités restantes du terme demeurent dues (${contract.monthly_rate || 0} $/mois × mois restants).`
      : `<strong>Aucune pénalité de résiliation.</strong> ${installFee ? `L'installation (${installFee} $) est financée sur ${duration} mois : seul le <strong>solde restant de l'installation</strong> — un service déjà réalisé — demeure dû. ` : ''}Vos promotions et la carte-cadeau ne sont <strong>jamais récupérées</strong> ; le rabais nouveau client cesse simplement.`}
  </div>

  <div class="legal">
    En cliquant <strong>« J'accepte »</strong>, vous confirmez que les informations ci-dessus correspondent à ce que nous avons convenu. Les <a href="https://www.targo.ca/conditions" target="_blank">conditions complètes du service</a> (facturation, équipement, Loi 25, juridiction du Québec) s'appliquent également. Votre confirmation est horodatée.
  </div>

  <button class="btn" onclick="accept()">J'accepte ce récapitulatif</button>
</div>

<div class="card ok" id="result">
  <div class="check">✓</div>
  <h2>C'est confirmé !</h2>
  <p>Merci ! Une copie du récapitulatif vous sera envoyée par courriel sous peu.</p>
</div>

<script>
async function accept(){
  const btn=document.querySelector('.btn');btn.disabled=true;btn.textContent='Enregistrement...';
  try{
    const r=await fetch('/contract/confirm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:'${token}'})});
    const d=await r.json();
    if(d.ok){document.getElementById('contract').style.display='none';document.getElementById('result').style.display='block';}
    else{btn.textContent='Erreur : '+(d.error||'Réessayez');btn.disabled=false;}
  }catch(e){btn.textContent='Erreur réseau';btn.disabled=false;}
}
</script></body></html>`
}

function escapeHtml (s) {
  return String(s || '').replace(/[&<>"']/g, c => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[c]))
}

function monthsBetween (d1, d2) {
  return (d2.getFullYear() - d1.getFullYear()) * 12 + (d2.getMonth() - d1.getMonth())
}

function addMonths (dateStr, months) {
  const d = new Date(dateStr)
  d.setMonth(d.getMonth() + months)
  return d.toISOString().slice(0, 10)
}

function round2 (v) { return Math.round(v * 100) / 100 }

module.exports = {
  handle,
  calculateTerminationFee,
  generateInvoiceNote,
  generateContractLink,
  renderAcceptancePage,
  // Exposed so ops tools / one-shot scripts can retro-create the install chain
  // for contracts that were signed before the built-in fallback existed.
  createBuiltInInstallChain: _createBuiltInInstallChain,
  sendPostSignAcknowledgment: _sendPostSignAcknowledgment,
}
