// buildium-payee-backfill
// One-off-style backfill: the GL CSV/pull imports only captured Buildium's
// per-entry Description (the transaction *type* — "Charge", "Payment", "Bill",
// "Check 1153"), so imported journal_entries carry no payor/payee. This pulls
// Buildium's GL *transactions* for a company's window, extracts the party name
// (vendor for bills/checks, owner/unit/tenant for charges/payments), and writes
// it onto accounting.journal_entries.description, matched by external_id (the
// Buildium transaction id).
//
// Body:
//   companyId   (required) accounting.companies.id
//   dateFrom    (required) ISO date, inclusive
//   dateTo      (optional) ISO date, inclusive (default today)
//   mode        "sample" | "dry" | "apply"   (default "dry")
//                 sample — return raw Buildium GL-transaction JSON, write nothing
//                 dry    — compute new descriptions + counts, write nothing
//                 apply  — update journal_entries.description
//   sampleSize  (sample mode) number of raw transactions to return (default 5)
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
const BUILDIUM_BASE = "https://api.buildium.com";
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
const norm = (v: unknown) => String(v ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
const json = (b: unknown, s = 200) =>
  new Response(JSON.stringify(b), { status: s, headers: { ...corsHeaders, "Content-Type": "application/json" } });

async function buildiumFetch(path: string, clientId: string, clientSecret: string, params?: URLSearchParams) {
  const url = new URL(`${BUILDIUM_BASE}${path}`);
  if (params) url.search = params.toString();
  for (let attempt = 0; attempt < 4; attempt++) {
    const res = await fetch(url.toString(), {
      headers: { "x-buildium-client-id": clientId, "x-buildium-client-secret": clientSecret, Accept: "application/json" },
    });
    if (res.ok) return res.json();
    const text = await res.text();
    if ((res.status === 429 || res.status >= 500) && attempt < 3) {
      const ra = Number(res.headers.get("Retry-After") ?? "");
      await wait(Number.isFinite(ra) && ra > 0 ? ra * 1000 : 600 * Math.pow(2, attempt));
      continue;
    }
    throw new Error(`Buildium ${path} [${res.status}]: ${text}`);
  }
  throw new Error(`Buildium ${path} failed after retries`);
}

async function buildiumFetchAll(path: string, clientId: string, clientSecret: string, base?: URLSearchParams) {
  const all: any[] = [];
  let offset = 0;
  const limit = 1000;
  while (true) {
    const params = new URLSearchParams(base);
    params.set("offset", String(offset));
    params.set("limit", String(limit));
    const page = await buildiumFetch(path, clientId, clientSecret, params);
    if (!Array.isArray(page) || page.length === 0) break;
    all.push(...page);
    offset += page.length;
  }
  return all;
}

// Build unitId → owner name(s) for one association. /v1/associations/owners
// returns owners with an OwnershipAccounts array; each entry ties the owner to
// a UnitId within a specific AssociationId. A unit can have co-owners, so we
// collect unique names and join up to two.
async function buildOwnerByUnit(clientId: string, clientSecret: string, bAssocId: string) {
  const params = new URLSearchParams();
  params.append("associationids", String(bAssocId));
  const owners = await buildiumFetchAll("/v1/associations/owners", clientId, clientSecret, params);
  const namesByUnit = new Map<string, string[]>();
  for (const o of owners) {
    const name = [o?.FirstName, o?.LastName].filter(Boolean).join(" ").trim();
    if (!name) continue;
    for (const oa of o?.OwnershipAccounts ?? []) {
      if (String(oa?.AssociationId ?? "") !== String(bAssocId)) continue;
      const uid = String(oa?.UnitId ?? "");
      if (!uid) continue;
      const arr = namesByUnit.get(uid) ?? [];
      if (!arr.includes(name)) arr.push(name);
      namesByUnit.set(uid, arr);
    }
  }
  const ownerByUnit = new Map<string, string>();
  for (const [uid, arr] of namesByUnit) ownerByUnit.set(uid, arr.slice(0, 2).join(" & "));
  return ownerByUnit;
}

// Extract the party name from a Buildium GL transaction.
//  • Bill / Check / EFT / many Payments expose PaymentDetail.Payee.Name
//    (vendor or owner) directly.
//  • Owner-side Charge / ApplyDeposit carry only UnitId → resolve to owner name.
//  • Fall back to the raw UnitNumber so something identifying still shows.
function extractPayee(tx: any, ownerByUnit: Map<string, string>): string | null {
  const direct = tx?.PaymentDetail?.Payee?.Name;
  if (typeof direct === "string" && direct.trim()) return direct.trim();

  const uid = tx?.UnitId != null ? String(tx.UnitId) : "";
  if (uid && ownerByUnit.has(uid)) return ownerByUnit.get(uid)!;

  const unitNo = tx?.UnitNumber;
  if (typeof unitNo === "string" && unitNo.trim()) return `Unit ${unitNo.trim()}`;
  return null;
}

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
  try {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader?.startsWith("Bearer ")) return json({ error: "Unauthorized" }, 401);

    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
    const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
    const clientId = Deno.env.get("BUILDIUM_API_KEY") ?? "";
    const clientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? "";
    if (!clientId || !clientSecret) return json({ error: "Buildium API credentials not configured" }, 500);

    const body = await req.json().catch(() => ({} as any));
    const companyId = String(body?.companyId ?? "");
    const dateFrom = String(body?.dateFrom ?? "");
    const dateTo = String(body?.dateTo ?? new Date().toISOString().slice(0, 10));
    const mode = String(body?.mode ?? "dry");
    const sampleSize = Number(body?.sampleSize ?? 5);
    if (!companyId || !dateFrom) return json({ error: "companyId and dateFrom are required" }, 400);

    const supabase = createClient(supabaseUrl, serviceKey);

    // Resolve the Buildium association by the local association's name.
    const { data: company, error: cErr } = await supabase.schema("accounting")
      .from("companies").select("id, name, association_id").eq("id", companyId).maybeSingle();
    if (cErr) throw cErr;
    if (!company) return json({ error: "company not found" }, 404);

    const { data: assoc } = await supabase.from("associations").select("id, name").eq("id", company.association_id).maybeSingle();
    const buildiumAssocs = await buildiumFetchAll("/v1/associations", clientId, clientSecret);
    const bAssocId = buildiumAssocs.find((a: any) => norm(a.Name) === norm(assoc?.name))?.Id;
    if (!bAssocId) return json({ error: `No Buildium association matches "${assoc?.name}"` }, 400);

    // /v1/generalledger/transactions requires glaccountids. Gather every GL
    // account id for the chart (children nest under SubAccounts) plus inactive
    // bank GL accounts, then chunk them into the transactions call (the URL
    // can't hold the whole chart at once). Dedupe transactions by Id.
    const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
    const glIds = new Set<string>();
    const addGl = (g: any) => {
      if (!g?.Id) return;
      glIds.add(String(g.Id));
      if (Array.isArray(g.SubAccounts)) for (const s of g.SubAccounts) addGl(s);
    };
    for (const g of glAccounts) addGl(g);
    for (const b of await buildiumFetchAll("/v1/bankaccounts", clientId, clientSecret)) {
      const gid = String(b.GLAccount?.Id ?? b.Id ?? "");
      if (gid) glIds.add(gid);
    }
    const allGlIds = [...glIds];
    const CHUNK = 50;

    // report mode: pull /v1/generalledger (signed amounts, debit +/credit −) and
    // sum net movement per account for the window, to diff against our books.
    if (mode === "report") {
      const glMeta = new Map<string, { number: string; name: string; type: string }>();
      const collectMeta = (g: any) => {
        if (g?.Id) glMeta.set(String(g.Id), { number: String(g.AccountNumber ?? ""), name: String(g.Name ?? ""), type: String(g.Type ?? "") });
        if (Array.isArray(g.SubAccounts)) for (const s of g.SubAccounts) collectMeta(s);
      };
      for (const g of glAccounts) collectMeta(g);
      const netById = new Map<string, number>();
      const txnIds = new Set<string>();
      for (let i = 0; i < allGlIds.length; i += CHUNK) {
        const params = new URLSearchParams();
        params.set("accountingbasis", "Accrual");
        params.set("startdate", dateFrom);
        params.set("enddate", dateTo);
        params.set("entitytype", "Association");
        params.set("entityid", String(bAssocId));
        for (const id of allGlIds.slice(i, i + CHUNK)) params.append("glaccountids", id);
        const ledgers = await buildiumFetchAll("/v1/generalledger", clientId, clientSecret, params);
        for (const ledger of ledgers) {
          const gid = String(ledger.GLAccountId ?? ledger.GLAccount?.Id ?? "");
          for (const e of ledger.Entries ?? []) {
            netById.set(gid, (netById.get(gid) ?? 0) + (Number(e.Amount) || 0));
            if (e.Id) txnIds.add(String(e.Id));
          }
        }
      }
      const rows = [...netById.entries()]
        .map(([gid, net]) => ({ ...(glMeta.get(gid) ?? { number: "", name: gid, type: "" }), net: Math.round(net * 100) / 100 }))
        .filter((r) => Math.abs(r.net) > 0.005)
        .sort((a, b) => a.number.localeCompare(b.number));
      return json({ mode, company: company.name, window: [dateFrom, dateTo], distinctTxns: txnIds.size, accounts: rows.length, rows });
    }

    // account mode: list every /v1/generalledger entry for a single account
    // (by AccountNumber) in the window — used to chase per-account discrepancies.
    if (mode === "account") {
      const wantNum = String(body?.accountNumber ?? "");
      let gid = "";
      const findGl = (g: any) => {
        if (String(g?.AccountNumber ?? "") === wantNum) gid = String(g.Id);
        if (Array.isArray(g.SubAccounts)) for (const s of g.SubAccounts) findGl(s);
      };
      for (const g of glAccounts) findGl(g);
      if (!gid) return json({ error: `account number ${wantNum} not found in Buildium chart` }, 404);
      const params = new URLSearchParams();
      params.set("accountingbasis", "Accrual");
      params.set("startdate", dateFrom);
      params.set("enddate", dateTo);
      params.set("entitytype", "Association");
      params.set("entityid", String(bAssocId));
      params.append("glaccountids", gid);
      const ledgers = await buildiumFetchAll("/v1/generalledger", clientId, clientSecret, params);
      const entries: any[] = [];
      let net = 0;
      for (const ledger of ledgers) for (const e of ledger.Entries ?? []) {
        net += Number(e.Amount) || 0;
        entries.push({ txnId: String(e.Id ?? ""), date: String(e.Date ?? "").split("T")[0], type: e.TransactionType, amount: Number(e.Amount) || 0, memo: e.Memo ?? e.Description ?? "" });
      }
      entries.sort((a, b) => a.date.localeCompare(b.date));
      return json({ mode, accountNumber: wantNum, gid, window: [dateFrom, dateTo], net: Math.round(net * 100) / 100, count: entries.length, entries });
    }

    // txn mode: reconstruct one transaction's full double-entry by scanning the
    // ledger across all accounts for entries carrying its id.
    if (mode === "txn") {
      const wantId = String(body?.txnId ?? "");
      const numByGid = new Map<string, { number: string; name: string }>();
      const collect = (g: any) => {
        if (g?.Id) numByGid.set(String(g.Id), { number: String(g.AccountNumber ?? ""), name: String(g.Name ?? "") });
        if (Array.isArray(g.SubAccounts)) for (const s of g.SubAccounts) collect(s);
      };
      for (const g of glAccounts) collect(g);
      const lines: any[] = [];
      for (let i = 0; i < allGlIds.length; i += CHUNK) {
        const params = new URLSearchParams();
        params.set("accountingbasis", "Accrual");
        params.set("startdate", dateFrom);
        params.set("enddate", dateTo);
        params.set("entitytype", "Association");
        params.set("entityid", String(bAssocId));
        for (const id of allGlIds.slice(i, i + CHUNK)) params.append("glaccountids", id);
        const ledgers = await buildiumFetchAll("/v1/generalledger", clientId, clientSecret, params);
        for (const ledger of ledgers) {
          const gid = String(ledger.GLAccountId ?? ledger.GLAccount?.Id ?? "");
          for (const e of ledger.Entries ?? []) {
            if (String(e.Id ?? "") !== wantId) continue;
            const m = numByGid.get(gid) ?? { number: "", name: gid };
            lines.push({ account: `${m.number} ${m.name}`.trim(), amount: Number(e.Amount) || 0, date: String(e.Date ?? "").split("T")[0], type: e.TransactionType, memo: e.Memo ?? e.Description ?? "" });
          }
        }
      }
      const sum = Math.round(lines.reduce((s, l) => s + l.amount, 0) * 100) / 100;
      return json({ mode, txnId: wantId, balanceCheck: sum, lineCount: lines.length, lines });
    }

    // Pull GL transactions for the window. /v1/generalledger/transactions is the
    // Journal view — one row per transaction with its Id, type and party data.
    const txById = new Map<string, any>();
    for (let i = 0; i < allGlIds.length; i += CHUNK) {
      const params = new URLSearchParams();
      params.set("startdate", dateFrom);
      params.set("enddate", dateTo);
      params.set("entitytype", "Association");
      params.set("entityid", String(bAssocId));
      for (const id of allGlIds.slice(i, i + CHUNK)) params.append("glaccountids", id);
      const page = await buildiumFetchAll("/v1/generalledger/transactions", clientId, clientSecret, params);
      for (const tx of page) { const id = String(tx?.Id ?? ""); if (id) txById.set(id, tx); }
    }
    const txns = [...txById.values()];

    if (mode === "sample") {
      const ownersParams = new URLSearchParams();
      ownersParams.append("associationids", String(bAssocId));
      const owners = await buildiumFetchAll("/v1/associations/owners", clientId, clientSecret, ownersParams).catch((e) => [{ ownersError: String(e) }]);
      return json({
        mode, company: company.name, bAssocId, window: [dateFrom, dateTo],
        pulled: txns.length,
        ownersCount: Array.isArray(owners) ? owners.length : 0,
        ownersSample: (owners || []).slice(0, 3),
        sample: txns.slice(0, sampleSize),
      });
    }

    const ownerByUnit = await buildOwnerByUnit(clientId, clientSecret, String(bAssocId));

    // Map Buildium tx id → extracted party name.
    const payeeById = new Map<string, string>();
    const byType: Record<string, { total: number; named: number }> = {};
    for (const tx of txns) {
      const id = String(tx?.Id ?? "");
      if (!id) continue;
      const type = String(tx?.TransactionType ?? "?");
      byType[type] ??= { total: 0, named: 0 };
      byType[type].total++;
      const name = extractPayee(tx, ownerByUnit);
      if (name) { payeeById.set(id, name); byType[type].named++; }
    }

    // partymap mode: resolve each Buildium tx to a LOCAL vendor/customer id and
    // stage it (accounting.buildium_party_map) so the materialized bank register
    // (accounting.transactions) can be backfilled in SQL by bridging tx→JE.
    //   vendor   : Payee.Type=Vendor  → match Payee.Name to accounting.vendors.name
    //   customer : tx.UnitNumber      → match to accounting.customers.unit_number
    if (mode === "partymap") {
      const vendorByName = new Map<string, string>();
      for (const v of (await supabase.schema("accounting").from("vendors").select("id,name").eq("company_id", companyId)).data ?? [])
        if (v.name) vendorByName.set(norm(v.name), v.id);
      const customerByUnitNo = new Map<string, string>();
      for (const c of (await supabase.schema("accounting").from("customers").select("id,unit_number").eq("company_id", companyId)).data ?? [])
        if (c.unit_number) customerByUnitNo.set(norm(c.unit_number), c.id);
      // Local income/expense accounts by code → {id,name}, for the category.
      const acctByCode = new Map<string, { id: string; name: string }>();
      for (const a of (await supabase.schema("accounting").from("accounts").select("id,code,name,type").eq("company_id", companyId)).data ?? [])
        if (a.code && (a.type === "income" || a.type === "expense")) acctByCode.set(String(a.code).trim(), { id: a.id, name: a.name });

      const rows: any[] = [];
      let vendorHits = 0, customerHits = 0, categoryHits = 0;
      for (const tx of txns) {
        const id = String(tx?.Id ?? "");
        if (!id) continue;
        const name = payeeById.get(id) ?? null;
        const payeeType = String(tx?.PaymentDetail?.Payee?.Type ?? "");
        let vendor_id: string | null = null;
        let customer_id: string | null = null;
        if (payeeType === "Vendor" && name) vendor_id = vendorByName.get(norm(name)) ?? null;
        const unitNo = tx?.UnitNumber;
        if (!vendor_id && typeof unitNo === "string" && unitNo.trim()) customer_id = customerByUnitNo.get(norm(unitNo)) ?? null;

        // Category: the dominant income/expense GL account across the journal
        // lines (Buildium expands a check/bill/payment to its category lines).
        const byCode = new Map<string, number>();
        for (const ln of (tx?.Journal?.Lines ?? [])) {
          const t = String(ln?.GLAccount?.Type ?? "");
          if (t !== "Expense" && t !== "Income") continue;
          const code = String(ln?.GLAccount?.AccountNumber ?? "").trim();
          if (!code) continue;
          byCode.set(code, (byCode.get(code) ?? 0) + Math.abs(Number(ln?.Amount) || 0));
        }
        let category_name: string | null = null;
        let coa_account_id: string | null = null;
        if (byCode.size) {
          const topCode = [...byCode.entries()].sort((a, b) => b[1] - a[1])[0][0];
          const acct = acctByCode.get(topCode);
          if (acct) { category_name = acct.name; coa_account_id = acct.id; }
        }

        if (vendor_id) vendorHits++;
        if (customer_id) customerHits++;
        if (coa_account_id) categoryHits++;
        if (name || vendor_id || customer_id || coa_account_id)
          rows.push({ company_id: companyId, external_id: id, party_name: name, vendor_id, customer_id, party_type: payeeType || null, category_name, coa_account_id });
      }
      // Upsert in batches.
      let staged = 0;
      for (let i = 0; i < rows.length; i += 500) {
        const batch = rows.slice(i, i + 500);
        const { error } = await supabase.schema("accounting").from("buildium_party_map").upsert(batch, { onConflict: "company_id,external_id" });
        if (error) throw error;
        staged += batch.length;
      }
      return json({ mode, company: company.name, window: [dateFrom, dateTo], txns: txns.length, staged, vendorHits, customerHits, categoryHits });
    }

    // Load this company's imported entries and compute the new description.
    const entries: { id: string; external_id: string; description: string }[] = [];
    for (let offset = 0; ; offset += 1000) {
      const { data: rows, error } = await supabase.schema("accounting")
        .from("journal_entries")
        .select("id, external_id, description")
        .eq("company_id", companyId)
        .eq("external_source", "buildium_gl")
        .order("id", { ascending: true })
        .range(offset, offset + 999);
      if (error) throw error;
      for (const r of rows || []) entries.push(r as any);
      if ((rows || []).length < 1000) break;
    }

    const updates: { id: string; before: string; after: string }[] = [];
    let unmatched = 0;
    for (const e of entries) {
      const name = e.external_id ? payeeById.get(String(e.external_id)) : undefined;
      if (!name) { unmatched++; continue; }
      const base = String(e.description ?? "").trim();
      // Don't double-append if a previous run already added the name.
      if (base.includes(name)) continue;
      const after = base ? `${base} · ${name}` : name;
      if (after !== base) updates.push({ id: e.id, before: base, after });
    }

    let updated = 0;
    if (mode === "apply") {
      for (const u of updates) {
        const { error } = await supabase.schema("accounting")
          .from("journal_entries").update({ description: u.after }).eq("id", u.id);
        if (error) throw error;
        updated++;
      }
    }

    return json({
      mode, company: company.name, window: [dateFrom, dateTo],
      pulledTxns: txns.length,
      unitsWithOwner: ownerByUnit.size,
      txnsWithName: payeeById.size,
      byType,
      entries: entries.length,
      unmatched,
      toUpdate: updates.length,
      updated,
      examples: updates.slice(0, 15),
    });
  } catch (e: any) {
    return json({ error: String(e?.message ?? e) }, 500);
  }
});
