// Buildium Sync Edge Function v8 — Unit-centric
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, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};

const BUILDIUM_BASE = "https://api.buildium.com";

function wait(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function buildiumFetch(path: string, clientId: string, clientSecret: string, params?: Record<string, string>) {
  const url = new URL(`${BUILDIUM_BASE}${path}`);
  if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));

  const maxAttempts = 4;

  for (let attempt = 0; attempt < maxAttempts; 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();
    const shouldRetry = res.status === 429 || res.status >= 500;

    if (shouldRetry && attempt < maxAttempts - 1) {
      const retryAfterSeconds = Number(res.headers.get("Retry-After") ?? "");
      const delayMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
        ? retryAfterSeconds * 1000
        : 600 * Math.pow(2, attempt);

      console.warn(`Buildium API ${path} throttled/retrying [${res.status}] attempt ${attempt + 1}/${maxAttempts} after ${delayMs}ms`);
      await wait(delayMs);
      continue;
    }

    throw new Error(`Buildium API ${path} failed [${res.status}]: ${text}`);
  }

  throw new Error(`Buildium API ${path} failed after retries`);
}

async function buildiumFetchAll(path: string, clientId: string, clientSecret: string, extraParams?: Record<string, string>) {
  const all: any[] = [];
  let offset = 0;
  const limit = 50;
  while (true) {
    const params = { ...extraParams, offset: String(offset), limit: String(limit) };
    const page = await buildiumFetch(path, clientId, clientSecret, params);
    if (!Array.isArray(page) || page.length === 0) break;
    all.push(...page);
    if (page.length < limit) break;
    offset += limit;
  }
  return all;
}

function norm(value: unknown): string {
  return String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
}

function normalizeAssociationName(value: unknown): string {
  return String(value ?? "").toLowerCase()
    .replace(/&/g, " and ")
    .replace(/\b(homeowners association|homeowners assoc|association|assoc|incorporated|inc|llc|hoa|coa)\b/g, " ")
    .replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim();
}

function normId(value: unknown): string | null {
  if (value === null || value === undefined) return null;
  const s = String(value).trim();
  return s.length > 0 ? s : null;
}

function formatAddress(obj: any): string {
  if (!obj) return "";
  return [obj.AddressLine1, obj.AddressLine2, obj.AddressLine3, obj.City, obj.State, obj.ZipCode ?? obj.PostalCode]
    .map((p) => String(p ?? "").trim()).filter(Boolean).join(", ");
}

function extractUnitNumber(address: string): string | null {
  const line1 = String(address || "").split(",")[0]?.trim();
  if (!line1) return null;

  const explicitMatch = line1.match(/(?:unit|apt|#|suite|ste)\s*([a-z0-9-]+)/i);
  if (explicitMatch?.[1]) return explicitMatch[1];

  if (line1.length > 0) return line1;

  return null;
}

function isPlaceholderUnitNumber(value: unknown): boolean {
  const normalized = norm(value);
  return !normalized || normalized === "unknown" || normalized === "n a" || normalized === "na";
}

function buildOwnerSyncKey(associationId: string, firstName: string, lastName: string, propertyAddress: string): string {
  return `${associationId}|${norm(firstName)}|${norm(lastName)}|${norm(propertyAddress)}`;
}

type OwnerLedgerEntryRow = {
  id: string;
  owner_id: string;
  reference_id: string | null;
  reference_type: string | null;
  description: string | null;
  date: string | null;
  created_at: string | null;
  debit: number;
  credit: number;
  transaction_type: string | null;
};

type OwnerLedgerEntryMaps = {
  byReferenceId: Map<string, OwnerLedgerEntryRow>;
  byLegacyKey: Map<string, OwnerLedgerEntryRow>;
  byDateAmount: Map<string, OwnerLedgerEntryRow>;
  defaultOwnerId: string | null;
};

function isUniqueViolation(error: { code?: string | null; message?: string | null } | null | undefined) {
  if (!error) return false;
  return error.code === "23505" || String(error.message || "").toLowerCase().includes("duplicate key");
}

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 new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
    }

    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
    const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
    const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
    const token = authHeader.replace("Bearer ", "");

    let userId: string | null = null;
    try {
      const payload = token.split(".")[1];
      if (!payload) throw new Error("Missing JWT payload");
      const padded = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.replace(/-/g, "+").replace(/_/g, "/").length / 4) * 4, "=");
      const decoded = JSON.parse(atob(padded));
      userId = typeof decoded?.sub === "string" ? decoded.sub : null;
    } catch {
      return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
    }

    const authClient = createClient(supabaseUrl, supabaseAnonKey, { global: { headers: { Authorization: authHeader } } });
    const { data: roles, error: rolesError } = await authClient.from("user_roles").select("role").eq("user_id", userId).limit(10);
    const isAdmin = !rolesError && Array.isArray(roles) && roles.some((r: any) => r.role === "admin");
    if (!isAdmin) {
      return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
    }

    const supabase = createClient(supabaseUrl, serviceRoleKey);

    const { syncType, selectedAssociationIds: rawIds, dateFrom, dateTo, unitId: rawUnitId, documentOffset: rawDocumentOffset, documentLimit: rawDocumentLimit, documentScope: rawDocumentScope } = await req.json();
    const documentScope: "association" | "company" = rawDocumentScope === "company" ? "company" : "association";
    let selectedAssociationIds = Array.isArray(rawIds) ? rawIds.filter((v): v is string => typeof v === "string" && v.length > 0) : [];
    const ledgerDateFrom = typeof dateFrom === "string" && dateFrom ? dateFrom : null;
    const ledgerDateTo = typeof dateTo === "string" && dateTo ? dateTo : null;
    const unitFilterId = typeof rawUnitId === "string" && rawUnitId.length > 0 ? rawUnitId : null;
    const documentOffset = Number.isFinite(Number(rawDocumentOffset)) && Number(rawDocumentOffset) >= 0 ? Math.floor(Number(rawDocumentOffset)) : 0;
    const documentLimit = Math.min(10, Math.max(1, Number.isFinite(Number(rawDocumentLimit)) ? Math.floor(Number(rawDocumentLimit)) : 5));
    const results: Record<string, any> = {};

    // If a single unit was requested, auto-scope selectedAssociationIds to that unit's association
    if (unitFilterId && selectedAssociationIds.length === 0) {
      const { data: unitRow } = await createClient(supabaseUrl, serviceRoleKey)
        .from("units").select("association_id").eq("id", unitFilterId).maybeSingle();
      if (unitRow?.association_id) selectedAssociationIds = [unitRow.association_id];
    }

    if (syncType === "reset_ledgers") {
      let deleteQuery = supabase.from("owner_ledger_entries").delete({ count: "exact" });
      if (selectedAssociationIds.length > 0) deleteQuery = deleteQuery.in("association_id", selectedAssociationIds);

      const { count, error } = await deleteQuery;
      if (error) throw error;

      let ownerQuery = supabase.from("owners").update({ balance: 0 });
      if (selectedAssociationIds.length > 0) ownerQuery = ownerQuery.in("association_id", selectedAssociationIds);
      const { error: ownerBalanceError } = await ownerQuery;
      if (ownerBalanceError) throw ownerBalanceError;

      await supabase.from("company_settings").upsert(
        { key: "buildium_last_sync_reset_ledgers", value: new Date().toISOString() },
        { onConflict: "key" }
      );

      return new Response(JSON.stringify({ success: true, results: { reset: { deleted: count || 0 } } }), {
        status: 200,
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    }

    const clientId = Deno.env.get("BUILDIUM_API_KEY") ?? "";
    const clientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? "";
    if (!clientId || !clientSecret) {
      return new Response(JSON.stringify({ error: "Buildium API credentials not configured" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
    }

    if (syncType === "gl_accounts") {
      const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret) as Record<string, unknown>[];

      // Flatten Buildium's nested SubAccounts so sub-accounts also appear in the dropdown.
      const flat: Record<string, unknown>[] = [];
      const seen = new Set<string>();
      const walk = (acc: Record<string, unknown>) => {
        if (!acc) return;
        const id = String((acc as any).Id ?? "");
        if (id && !seen.has(id)) {
          seen.add(id);
          flat.push(acc);
        }
        const subs = (acc as any).SubAccounts;
        if (Array.isArray(subs)) {
          for (const s of subs) walk(s as Record<string, unknown>);
        }
      };
      for (const gl of glAccounts) walk(gl);

      const chargeable = flat
        .filter((gl) => (gl as any).IsActive !== false)
        .map((gl) => ({
          id: String((gl as any).Id),
          name: String((gl as any).Name || `Buildium GL ${(gl as any).Id}`),
          account_number: String((gl as any).AccountNumber ?? (gl as any).Number ?? (gl as any).GlNumber ?? (gl as any).GLNumber ?? (gl as any).Code ?? (gl as any).Id ?? "").trim() || null,
          type: String((gl as any).Type || (gl as any).AccountType || ""),
        }))
        .sort((a, b) => a.name.localeCompare(b.name));

      return new Response(JSON.stringify({ success: true, gl_accounts: chargeable }), {
        status: 200,
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    }

    let associationMapsPromise: Promise<any> | null = null;
    let unitLookupPromise: Promise<any> | null = null;
    let ownerLookupPromise: Promise<any> | null = null;
    let buildiumAssociationUnitsPromise: Promise<any> | null = null;
    let ownershipAccountsPromise: Promise<any[]> | null = null;
    const ownerLedgerCache = new Map<string, OwnerLedgerEntryMaps>();

    const isSelected = (id: string | null) => selectedAssociationIds.length === 0 || Boolean(id && selectedAssociationIds.includes(id));

    const ownerStatusRank = (status: unknown) => {
      const normalized = norm(status);
      if (normalized === "active") return 2;
      if (normalized.length > 0) return 1;
      return 0;
    };

    const isPreferredOwnerCandidate = (candidate: any, current: any) => {
      const candidateRank = ownerStatusRank(candidate?.status);
      const currentRank = ownerStatusRank(current?.status);

      if (candidateRank !== currentRank) return candidateRank > currentRank;

      const candidateBuildiumOwnerId = normId(candidate?.buildium_owner_id) ?? "";
      const currentBuildiumOwnerId = normId(current?.buildium_owner_id) ?? "";
      const candidateHasBuildiumOwnerId = candidateBuildiumOwnerId.length > 0;
      const currentHasBuildiumOwnerId = currentBuildiumOwnerId.length > 0;

      if (candidateHasBuildiumOwnerId !== currentHasBuildiumOwnerId) {
        return candidateHasBuildiumOwnerId;
      }

      if (candidateBuildiumOwnerId !== currentBuildiumOwnerId) {
        return candidateBuildiumOwnerId < currentBuildiumOwnerId;
      }

      return String(candidate?.id ?? "") < String(current?.id ?? "");
    };

    const setPreferredOwnerByUnit = (map: Map<string, any>, owner: any) => {
      const unitId = owner?.unit_id;
      if (!unitId) return;

      const existing = map.get(unitId);
      if (!existing || isPreferredOwnerCandidate(owner, existing)) {
        map.set(unitId, owner);
      }
    };

    const makeLegacyKey = (description: unknown, date: unknown) => `${String(date ?? "")}|${String(description ?? "")}`;
    const makeDateAmountKey = (date: unknown, debit: number, credit: number) => `${String(date ?? "")}|${debit}|${credit}`;

    async function getOwnerLedgerEntryMaps(ownerId: string, unitId: string | null = null) {
      const cacheKey = unitId ? `unit:${unitId}` : `owner:${ownerId}`;
      const cached = ownerLedgerCache.get(cacheKey);
      if (cached) return cached;

      let query = supabase
        .from("owner_ledger_entries")
        .select("id, owner_id, reference_id, reference_type, description, date, created_at, debit, credit, transaction_type");

      query = unitId ? query.eq("unit_id", unitId) : query.eq("owner_id", ownerId);

      const { data: rows } = await query
        .order("created_at", { ascending: true })
        .order("id", { ascending: true });

      const byReferenceId = new Map<string, OwnerLedgerEntryRow>();
      const byLegacyKey = new Map<string, OwnerLedgerEntryRow>();
      const byDateAmount = new Map<string, OwnerLedgerEntryRow>();
      let defaultOwnerId: string | null = null;

      for (const row of rows || []) {
        if (!defaultOwnerId && row.reference_type === "buildium" && row.owner_id) {
          defaultOwnerId = row.owner_id;
        }

        if (row.reference_type === "buildium" && row.reference_id && !byReferenceId.has(String(row.reference_id))) {
          byReferenceId.set(String(row.reference_id), row);
        }

        if (!row.reference_id) {
          const key = makeLegacyKey(row.description, row.date);
          if (!byLegacyKey.has(key)) byLegacyKey.set(key, row);
        }

        const daKey = makeDateAmountKey(row.date, row.debit, row.credit);
        if (!byDateAmount.has(daKey)) byDateAmount.set(daKey, row);
      }

      const next = { byReferenceId, byLegacyKey, byDateAmount, defaultOwnerId: defaultOwnerId || ownerId };
      ownerLedgerCache.set(cacheKey, next);
      return next;
    }

    async function calculateOwnerLedgerBalance(ownerId: string) {
      let from = 0;
      const pageSize = 1000;
      let balance = 0;

      while (true) {
        const { data, error } = await supabase
          .from("owner_ledger_entries")
          .select("debit, credit")
          .eq("owner_id", ownerId)
          .range(from, from + pageSize - 1);

        if (error) throw error;
        for (const row of data || []) {
          balance += Number(row.debit || 0) - Number(row.credit || 0);
        }
        if (!data || data.length < pageSize) break;
        from += pageSize;
      }

      return Math.round(balance * 100) / 100;
    }

    async function getAssociationMaps() {
      if (associationMapsPromise) return associationMapsPromise;

      associationMapsPromise = (async () => {
      let query = supabase.from("associations").select("id, name").eq("status", "active");
      if (selectedAssociationIds.length > 0) query = query.in("id", selectedAssociationIds);
      const { data: rows } = await query;
      const exactMap = new Map((rows || []).map((a: any) => [String(a.name).trim(), a.id]));
      const normMap = new Map((rows || []).map((a: any) => [normalizeAssociationName(a.name), a.id]));

      const buildiumAssocs = await buildiumFetchAll("/v1/associations", clientId, clientSecret);
      const bIdToLocalId = new Map<string, string>();
      const bIdToName = new Map<string, string>();
      for (const ba of buildiumAssocs) {
        const bid = normId(ba.Id);
        const name = String(ba.Name || "").trim();
        if (!bid || !name) continue;
        bIdToName.set(bid, name);
        const localId = exactMap.get(name) ?? normMap.get(normalizeAssociationName(name)) ?? null;
        if (localId) bIdToLocalId.set(bid, localId);
      }
      return { bIdToLocalId, bIdToName };
      })();

      return associationMapsPromise;
    }

    async function getUnitLookup() {
      if (unitLookupPromise) return unitLookupPromise;

      unitLookupPromise = (async () => {
      const data: any[] = [];
      let from = 0;
      const pageSize = 1000;
      while (true) {
        let query = supabase.from("units").select("id, association_id, unit_number, buildium_unit_id, account_number, address");
        if (selectedAssociationIds.length > 0) query = query.in("association_id", selectedAssociationIds);
        const { data: page } = await query.range(from, from + pageSize - 1);
        if (page) data.push(...page);
        if (!page || page.length < pageSize) break;
        from += pageSize;
      }
      const byBuildiumId = new Map<string, any>();
      const byAssocAndNumber = new Map<string, any>();
      const byAssocAndAddress = new Map<string, any>();

      for (const unit of data || []) {
        const buildiumId = normId(unit.buildium_unit_id);
        if (buildiumId) byBuildiumId.set(buildiumId, unit);
        if (unit.unit_number) byAssocAndNumber.set(`${unit.association_id}|${norm(unit.unit_number)}`, unit);
        if (unit.address) byAssocAndAddress.set(`${unit.association_id}|${norm(unit.address)}`, unit);
      }

      return { units: data || [], byBuildiumId, byAssocAndNumber, byAssocAndAddress };
      })();

      return unitLookupPromise;
    }

    async function getOwnerLookup() {
      if (ownerLookupPromise) return ownerLookupPromise;

      ownerLookupPromise = (async () => {
      const data: any[] = [];
      let from = 0;
      const pageSize = 1000;
      while (true) {
        let query = supabase.from("owners").select("id, association_id, first_name, last_name, property_address, buildium_owner_id, unit_id, email, phone, mailing_address, status");
        if (selectedAssociationIds.length > 0) query = query.in("association_id", selectedAssociationIds);
        const { data: page } = await query.range(from, from + pageSize - 1);
        if (page) data.push(...page);
        if (!page || page.length < pageSize) break;
        from += pageSize;
      }
      const byBuildiumId = new Map<string, any>();
      const bySyncKey = new Map<string, any>();
      const byUnitId = new Map<string, any>();

      for (const owner of data || []) {
        const buildiumOwnerId = normId(owner.buildium_owner_id);
        if (buildiumOwnerId) byBuildiumId.set(buildiumOwnerId, owner);
        const propertyAddress = String(owner.property_address || "").trim();
        if (propertyAddress) {
          bySyncKey.set(buildOwnerSyncKey(owner.association_id, owner.first_name, owner.last_name, propertyAddress), owner);
        }
        setPreferredOwnerByUnit(byUnitId, owner);
      }

      return { byBuildiumId, bySyncKey, byUnitId };
      })();

      return ownerLookupPromise;
    }

    async function getBuildiumAssociationUnits() {
      if (buildiumAssociationUnitsPromise) return buildiumAssociationUnitsPromise;

      buildiumAssociationUnitsPromise = (async () => {
      const units = await buildiumFetchAll("/v1/associations/units", clientId, clientSecret);
      const byId = new Map<string, any>();
      for (const unit of units) {
        const id = normId(unit.Id);
        if (id) byId.set(id, unit);
      }
      return { units, byId };
      })();

      return buildiumAssociationUnitsPromise;
    }

    async function getOwnershipAccounts() {
      if (ownershipAccountsPromise) return ownershipAccountsPromise;

      ownershipAccountsPromise = buildiumFetchAll("/v1/associations/ownershipaccounts", clientId, clientSecret);
      return ownershipAccountsPromise;
    }

    function buildOwnershipAccountsByUnit(ownershipAccounts: any[]) {
      const byUnit = new Map<string, any[]>();
      for (const account of ownershipAccounts) {
        const buildiumUnitId = normId(account?.UnitId);
        if (!buildiumUnitId) continue;
        if (!byUnit.has(buildiumUnitId)) byUnit.set(buildiumUnitId, []);
        byUnit.get(buildiumUnitId)!.push(account);
      }
      return byUnit;
    }

    function pickPreferredOwnershipAccount(accounts: any[]) {
      if (!Array.isArray(accounts) || accounts.length === 0) return null;
      // Prefer active/current accounts, but fall back to any account (including delinquent/collections)
      const preferred = accounts.find((account: any) => {
        const status = String(account?.Status || "").toLowerCase();
        return status === "active" || status === "current" || status === "";
      });
      return preferred || accounts[0];
    }

    async function updateUnitAccountNumber(
      unitLookup: Awaited<ReturnType<typeof getUnitLookup>>,
      unitId: string,
      accountNumber: string | null,
    ) {
      if (!accountNumber) return;

      const cachedUnit = unitLookup.units.find((unit: any) => unit.id === unitId) || null;
      if (cachedUnit?.account_number === accountNumber) return;

      const { error } = await supabase.from("units").update({ account_number: accountNumber }).eq("id", unitId);
      if (error) {
        console.warn(`Failed to update account number for unit ${unitId}: ${error.message}`);
        return;
      }

      if (cachedUnit) cachedUnit.account_number = accountNumber;
      for (const unit of unitLookup.byBuildiumId.values()) {
        if (unit.id === unitId) unit.account_number = accountNumber;
      }
      for (const unit of unitLookup.byAssocAndNumber.values()) {
        if (unit.id === unitId) unit.account_number = accountNumber;
      }
      for (const unit of unitLookup.byAssocAndAddress.values()) {
        if (unit.id === unitId) unit.account_number = accountNumber;
      }
    }

    async function resolveOrCreateUnit(
      buildiumUnit: any,
      assocId: string,
      unitLookup: Awaited<ReturnType<typeof getUnitLookup>>,
    ): Promise<{ unitId: string; created: boolean }> {
      const buildiumUnitId = normId(buildiumUnit.Id);
      const address = formatAddress(buildiumUnit.Address ?? buildiumUnit.PrimaryAddress);
      const explicitUnitNumber = normId(buildiumUnit.UnitNumber);
      const parsedUnitNumber = extractUnitNumber(address);
      const unitNumber = explicitUnitNumber || parsedUnitNumber || "Unknown";
      const canMatchByNumber = !isPlaceholderUnitNumber(unitNumber);

      const refreshLookup = (previousUnit: any, nextUnit: any) => {
        const previousBuildiumId = normId(previousUnit?.buildium_unit_id);
        const nextBuildiumId = normId(nextUnit?.buildium_unit_id);
        if (previousBuildiumId && previousBuildiumId !== nextBuildiumId) {
          unitLookup.byBuildiumId.delete(previousBuildiumId);
        }
        if (nextBuildiumId) unitLookup.byBuildiumId.set(nextBuildiumId, nextUnit);
        if (nextUnit.unit_number) unitLookup.byAssocAndNumber.set(`${assocId}|${norm(nextUnit.unit_number)}`, nextUnit);
        if (nextUnit.address) unitLookup.byAssocAndAddress.set(`${assocId}|${norm(nextUnit.address)}`, nextUnit);
      };

      if (buildiumUnitId) {
        const existing = unitLookup.byBuildiumId.get(buildiumUnitId);
        if (existing) {
          const updates: Record<string, any> = {};
          if ((!existing.address || !String(existing.address).trim()) && address) updates.address = address;
          if (isPlaceholderUnitNumber(existing.unit_number) && !isPlaceholderUnitNumber(unitNumber)) updates.unit_number = unitNumber;
          if (Object.keys(updates).length > 0) {
            await supabase.from("units").update(updates).eq("id", existing.id);
            refreshLookup(existing, { ...existing, ...updates });
          }
          return { unitId: existing.id, created: false };
        }
      }

      const byNumber = canMatchByNumber ? unitLookup.byAssocAndNumber.get(`${assocId}|${norm(unitNumber)}`) : null;
      if (byNumber) {
        const updates: Record<string, any> = {};
        if (buildiumUnitId && byNumber.buildium_unit_id !== buildiumUnitId) updates.buildium_unit_id = buildiumUnitId;
        if ((!byNumber.address || !String(byNumber.address).trim()) && address) updates.address = address;
        if (isPlaceholderUnitNumber(byNumber.unit_number) && !isPlaceholderUnitNumber(unitNumber)) updates.unit_number = unitNumber;
        if (Object.keys(updates).length > 0) {
          await supabase.from("units").update(updates).eq("id", byNumber.id);
          refreshLookup(byNumber, { ...byNumber, ...updates });
        }
        return { unitId: byNumber.id, created: false };
      }

      if (address) {
        const byAddr = unitLookup.byAssocAndAddress.get(`${assocId}|${norm(address)}`);
        if (byAddr) {
          const updates: Record<string, any> = {};
          if (buildiumUnitId && byAddr.buildium_unit_id !== buildiumUnitId) updates.buildium_unit_id = buildiumUnitId;
          if (isPlaceholderUnitNumber(byAddr.unit_number) && !isPlaceholderUnitNumber(unitNumber)) updates.unit_number = unitNumber;
          if (Object.keys(updates).length > 0) {
            await supabase.from("units").update(updates).eq("id", byAddr.id);
            refreshLookup(byAddr, { ...byAddr, ...updates, address: byAddr.address || address });
          }
          return { unitId: byAddr.id, created: false };
        }
      }

      const { data: newUnit, error } = await supabase.from("units").insert({
        association_id: assocId,
        unit_number: unitNumber,
        address: address || null,
        buildium_unit_id: buildiumUnitId,
        account_number: null,
        status: "active",
      }).select("id, association_id, unit_number, buildium_unit_id, account_number, address").single();

      if (error) {
        if (buildiumUnitId) {
          const { data: conflictUnit } = await supabase
            .from("units")
            .select("id, association_id, unit_number, buildium_unit_id, account_number, address")
            .eq("buildium_unit_id", buildiumUnitId)
            .maybeSingle();

          if (conflictUnit) {
            const recoveryUpdates: Record<string, any> = {};
            if ((!conflictUnit.address || !String(conflictUnit.address).trim()) && address) recoveryUpdates.address = address;
            if (isPlaceholderUnitNumber(conflictUnit.unit_number) && !isPlaceholderUnitNumber(unitNumber)) recoveryUpdates.unit_number = unitNumber;
            if (Object.keys(recoveryUpdates).length > 0) {
              await supabase.from("units").update(recoveryUpdates).eq("id", conflictUnit.id);
            }
            refreshLookup(conflictUnit, { ...conflictUnit, ...recoveryUpdates });
            return { unitId: conflictUnit.id, created: false };
          }
        }

        console.warn(`Unit create error for Buildium unit ${buildiumUnit?.Id ?? "unknown"}: ${error.message}`);
        throw error;
      }

      refreshLookup(newUnit, newUnit);
      return { unitId: newUnit.id, created: true };
    }

    if (syncType === "all" || syncType === "associations") {
      const associations = await buildiumFetchAll("/v1/associations", clientId, clientSecret);
      const { data: excludedRows } = await supabase.from("associations").select("name").neq("status", "active");
      const excludedNames = new Set((excludedRows || []).map((a: any) => a.name));
      let filtered = associations.filter((a: any) => !excludedNames.has(a.Name));
      if (selectedAssociationIds.length > 0) {
        const { data: selected } = await supabase.from("associations").select("name").in("id", selectedAssociationIds);
        const selectedNames = new Set((selected || []).map((s: any) => normalizeAssociationName(s.name)));
        filtered = filtered.filter((a: any) => selectedNames.has(normalizeAssociationName(a.Name)));
      }
      const upserts = filtered.map((a: any) => ({
        name: a.Name || "Unknown",
        address: a.Address?.AddressLine1 || null,
        city: a.Address?.City || null,
        state: a.Address?.State || null,
        zip: a.Address?.ZipCode || a.Address?.PostalCode || null,
        email: a.Email || null,
        phone: a.Phone || null,
        fiscal_year_start: a.FiscalYearEndMonth || null,
      }));
      if (upserts.length > 0) {
        const { error } = await supabase.from("associations").upsert(upserts, { onConflict: "name", ignoreDuplicates: true });
        if (error) throw new Error(`Associations upsert failed: ${error.message}`);
      }

      // Ensure every active association has an accounting company. Once it exists,
      // the existing DB triggers automatically flow units -> customers, owner
      // ledger -> A/R + income, and bills -> A/P + expense into Accounting.
      // (Opening balances are a separate one-time migration: buildium-opening-balances.)
      let accountingCompaniesCreated = 0;
      try {
        const { data: activeAssocs } = await supabase.from("associations").select("id, name").eq("status", "active");
        const acctSchema = (supabase as any).schema("accounting");
        const { data: existingCompanies } = await acctSchema.from("companies").select("association_id");
        const haveCompany = new Set((existingCompanies || []).map((c: any) => c.association_id));
        const toCreate = (activeAssocs || []).filter((a: any) => a.id && !haveCompany.has(a.id));
        if (toCreate.length > 0) {
          const { error: compErr } = await acctSchema.from("companies").insert(
            toCreate.map((a: any) => ({ association_id: a.id, name: a.name || "Association", created_by: userId }))
          );
          if (compErr) console.warn(`Accounting company provisioning failed: ${compErr.message}`);
          else accountingCompaniesCreated = toCreate.length;
        }
      } catch (e) {
        console.warn("Accounting company provisioning error:", (e as Error)?.message);
      }

      results.associations = { fetched: associations.length, upserted: upserts.length, accounting_companies_created: accountingCompaniesCreated };
    }

    if (syncType === "all" || syncType === "units") {
      const { bIdToLocalId } = await getAssociationMaps();
      const unitLookup = await getUnitLookup();
      const { units: buildiumUnits } = await getBuildiumAssociationUnits();
      const ownershipAccounts = await getOwnershipAccounts();
      const ownershipAccountsByUnit = buildOwnershipAccountsByUnit(ownershipAccounts);

      let created = 0, linked = 0, skippedUnits = 0;

      for (const buildiumUnit of buildiumUnits) {
        const assocId = bIdToLocalId.get(normId(buildiumUnit.AssociationId) || "") || null;
        if (!assocId || !isSelected(assocId)) {
          skippedUnits++;
          continue;
        }

        try {
          const result = await resolveOrCreateUnit(buildiumUnit, assocId, unitLookup);
          const buildiumUnitId = normId(buildiumUnit.Id);
          const preferredAccount = buildiumUnitId ? pickPreferredOwnershipAccount(ownershipAccountsByUnit.get(buildiumUnitId) || []) : null;
          const leaseId = normId(preferredAccount?.Id ?? preferredAccount?.OwnershipAccountId);
          if (leaseId) await updateUnitAccountNumber(unitLookup, result.unitId, leaseId);

          if (result.created) created++;
          else linked++;
        } catch (e) {
          const message = e instanceof Error ? e.message : String(e);
          console.warn(`Unit resolve error for Buildium unit ${buildiumUnit.Id}: ${message}`);
          skippedUnits++;
        }
      }

      results.units = { fetched: buildiumUnits.length, created, linked, skipped: skippedUnits };
    }

    if (syncType === "all" || syncType === "owners") {
      const { bIdToLocalId } = await getAssociationMaps();
      const unitLookup = await getUnitLookup();
      const ownerLookup = await getOwnerLookup();
      const { units: buildiumUnits } = await getBuildiumAssociationUnits();
      const ownershipAccounts = await getOwnershipAccounts();
      const ownershipAccountsByUnit = buildOwnershipAccountsByUnit(ownershipAccounts);
      const owners = await buildiumFetchAll("/v1/associations/owners", clientId, clientSecret);

      let importedOwners = 0;
      let skippedOwners = 0;
      let unitsCreated = 0;
      let unitsLinked = 0;

      for (const buildiumUnit of buildiumUnits) {
        const assocId = bIdToLocalId.get(normId(buildiumUnit.AssociationId) || "") || null;
        if (!assocId || !isSelected(assocId)) continue;

        try {
          const result = await resolveOrCreateUnit(buildiumUnit, assocId, unitLookup);
          const buildiumUnitId = normId(buildiumUnit.Id);
          const preferredAccount = buildiumUnitId ? pickPreferredOwnershipAccount(ownershipAccountsByUnit.get(buildiumUnitId) || []) : null;
          const leaseId = normId(preferredAccount?.Id ?? preferredAccount?.OwnershipAccountId);
          if (leaseId) await updateUnitAccountNumber(unitLookup, result.unitId, leaseId);

          if (result.created) unitsCreated++;
          else unitsLinked++;
        } catch (e) {
          const message = e instanceof Error ? e.message : String(e);
          console.warn(`Unit resolve error for Buildium unit ${buildiumUnit.Id}: ${message}`);
        }
      }

      const ownerIdToAccounts = new Map<string, any[]>();
      for (const acct of ownershipAccounts) {
        const ownerIds: string[] = [];
        if (acct.AssociationOwnerIds && Array.isArray(acct.AssociationOwnerIds)) {
          for (const oid of acct.AssociationOwnerIds) ownerIds.push(String(oid));
        }
        if (acct.AssociationOwnerId) ownerIds.push(String(acct.AssociationOwnerId));
        for (const oid of ownerIds) {
          if (!ownerIdToAccounts.has(oid)) ownerIdToAccounts.set(oid, []);
          ownerIdToAccounts.get(oid)!.push(acct);
        }
      }

      console.log(`Owner sync: ${owners.length} owners, ${ownershipAccounts.length} ownership accounts, ownerIdToAccounts size: ${ownerIdToAccounts.size}`);
      if (owners.length > 0) {
        console.log(`Sample owner keys: ${JSON.stringify(Object.keys(owners[0]))}`);
      }

      for (const owner of owners) {
        const firstName = (owner.FirstName || "").trim() || "Unknown";
        const lastName = (owner.LastName || "").trim() || "Owner";
        const address = formatAddress(owner.PrimaryAddress);
        const mailingAddress = formatAddress(owner.AlternateAddress);
        const buildiumOwnerId = normId(owner.Id);

        let assocId = bIdToLocalId.get(normId(owner.AssociationId) || "") || null;
        const linkedAccountsById = new Map<string, any>();
        const linkedAccountsRaw = [
          ...(Array.isArray(owner.OwnershipAccounts) ? owner.OwnershipAccounts : []),
          ...(buildiumOwnerId ? (ownerIdToAccounts.get(buildiumOwnerId) || []) : []),
        ];
        for (const acct of linkedAccountsRaw) {
          const accountId = normId(acct?.Id ?? acct?.OwnershipAccountId);
          if (accountId) linkedAccountsById.set(accountId, acct);
        }
        const linkedAccounts = Array.from(linkedAccountsById.values());

        if (!assocId && linkedAccounts.length > 0) {
          for (const acct of linkedAccounts) {
            assocId = bIdToLocalId.get(normId(acct.AssociationId) || "") || null;
            if (assocId) break;
          }
        }

        if (!assocId || !isSelected(assocId)) {
          if (importedOwners === 0 && skippedOwners < 3) {
            console.log(`Skipping owner ${buildiumOwnerId} "${firstName} ${lastName}": AssociationId=${owner.AssociationId}, linked accounts=${linkedAccounts.length}`);
          }
          skippedOwners++;
          continue;
        }

        const propertyAddress = address || "N/A";
        let unitId: string | null = null;
        const buildiumUnitIds = new Set<string>();

        for (const acct of linkedAccounts) {
          const linkedUnitId = normId(acct.UnitId);
          if (linkedUnitId) buildiumUnitIds.add(linkedUnitId);
        }
        const directUnitId = normId(owner.UnitId);
        if (directUnitId) buildiumUnitIds.add(directUnitId);

        for (const buildiumUnitId of buildiumUnitIds) {
          const unit = unitLookup.byBuildiumId.get(buildiumUnitId);
          if (unit) {
            unitId = unit.id;
            break;
          }
        }

        if (!unitId && propertyAddress !== "N/A") {
          const unitByAddress = unitLookup.byAssocAndAddress.get(`${assocId}|${norm(propertyAddress)}`);
          if (unitByAddress) unitId = unitByAddress.id;
        }

        if (unitId) {
          const preferredAccount = pickPreferredOwnershipAccount(linkedAccounts);
          const leaseId = normId(preferredAccount?.Id ?? preferredAccount?.OwnershipAccountId);
          if (leaseId) await updateUnitAccountNumber(unitLookup, unitId, leaseId);
        }

        const isCurrentOwner = linkedAccounts.some((acct: any) => {
          const acctStatus = String(acct.Status || "").toLowerCase();
          return acctStatus === "active" || acctStatus === "current" || acctStatus === "";
        });
        const ownerStatus = isCurrentOwner ? "active" : "archived";

        const ownerRecord: Record<string, any> = {
          association_id: assocId,
          first_name: firstName,
          last_name: lastName,
          property_address: propertyAddress,
          mailing_address: mailingAddress || null,
          email: owner.Email || null,
          phone: owner.PhoneNumbers?.[0]?.Number || null,
          buildium_owner_id: buildiumOwnerId,
          unit_id: unitId,
          status: ownerStatus,
        };

        const syncKey = buildOwnerSyncKey(assocId, firstName, lastName, propertyAddress);
        const existingById = buildiumOwnerId ? ownerLookup.byBuildiumId.get(buildiumOwnerId) : null;
        const existingBySyncKey = ownerLookup.bySyncKey.get(syncKey);
        const existingOwner = existingById || existingBySyncKey;

        if (existingOwner) {
          const updates: Record<string, any> = {};
          if (!existingOwner.buildium_owner_id && buildiumOwnerId) updates.buildium_owner_id = buildiumOwnerId;
          if (unitId && existingOwner.unit_id !== unitId) updates.unit_id = unitId;
          if (!existingOwner.email && ownerRecord.email) updates.email = ownerRecord.email;
          if (!existingOwner.phone && ownerRecord.phone) updates.phone = ownerRecord.phone;
          if (!existingOwner.mailing_address && ownerRecord.mailing_address) updates.mailing_address = ownerRecord.mailing_address;
          if ((!existingOwner.property_address || existingOwner.property_address === "N/A") && ownerRecord.property_address && ownerRecord.property_address !== "N/A") updates.property_address = ownerRecord.property_address;
          if (existingOwner.status !== ownerStatus) updates.status = ownerStatus;

          if (Object.keys(updates).length > 0) {
            const { error } = await supabase.from("owners").update(updates).eq("id", existingOwner.id);
            if (error) {
              console.warn(`Owner update: ${error.message}`);
              skippedOwners++;
              continue;
            }
          }

          const nextOwner = { ...existingOwner, ...updates, id: existingOwner.id };
          if (buildiumOwnerId) ownerLookup.byBuildiumId.set(buildiumOwnerId, nextOwner);
          ownerLookup.bySyncKey.set(syncKey, nextOwner);
          setPreferredOwnerByUnit(ownerLookup.byUnitId, nextOwner);
          importedOwners++;
          continue;
        }

        const { data: dupeCheck } = await supabase.from("owners")
          .select("id, association_id, first_name, last_name, property_address, buildium_owner_id, unit_id, email, phone, mailing_address, status")
          .eq("association_id", assocId)
          .eq("first_name", firstName)
          .eq("last_name", lastName)
          .maybeSingle();

        if (dupeCheck) {
          const updates: Record<string, any> = {};
          if (!dupeCheck.buildium_owner_id && buildiumOwnerId) updates.buildium_owner_id = buildiumOwnerId;
          if (unitId && dupeCheck.unit_id !== unitId) updates.unit_id = unitId;
          if (!dupeCheck.email && ownerRecord.email) updates.email = ownerRecord.email;
          if (!dupeCheck.phone && ownerRecord.phone) updates.phone = ownerRecord.phone;
          if (!dupeCheck.mailing_address && ownerRecord.mailing_address) updates.mailing_address = ownerRecord.mailing_address;
          if ((!dupeCheck.property_address || dupeCheck.property_address === "N/A") && ownerRecord.property_address && ownerRecord.property_address !== "N/A") updates.property_address = ownerRecord.property_address;
          if (dupeCheck.status !== ownerStatus) updates.status = ownerStatus;
          if (Object.keys(updates).length > 0) {
            await supabase.from("owners").update(updates).eq("id", dupeCheck.id);
          }
          const nextOwner = { ...dupeCheck, ...updates };
          if (buildiumOwnerId) ownerLookup.byBuildiumId.set(buildiumOwnerId, nextOwner);
          ownerLookup.bySyncKey.set(syncKey, nextOwner);
          setPreferredOwnerByUnit(ownerLookup.byUnitId, nextOwner);
          importedOwners++;
          continue;
        }

        const { data: insertedOwner, error } = await supabase
          .from("owners")
          .insert(ownerRecord)
          .select("id, association_id, first_name, last_name, property_address, buildium_owner_id, unit_id, email, phone, mailing_address, status")
          .single();

        if (error) {
          console.warn(`Owner insert: ${error.message}`);
          skippedOwners++;
          continue;
        }

        if (buildiumOwnerId) ownerLookup.byBuildiumId.set(buildiumOwnerId, insertedOwner);
        ownerLookup.bySyncKey.set(syncKey, insertedOwner);
        setPreferredOwnerByUnit(ownerLookup.byUnitId, insertedOwner);
        importedOwners++;
      }

      results.owners = {
        fetched: owners.length,
        imported: importedOwners,
        skipped: skippedOwners,
        units_created: unitsCreated,
        units_linked: unitsLinked,
      };
    }

    if (syncType === "all" || syncType === "financials") {
      const glAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
      const { data: assocRows } = await supabase.from("associations").select("id, name");
      let totalAccounts = 0;

      // Separate parent (top-level) and child (sub) accounts
      const parentAccounts = glAccounts.filter((gl: any) => !gl.ParentGLAccountId);
      const childAccounts = glAccounts.filter((gl: any) => gl.ParentGLAccountId);

      for (const assoc of assocRows || []) {
        if (!isSelected(assoc.id)) continue;

        // Step 1: Upsert parent accounts first
        const parentUpserts = parentAccounts.map((gl: any) => ({
          association_id: assoc.id,
          account_number: String(gl.Id || gl.AccountNumber || ""),
          account_name: gl.Name || "Unknown",
          account_type: mapGLAccountType(gl.Type || gl.AccountType),
          description: gl.Description || null,
          is_active: gl.IsActive !== false,
        }));
        if (parentUpserts.length > 0) {
          const { error } = await supabase.from("chart_of_accounts").upsert(parentUpserts, { onConflict: "association_id, account_number", ignoreDuplicates: false });
          if (error) console.warn(`GL parent upsert for ${assoc.name}: ${error.message}`);
          else totalAccounts += parentUpserts.length;
        }

        // Step 2: Build lookup of account_number -> id for this association
        const { data: existingAccounts } = await supabase
          .from("chart_of_accounts")
          .select("id, account_number")
          .eq("association_id", assoc.id);
        const acctNumToId = new Map<string, string>();
        for (const a of existingAccounts || []) {
          acctNumToId.set(a.account_number, a.id);
        }

        // Step 3: Upsert child/subaccounts with parent_account_id linked
        if (childAccounts.length > 0) {
          const childUpserts = childAccounts.map((gl: any) => {
            const parentBuildiumId = String(gl.ParentGLAccountId || "");
            const parentDbId = acctNumToId.get(parentBuildiumId) || null;
            return {
              association_id: assoc.id,
              account_number: String(gl.Id || gl.AccountNumber || ""),
              account_name: gl.Name || "Unknown",
              account_type: mapGLAccountType(gl.Type || gl.AccountType),
              description: gl.Description || null,
              is_active: gl.IsActive !== false,
              parent_account_id: parentDbId,
            };
          });
          const { error } = await supabase.from("chart_of_accounts").upsert(childUpserts, { onConflict: "association_id, account_number", ignoreDuplicates: false });
          if (error) console.warn(`GL child upsert for ${assoc.name}: ${error.message}`);
          else totalAccounts += childUpserts.length;
        }
      }
      results.financials = { fetched: glAccounts.length, upserted: totalAccounts };
    }

    if (syncType === "all" || syncType === "charges" || syncType === "payments" || syncType === "ledger") {
      const { bIdToLocalId } = await getAssociationMaps();
      const unitLookup = await getUnitLookup();
      const ownerLookup = await getOwnerLookup();
      const { byId: buildiumUnitsById } = await getBuildiumAssociationUnits();
      const ownershipAccountsForLedger = await getOwnershipAccounts();

      let imported = 0, skipped = 0, updated = 0, totalFetched = 0;

      function getEntryLines(entry: any): any[] {
        const topLines = Array.isArray(entry.Lines) ? entry.Lines : [];
        const journalLines = Array.isArray(entry.Journal?.Lines) ? entry.Journal.Lines : [];
        const journalEntryLines = Array.isArray(entry.JournalEntry?.Lines) ? entry.JournalEntry.Lines : [];
        return topLines.length > 0 ? topLines : journalLines.length > 0 ? journalLines : journalEntryLines;
      }

      function getEntryDescription(entry: any): string {
        const lines = getEntryLines(entry);

        const candidates = [
          entry.Description,
          entry.Memo,
          entry.Journal?.Memo,
          entry.Journal?.Description,
          entry.JournalEntry?.Memo,
          entry.JournalEntry?.Description,
          entry.Notes,
          entry.PayeeName,
          entry.Name,
          entry.Title,
          entry.ChargeTypeName,
          entry.CategoryName,
          lines[0]?.Description,
          lines[0]?.Memo,
          lines[0]?.Name,
          lines[0]?.ChargeTypeName,
          lines[0]?.GLAccountName,
          lines[0]?.GLAccount?.Name,
          lines[0]?.GLAccount?.DefaultAccountName,
          lines[0]?.GLAccount?.Description,
          lines[0]?.GLAccount?.AccountName,
        ];

        for (const c of candidates) {
          if (c && String(c).trim()) return String(c).trim();
        }

        return "";
      }

      function getEntrySearchText(entry: any): string {
        const lines = getEntryLines(entry);

        const topLevelValues = [
          entry.TransactionType,
          entry.TransactionTypeEnum,
          entry.Type,
          entry.SubType,
          entry.Subtype,
          entry.Category,
          entry.CategoryName,
          entry.ChargeType,
          entry.ChargeTypeName,
          entry.PaymentType,
          entry.Description,
          entry.Memo,
          entry.Journal?.Memo,
          entry.Journal?.Description,
          entry.Journal?.Type,
          entry.Journal?.SubType,
          entry.JournalEntry?.Memo,
          entry.JournalEntry?.Description,
          entry.JournalEntry?.Type,
          entry.JournalEntry?.SubType,
          entry.Notes,
          entry.PayeeName,
          entry.ReferenceNumber,
          entry.Reference,
          entry.Name,
          entry.Title,
          entry.GLAccountName,
          entry.GLAccount?.Name,
          entry.GLAccount?.DefaultAccountName,
          entry.GLAccount?.Description,
          entry.GLAccount?.AccountName,
          entry.GLAccount?.Number,
          entry.GLAccount?.AccountNumber,
          entry.GLAccount?.Type,
          entry.GLAccount?.AccountType,
        ];

        const lineLevelValues = lines.flatMap((line: any) => [
          line.Description,
          line.Memo,
          line.Name,
          line.Type,
          line.SubType,
          line.Category,
          line.CategoryName,
          line.ChargeType,
          line.ChargeTypeName,
          line.ReferenceNumber,
          line.AccountName,
          line.AccountNumber,
          line.AccountType,
          line.GLAccountName,
          line.GLAccount?.Name,
          line.GLAccount?.DefaultAccountName,
          line.GLAccount?.Description,
          line.GLAccount?.AccountName,
          line.GLAccount?.Number,
          line.GLAccount?.AccountNumber,
          line.GLAccount?.Type,
          line.GLAccount?.AccountType,
        ]);

        return norm([...topLevelValues, ...lineLevelValues].filter(Boolean).join(" "));
      }

      // Shared classification logic used by both whole-entry and per-line classifiers
      function classifyFromText(searchText: string, glAccountText: string, debugInfo?: any): string {
        const has = (...needles: string[]) => needles.some((needle) => searchText.includes(needle));
        const glHas = (...needles: string[]) => needles.some((n) => glAccountText.includes(n));

        if (glHas("interest", "finance charge")) return "interest";
        if (glHas("prepayment", "prepaid", "pre payment", "pre paid", "advance payment")) return "Prepayment";
        if (has("prepayment charge", "prepayment", "prepay", "pre payment", "pre pay", "advance payment", "advance deposit")) return "Prepayment";
        if (has("special assessment", "reserve assessment", "capital improvement assessment")) return "special_assessment";
        if (has("interest", "finance charge", "finance charges")) return "interest";
        if (has("late fee", "late charge", "late charges", "late payment fee", "late payment charge", "non payment penalty", "delinquency fee", "delinquent fee")) return "late_fee";
        if (has("legal fee", "legal fees", "attorney", "attorneys fee", "attorneys fees", "lien", "foreclosure", "court cost", "court costs", "collection cost", "collection costs", "demand letter")) return "legal_fee";
        if (has("admin fee", "administrative fee", "administrative fees", "administration fee", "processing fee", "document fee", "notice fee", "statement fee", "mailing fee")) return "admin_fee";
        if (has("violation", "violations", "fine", "fines", "covenant", "compliance", "enforcement")) return "violation";
        if (has("bank fee", "bank charge", "nsf", "returned check", "returned payment", "return item", "insufficient funds", "chargeback", "ach reject", "rejected ach", "reject fee")) return "bank_fee";
        if (has("assessment", "assessment fees", "monthly assessment", "monthly assessments", "monthly dues", "hoa dues", "association dues", "maintenance fee", "maintenance dues", "association fee income", "condo fee")) return "assessment";

        if (debugInfo) {
          console.warn("[buildium-sync] Unclassified charge defaulted to assessment", debugInfo);
        }
        return "assessment";
      }

      function classifyChargeType(entry: any): string {
        const searchText = getEntrySearchText(entry);
        const lines = getEntryLines(entry);
        const glAccountText = norm(
          [
            ...lines.map((l: any) => l.GLAccount?.Name || l.GLAccount?.name || ""),
            ...lines.map((l: any) => l.GLAccount?.AccountType || l.GLAccount?.accountType || ""),
          ]
            .filter(Boolean)
            .join(" ")
        );
        return classifyFromText(searchText, glAccountText, {
          id: entry.Id,
          transactionType: entry.TransactionType,
          description: entry.Description ?? null,
          memo: entry.Memo ?? null,
          journalMemo: entry.Journal?.Memo ?? entry.JournalEntry?.Memo ?? null,
          glAccountText,
          searchText,
        });
      }

      // Classify a single line item from a multi-line charge
      function classifyLineChargeType(line: any, entry: any): string {
        const lineFields = [
          line.Description, line.Memo, line.Name, line.Type, line.SubType,
          line.Category, line.CategoryName, line.ChargeType, line.ChargeTypeName,
          line.GLAccountName, line.GLAccount?.Name, line.GLAccount?.DefaultAccountName,
          line.GLAccount?.Description, line.GLAccount?.AccountName,
          line.GLAccount?.Number, line.GLAccount?.AccountNumber,
          line.GLAccount?.Type, line.GLAccount?.AccountType,
          // Also include entry-level context for fallback matching
          entry.Description, entry.Memo, entry.Journal?.Memo, entry.JournalEntry?.Memo,
        ];
        const searchText = norm(lineFields.filter(Boolean).join(" "));

        const glAccountText = norm(
          [line.GLAccount?.Name || line.GLAccount?.name || "",
           line.GLAccount?.AccountType || line.GLAccount?.accountType || ""]
            .filter(Boolean).join(" ")
        );
        return classifyFromText(searchText, glAccountText);
      }

      // Get description for a single line item
      function getLineDescription(line: any, entry: any): string {
        const candidates = [
          line.Description, line.Memo, line.Name, line.ChargeTypeName,
          line.GLAccountName, line.GLAccount?.Name, line.GLAccount?.DefaultAccountName,
          line.GLAccount?.Description, line.GLAccount?.AccountName,
          entry.Description, entry.Memo,
        ];
        for (const c of candidates) {
          if (c && String(c).trim()) return String(c).trim();
        }
        return "";
      }

      for (const acct of ownershipAccountsForLedger) {
        const assocId = bIdToLocalId.get(normId(acct.AssociationId) || "") || null;
        if (!assocId || !isSelected(assocId)) continue;

        const buildiumUnitId = normId(acct.UnitId);
        let unit = buildiumUnitId ? unitLookup.byBuildiumId.get(buildiumUnitId) : null;
        if (!unit && buildiumUnitId) {
          const buildiumUnit = buildiumUnitsById.get(buildiumUnitId);
          if (buildiumUnit) {
            try {
              await resolveOrCreateUnit(buildiumUnit, assocId, unitLookup);
              unit = unitLookup.byBuildiumId.get(buildiumUnitId) || null;
            } catch (e) {
              console.warn(`Ledger unit recovery failed for account ${acct.Id}: ${e}`);
            }
          }
        }
        if (!unit) { skipped++; continue; }
        if (unitFilterId && unit.id !== unitFilterId) { skipped++; continue; }

        const unitOwner = ownerLookup.byUnitId.get(unit.id) || null;
        if (!unitOwner) { skipped++; continue; }

        const ownershipAccountId = normId(acct.Id);

        let ledgerEntries: any[] = [];
        try {
          const txnParams: Record<string, string> = {};
          if (ledgerDateFrom) txnParams.transactiondatefrom = ledgerDateFrom;
          if (ledgerDateTo) txnParams.transactiondateto = ledgerDateTo;
          ledgerEntries = await buildiumFetchAll(`/v1/associations/ownershipaccounts/${acct.Id}/transactions`, clientId, clientSecret, Object.keys(txnParams).length > 0 ? txnParams : undefined);
        } catch (e) {
          console.warn(`Ledger fetch for account ${acct.Id}: ${e}`);
          skipped++;
          continue;
        }

        totalFetched += ledgerEntries.length;

        // DEBUG: Log the raw structure of the first few entries to understand Buildium's response format
        if (ledgerEntries.length > 0) {
          const sampleEntries = ledgerEntries.slice(0, 3);
          for (const sample of sampleEntries) {
            console.log("[buildium-sync] RAW ENTRY SAMPLE", JSON.stringify(sample, null, 2));
          }
        }

        // Skip old lump-sum cleanup — we now rely on reference_id deduplication only
        // to prevent accidental deletion and re-insertion of entries.

        // Invalidate cache for this owner so we get fresh data
        ownerLedgerCache.delete(unit.id ? `unit:${unit.id}` : `owner:${unitOwner.id}`);
        const ledgerEntryMaps = await getOwnerLedgerEntryMaps(unitOwner.id, unit.id);
        const targetOwnerId = ledgerEntryMaps.defaultOwnerId || unitOwner.id;

        // Process ALL ledger entries (charges, payments, credits, etc.)
        for (const entry of ledgerEntries) {
          const buildiumLedgerId = String(entry.Id || "");
          if (!buildiumLedgerId) continue;

          const amount = Number(entry.TotalAmount ?? entry.Amount ?? 0);
          if (amount === 0) continue;

          const txnType = String(entry.TransactionType || "");
          const txnDate = entry.Date ? entry.Date.split("T")[0] : new Date().toISOString().split("T")[0];

          // BLOCK: Skip any prepayment-related entries from Buildium (per user request).
          // Exception: keep "SIRS Reserve Prepayment" entries which are intentional reserves.
          const rawDescForFilter = String(getEntryDescription(entry) || "");
          const isPrepaymentTxn =
            txnType === "AppliedPrepayment" ||
            txnType === "Applied Prepayment" ||
            /prepayment/i.test(txnType) ||
            /prepayment/i.test(rawDescForFilter);
          const isSirsReserve = /sirs reserve prepayment/i.test(rawDescForFilter);
          if (isPrepaymentTxn && !isSirsReserve) {
            skipped++;
            continue;
          }

          // Helper: upsert a single ledger entry by reference_id
          async function upsertLedgerEntry(
            refId: string, txDate: string, txType: string, desc: string,
            entryDebit: number, entryCredit: number,
            maps: typeof ledgerEntryMaps, assocIdLocal: string, ownerIdLocal: string, unitIdLocal: string,
          ): Promise<"imported" | "updated" | "skipped"> {
            const existingEntry = maps.byReferenceId.get(refId) || null;
            if (existingEntry) {
              const dChanged = desc && existingEntry.description !== desc;
              if (existingEntry.debit !== entryDebit || existingEntry.credit !== entryCredit || existingEntry.transaction_type !== txType || dChanged) {
                await supabase.from("owner_ledger_entries").update({
                  debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType,
                }).eq("id", existingEntry.id);
                maps.byReferenceId.set(refId, { ...existingEntry, debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType });
                return "updated";
              }
              return "skipped";
            }

            const legacyKey = makeLegacyKey(desc, txDate);
            const legacyMatch = maps.byLegacyKey.get(legacyKey) || null;
            if (legacyMatch) {
              await supabase.from("owner_ledger_entries").update({
                reference_id: refId, reference_type: "buildium",
                debit: entryDebit, credit: entryCredit, transaction_type: txType, description: desc,
              }).eq("id", legacyMatch.id);
              maps.byLegacyKey.delete(legacyKey);
              maps.byReferenceId.set(refId, { ...legacyMatch, reference_id: refId, reference_type: "buildium", debit: entryDebit, credit: entryCredit, transaction_type: txType, description: desc });
              return "skipped";
            }

            const daKey = makeDateAmountKey(txDate, entryDebit, entryCredit);
            const dateAmountMatch = maps.byDateAmount.get(daKey) || null;
            if (dateAmountMatch && !dateAmountMatch.reference_id) {
              await supabase.from("owner_ledger_entries").update({
                reference_id: refId, reference_type: "buildium", transaction_type: txType, description: desc,
              }).eq("id", dateAmountMatch.id);
              maps.byDateAmount.delete(daKey);
              maps.byReferenceId.set(refId, { ...dateAmountMatch, reference_id: refId, reference_type: "buildium", transaction_type: txType, description: desc });
              return "skipped";
            }



            const insertPayload = {
              association_id: assocIdLocal, owner_id: ownerIdLocal, unit_id: unitIdLocal,
              date: txDate, transaction_type: txType, description: desc,
              debit: entryDebit, credit: entryCredit, reference_id: refId, reference_type: "buildium",
            };

            const { data: insertedRow, error: insertErr } = await supabase
              .from("owner_ledger_entries")
              .insert(insertPayload)
              .select("id, owner_id, reference_id, reference_type, description, date, created_at, debit, credit, transaction_type")
              .single();

            if (insertErr) {
              if (isUniqueViolation(insertErr)) {
                const { data: existingRow } = await supabase
                  .from("owner_ledger_entries")
                  .select("id, owner_id, reference_id, reference_type, description, date, created_at, debit, credit, transaction_type")
                  .eq("unit_id", unitIdLocal)
                  .eq("reference_type", "buildium")
                  .eq("reference_id", refId)
                  .order("created_at", { ascending: true })
                  .limit(1)
                  .maybeSingle();
                if (existingRow) {
                  const descDiff = desc && existingRow.description !== desc;
                  let resolvedRow: OwnerLedgerEntryRow = existingRow;
                  if (existingRow.debit !== entryDebit || existingRow.credit !== entryCredit || existingRow.transaction_type !== txType || descDiff) {
                    const { error: updateError } = await supabase.from("owner_ledger_entries").update({
                      debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType,
                    }).eq("id", existingRow.id);
                    if (!updateError) {
                      resolvedRow = { ...existingRow, debit: entryDebit, credit: entryCredit, description: desc, transaction_type: txType };
                    }
                  }
                  maps.byReferenceId.set(refId, resolvedRow);
                  maps.byDateAmount.set(daKey, resolvedRow);
                  return "skipped";
                }
              }
              console.warn(`Ledger insert (${txType}): ${insertErr.message}`);
              return "skipped";
            } else if (insertedRow) {
              maps.byReferenceId.set(refId, insertedRow);
              maps.byDateAmount.set(daKey, insertedRow);
              return "imported";
            }
            return "skipped";
          }

          let transactionType: string;
          let debit = 0;
          let credit = 0;
          let description: string;

          const buildiumDesc = getEntryDescription(entry);

          // POLICY: From Buildium we only PULL payments. Charges originate locally and are pushed to Buildium.
          const isPaymentTxn = ["Payment", "Credit", "Check"].includes(txnType) || amount < 0;
          if (!isPaymentTxn) {
            skipped++;
            continue;
          }

          if (["Payment", "Credit", "Check"].includes(txnType)) {
            transactionType = "payment";
            credit = Math.abs(amount);
            description = buildiumDesc || `Buildium ${txnType} - ${entry.ReferenceNumber || entry.Id}`;
          } else if (txnType === "Charge") {
            // Check if this charge has multiple line items linked to different GL accounts
            const chargeLines = getEntryLines(entry);
            const uniqueGLIds = new Set(
              chargeLines.map((l: any) =>
                String(l.GLAccount?.Id || l.GLAccountId || l.GLAccount?.AccountNumber || l.GLAccount?.Name || "unknown")
              )
            );
            const isMultiAccount = chargeLines.length > 1 && uniqueGLIds.size > 1;

            if (isMultiAccount) {
              // Multi-line charge: break into separate ledger entries per GL account line
              // Remove legacy single entry if it exists (from before this breakdown logic)
              const oldSingleEntry = ledgerEntryMaps.byReferenceId.get(buildiumLedgerId) || null;
              if (oldSingleEntry) {
                await supabase.from("owner_ledger_entries").delete().eq("id", oldSingleEntry.id);
                ledgerEntryMaps.byReferenceId.delete(buildiumLedgerId);
                console.log(`[buildium-sync] Replaced single entry ${buildiumLedgerId} with ${chargeLines.length} line items`);
              }

              for (let li = 0; li < chargeLines.length; li++) {
                const line = chargeLines[li];
                const lineAmount = Number(line.Amount ?? line.TotalAmount ?? 0);
                if (lineAmount === 0) continue;

                const lineRefId = `${buildiumLedgerId}_L${li}`;
                const lineType = classifyLineChargeType(line, entry);
                const lineDesc = getLineDescription(line, entry) ||
                  `Buildium ${lineType.replace(/_/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase())}`;

                let lineDebit = 0;
                let lineCredit = 0;
                if (lineType === "Prepayment") {
                  lineCredit = Math.abs(lineAmount);
                } else {
                  lineDebit = Math.max(0, lineAmount);
                  lineCredit = Math.max(0, -lineAmount);
                }

                const result = await upsertLedgerEntry(
                  lineRefId, txnDate, lineType, lineDesc,
                  lineDebit, lineCredit,
                  ledgerEntryMaps, assocId, targetOwnerId, unit.id,
                );
                if (result === "imported") imported++;
                else if (result === "updated") updated++;
                else skipped++;
              }
              continue; // skip the single-entry upsert below
            }

            // Single-line charge (or all lines share the same GL account)
            transactionType = classifyChargeType(entry);
            if (transactionType === "Prepayment") {
              credit = Math.abs(amount);
              debit = 0;
            } else {
              debit = Math.max(0, amount);
              credit = Math.max(0, -amount);
            }
            description = buildiumDesc || `Buildium ${transactionType.replace(/_/g, " ").replace(/\b\w/g, (c: string) => c.toUpperCase())}`;
          } else if (txnType === "AppliedPrepayment" || txnType === "Applied Prepayment") {
            transactionType = "applied_prepayment";
            credit = Math.abs(amount);
            debit = 0;
            description = buildiumDesc || `Buildium Applied Prepayment - ${entry.Id}`;
          } else {
            transactionType = txnType.toLowerCase().replace(/\s+/g, "_") || "other";
            if (amount > 0) { debit = amount; } else { credit = Math.abs(amount); }
            description = buildiumDesc || `Buildium ${txnType} - ${entry.Id}`;
          }

          // Single-entry upsert path
          const singleResult = await upsertLedgerEntry(
            buildiumLedgerId, txnDate, transactionType, description,
            debit, credit,
            ledgerEntryMaps, assocId, targetOwnerId, unit.id,
          );
          if (singleResult === "imported") imported++;
          else if (singleResult === "updated") updated++;
          else skipped++;
        }

        const recalculatedBalance = await calculateOwnerLedgerBalance(targetOwnerId);
        await supabase.from("owners").update({ balance: recalculatedBalance }).eq("id", targetOwnerId);
      }

      results.ledger = { fetched: totalFetched, imported, updated, skipped };
      results.charges = results.ledger;
      results.payments = results.ledger;
    }

    // Save last sync timestamp per syncType
    const syncTimestamp = new Date().toISOString();
    const settingsKey = `buildium_last_sync_${syncType}`;
    await supabase.from("company_settings").upsert(
      { key: settingsKey, value: syncTimestamp },
      { onConflict: "key" }
    );

    // ===== PUSH TO BUILDIUM: Charges & Payments =====
    if (syncType === "push_charges" || syncType === "push_payments" || syncType === "push_all") {
      const unitLookup = await getUnitLookup();
      const ownershipAccounts = await getOwnershipAccounts();

      // Build reverse lookup: unit buildium_unit_id -> ownership account ID
      const unitToOwnershipAccount = new Map<string, number>();
      for (const acct of ownershipAccounts) {
        const buildiumUnitId = normId(acct.UnitId);
        if (buildiumUnitId) {
          const existing = unitToOwnershipAccount.get(buildiumUnitId);
          if (!existing) {
            unitToOwnershipAccount.set(buildiumUnitId, Number(acct.Id));
          }
        }
      }

      // Fetch GL accounts from Buildium for mapping charge types.
      // Buildium returns some child accounts nested under SubAccounts, so flatten
      // them here too; otherwise mappings saved to sub-account IDs cannot resolve.
      const rawGlAccounts = await buildiumFetchAll("/v1/glaccounts", clientId, clientSecret);
      const glAccounts: any[] = [];
      const seenGlAccountIds = new Set<string>();
      const walkGlAccount = (gl: any) => {
        if (!gl) return;
        const id = String(gl.Id ?? "");
        if (id && !seenGlAccountIds.has(id)) {
          seenGlAccountIds.add(id);
          glAccounts.push(gl);
        }
        if (Array.isArray(gl.SubAccounts)) {
          for (const sub of gl.SubAccounts) walkGlAccount(sub);
        }
      };
      for (const gl of rawGlAccounts) walkGlAccount(gl);
      interface BuildiumGLAccountMeta { id: number; name: string; normalizedName: string; type: string; isActive: boolean; isChargeable: boolean; }
      const glAccountByName = new Map<string, BuildiumGLAccountMeta>();
      const glAccountByNumber = new Map<string, BuildiumGLAccountMeta>(); // account number -> Buildium account
      const glAccountById = new Map<number, BuildiumGLAccountMeta>(); // Buildium Id -> account
      const chargeableGlAccounts: BuildiumGLAccountMeta[] = [];
      for (const gl of glAccounts) {
        const glType = String(gl.Type || gl.AccountType || "");
        const typeKey = glType.toLowerCase();
        const meta: BuildiumGLAccountMeta = {
          id: Number(gl.Id),
          name: String(gl.Name || ""),
          normalizedName: norm(gl.Name),
          type: glType,
          isActive: gl.IsActive !== false,
          isChargeable: gl.IsActive !== false && (typeKey === "income" || typeKey === "liability"),
        };
        if (gl.Name) {
          const existingName = glAccountByName.get(meta.normalizedName);
          if (!existingName || (!existingName.isChargeable && meta.isChargeable)) glAccountByName.set(meta.normalizedName, meta);
        }
        if (gl.Id) glAccountById.set(Number(gl.Id), meta);
        if (meta.isChargeable) chargeableGlAccounts.push(meta);
        // Map by every plausible "account number" field Buildium might return
        for (const key of ["AccountNumber", "Number", "GlNumber", "GLNumber", "Code"]) {
          const acctNum = String(gl[key] ?? "").trim();
          if (acctNum && acctNum !== "undefined" && acctNum !== "null") {
            const existing = glAccountByNumber.get(acctNum);
            if (!existing || (!existing.isChargeable && meta.isChargeable)) glAccountByNumber.set(acctNum, meta);
          }
        }
        // Also map the Id as a string so "4" -> Id 4 works
        const existingIdKey = glAccountByNumber.get(String(gl.Id));
        if (!existingIdKey || (!existingIdKey.isChargeable && meta.isChargeable)) glAccountByNumber.set(String(gl.Id), meta);
      }
      console.log(`[push] Loaded ${glAccounts.length} Buildium GL accounts (${chargeableGlAccounts.length} chargeable income/liability). byNumber keys: ${[...glAccountByNumber.keys()].slice(0, 20).join(", ")}`);
      console.log(`[push] Chargeable account names: ${chargeableGlAccounts.map(a => `${a.id}:${a.name}`).slice(0, 60).join(" | ")}`);
      if (glAccounts.length > 0) {
        console.log(`[push] Sample GL account keys: ${JSON.stringify(Object.keys(glAccounts[0]))}`);
      }

      // Load per-association GL mappings from the database (with amount thresholds)
      interface GLMappingRule { glAccountId: string; glAccountName: string | null; amountMin: number | null; amountMax: number | null; customDescription: string | null; }
      const glMappingsByAssoc = new Map<string, Map<string, GLMappingRule[]>>();
      const customDescByAssocType = new Map<string, Map<string, string>>();
      for (const assocId of (selectedAssociationIds.length > 0 ? selectedAssociationIds : [])) {
        const { data: mappingRows } = await supabase
          .from("buildium_gl_mappings")
          .select("charge_type, buildium_gl_account_id, buildium_gl_account_name, amount_min, amount_max, custom_description")
          .eq("association_id", assocId);
        if (mappingRows && mappingRows.length > 0) {
          const map = new Map<string, GLMappingRule[]>();
          const descMap = new Map<string, string>();
          for (const row of mappingRows) {
            const rules = map.get(row.charge_type) || [];
            rules.push({
              glAccountId: row.buildium_gl_account_id,
              glAccountName: row.buildium_gl_account_name ?? null,
              amountMin: row.amount_min ?? null,
              amountMax: row.amount_max ?? null,
              customDescription: (row as any).custom_description ?? null,
            });
            map.set(row.charge_type, rules);
            const cd = ((row as any).custom_description ?? "").toString().trim();
            if (cd && row.charge_type !== "interest") descMap.set(row.charge_type, cd);
          }
          glMappingsByAssoc.set(assocId, map);
          customDescByAssocType.set(assocId, descMap);
          console.log(`[push] Loaded ${mappingRows.length} GL mappings for assoc ${assocId}: ${[...map.keys()].join(", ")}`);
        }
      }

      // Resolve a stored GL account value to a real Buildium API Id.
      // The value might already be a valid Buildium Id, or it might be an account number (e.g. "4002").
      function resolveGLId(storedValue: string | number, storedName?: string | null): number | null {
        const asChargeableId = (meta: BuildiumGLAccountMeta | undefined | null, source: string): number | null => {
          if (!meta) return null;
          if (meta.isChargeable) return meta.id;
          console.warn(`[push] GL ${source} resolved to ${meta.id} (${meta.name}, Type=${meta.type}) but charges require Income or Liability`);
          return null;
        };
        const sv = String(storedValue).trim();
        if (!sv) return null;
        const num = Number(sv);
        // If it's already a known Buildium Id, use it directly
        if (!isNaN(num)) {
          const byId = asChargeableId(glAccountById.get(num), `id "${sv}"`);
          if (byId) return byId;
        }
        // Try to look it up as an account number string
        const byNumber = asChargeableId(glAccountByNumber.get(sv), `number "${sv}"`);
        if (byNumber) return byNumber;
        // Try normalized name match (for values like "4000 - Assessment Fees") and saved display names.
        const nameCandidates = [sv, storedName || ""].filter(Boolean);
        for (const candidate of nameCandidates) {
          const normalized = norm(candidate);
          const withoutLeadingNumber = norm(String(candidate).replace(/^\s*\d+\s*-\s*/, ""));
          for (const [name, meta] of glAccountByName) {
            if (!meta.isChargeable) continue;
            if (name === normalized || name === withoutLeadingNumber || normalized.includes(name) || name.includes(normalized) || (withoutLeadingNumber && name.includes(withoutLeadingNumber))) return meta.id;
          }
        }
        console.warn(`[push] resolveGLId: could not resolve "${sv}" — not found in ${glAccountById.size} IDs or ${glAccountByNumber.size} account numbers`);
        return null;
      }

      // Find a GL account for a charge type — check DB mappings first (with amount matching), then fuzzy match
      function findGLAccountId(transactionType: string, assocId?: string, amount?: number): number | null {
        // Priority 1: Per-association mapping from buildium_gl_mappings table
        if (assocId) {
          const assocMappings = glMappingsByAssoc.get(assocId);
          if (assocMappings) {
            const rules = assocMappings.get(transactionType);
            if (rules && rules.length > 0) {
              // Try to find a threshold-specific match first
              if (amount !== undefined && amount !== null) {
                const thresholdMatch = rules.find((r) => {
                  if (r.amountMin === null && r.amountMax === null) return false;
                  const minOk = r.amountMin === null || amount >= r.amountMin;
                  const maxOk = r.amountMax === null || amount <= r.amountMax;
                  return minOk && maxOk;
                });
                if (thresholdMatch) {
                  const resolved = resolveGLId(thresholdMatch.glAccountId, thresholdMatch.glAccountName);
                  if (resolved) { console.log(`[push] GL resolved for ${transactionType} (threshold): ${thresholdMatch.glAccountId} -> ${resolved}`); return resolved; }
                }
              }
              const defaultRule = rules.find((r) => r.amountMin === null && r.amountMax === null);
              if (defaultRule) {
                const resolved = resolveGLId(defaultRule.glAccountId, defaultRule.glAccountName);
                if (resolved) { console.log(`[push] GL resolved for ${transactionType} (default rule): ${defaultRule.glAccountId} -> ${resolved}`); return resolved; }
              }
              const resolved = resolveGLId(rules[0].glAccountId, rules[0].glAccountName);
              if (resolved) { console.log(`[push] GL resolved for ${transactionType} (first rule): ${rules[0].glAccountId} -> ${resolved}`); return resolved; }
              console.warn(`[push] GL mapping rules found for ${transactionType} but none resolved. Rules: ${JSON.stringify(rules)}`);
            }
          } else {
            console.warn(`[push] No GL mappings loaded for assoc ${assocId}`);
          }
        }

        // Priority 2: Fuzzy name matching against Buildium GL accounts.
        // Order matters — earlier terms are tried first. Each term is matched as a
        // whole-word substring against chargeable account names.
        const typeToSearchTerms: Record<string, string[]> = {
          assessment: ["assessment fee", "assessment", "hoa dues", "association fee", "condo fee", "maintenance fee", "monthly dues", "dues"],
          late_fee: ["late fee", "late fees", "late charge", "late charges", "late payment fee", "late payment charge", "delinquency fee", "delinquent fee", "non payment penalty"],
          interest: ["interest income", "interest", "finance charge", "finance charges", "ar interest", "interest charge"],
          legal_fee: ["legal fee", "legal fees", "attorney fee", "attorney fees", "legal", "attorney"],
          admin_fee: ["administrative fee", "administrative fees", "administration fee", "admin fee", "admin fees", "processing fee", "document fee", "notice fee", "statement fee", "mailing fee", "administrative", "admin"],
          violation: ["violation fee", "violation fine", "violation", "fine", "fines"],
          bank_fee: ["nsf fee", "nsf", "returned check", "returned payment", "bank fee", "bank charge"],
          special_assessment: ["special assessment", "capital contribution", "reserve contribution", "reserve"],
        };
        const terms = typeToSearchTerms[transactionType] || [transactionType.replace(/_/g, " ")];
        // First pass: exact normalized match on full term
        for (const term of terms) {
          const normTerm = norm(term);
          for (const [name, meta] of glAccountByName) {
            if (!meta.isChargeable) continue;
            if (name === normTerm) {
              console.log(`[push] GL exact-matched ${transactionType}: "${term}" === "${name}" -> ${meta.id}`);
              return meta.id;
            }
          }
        }
        // Second pass: substring match (term inside account name)
        for (const term of terms) {
          const normTerm = norm(term);
          for (const [name, meta] of glAccountByName) {
            if (!meta.isChargeable) continue;
            if (name.includes(normTerm)) {
              console.log(`[push] GL fuzzy-matched ${transactionType}: "${term}" found in "${name}" -> ${meta.id}`);
              return meta.id;
            }
          }
        }
        // No match — DO NOT silently fall back to a generic income account, that
        // routes admin/late/interest charges into the wrong account in Buildium.
        // Instead return null so the caller skips the entry with a clear reason
        // and the user can fix the mapping.
        console.warn(`[push] No GL account found for ${transactionType}. Available chargeable: ${chargeableGlAccounts.map(a => a.name).join(", ")}`);
        return null;
      }

      // Get Buildium bank accounts for payment push
      let buildiumBankAccounts: any[] = [];
      if (syncType === "push_payments" || syncType === "push_all") {
        buildiumBankAccounts = await buildiumFetchAll("/v1/bankaccounts", clientId, clientSecret);
      }
      const defaultBankAccountId = buildiumBankAccounts.length > 0 ? Number(buildiumBankAccounts[0].Id) : null;

      let pushed = 0, pushSkipped = 0, pushErrors = 0;
      const errorSamples: any[] = [];
      const skipSamples: any[] = [];
      const recordSkip = (entry: any, reason: string, extra: any = {}) => {
        pushSkipped++;
        if (skipSamples.length < 20) skipSamples.push({ entry_id: entry.id, transaction_type: entry.transaction_type, debit: entry.debit, credit: entry.credit, reason, ...extra });
      };
      const recordError = (entry: any, status: number | null, body: string, extra: any = {}) => {
        pushErrors++;
        if (errorSamples.length < 20) errorSamples.push({ entry_id: entry.id, transaction_type: entry.transaction_type, debit: entry.debit, credit: entry.credit, status, body: String(body).slice(0, 600), ...extra });
      };

      // Fetch local ledger entries not yet pushed to Buildium
      for (const assocId of (selectedAssociationIds.length > 0 ? selectedAssociationIds : [])) {
        // Get entries that are local-only (no buildium reference)
        // Only fetch entries that haven't been synced from/to Buildium
        let entriesQuery = supabase
          .from("owner_ledger_entries")
          .select("id, owner_id, unit_id, date, transaction_type, description, debit, credit, reference_type, reference_id")
          .eq("association_id", assocId)
          .or("reference_type.is.null,and(reference_type.neq.buildium,reference_type.neq.buildium_pushed)")
          .order("date");
        if (unitFilterId) entriesQuery = entriesQuery.eq("unit_id", unitFilterId);
        const { data: entries } = await entriesQuery;

        if (!entries || entries.length === 0) continue;

        // Filter based on push type
        const filteredEntries = entries.filter((e: any) => {
          // Skip already pushed
          if (e.reference_type === "buildium" || e.reference_type === "buildium_pushed") return false;

          // POLICY: We only PUSH charges to Buildium. Payments originate in Buildium and are pulled.
          if (syncType === "push_charges" || syncType === "push_all") {
            if (e.debit > 0 && e.credit === 0) return true;
          }
          return false;
        });

        for (const entry of filteredEntries) {
          // Resolve ownership account ID via unit
          if (!entry.unit_id) { recordSkip(entry, "no_unit_id"); continue; }

          const unit = unitLookup.units.find((u: any) => u.id === entry.unit_id);
          const buildiumUnitId = unit?.buildium_unit_id;
          if (!buildiumUnitId) { recordSkip(entry, "unit_not_linked_to_buildium", { unit_id: entry.unit_id }); continue; }

          const ownershipAccountId = unitToOwnershipAccount.get(buildiumUnitId);
          if (!ownershipAccountId) { recordSkip(entry, "no_ownership_account_for_unit", { buildium_unit_id: buildiumUnitId }); continue; }

          try {
            if (entry.debit > 0) {
              // Push as charge
              const glAccountId = findGLAccountId(entry.transaction_type, assocId, entry.debit);
              if (!glAccountId) {
                recordSkip(entry, "no_gl_account_resolved", {
                  transaction_type: entry.transaction_type,
                  available_chargeable: chargeableGlAccounts.map(a => `${a.id}:${a.name}`).slice(0, 30),
                });
                continue;
              }

              const customMemo = entry.transaction_type === "interest"
                ? null
                : (customDescByAssocType.get(assocId)?.get(entry.transaction_type) || null);
              const chargeMemo = String(customMemo || entry.description || "Charge from management system").slice(0, 65);
              const chargeBody = {
                Date: entry.date,
                Memo: chargeMemo,
                Lines: [
                  {
                    Amount: entry.debit,
                    GLAccountId: glAccountId,
                    Memo: chargeMemo,
                  },
                ],
              };

              // POST charge to Buildium
              const postRes = await fetch(`${BUILDIUM_BASE}/v1/associations/ownershipaccounts/${ownershipAccountId}/charges`, {
                method: "POST",
                headers: {
                  "x-buildium-client-id": clientId,
                  "x-buildium-client-secret": clientSecret,
                  "Content-Type": "application/json",
                  Accept: "application/json",
                },
                body: JSON.stringify(chargeBody),
              });

              if (!postRes.ok) {
                const errText = await postRes.text();
                console.warn(`Push charge failed for entry ${entry.id} (gl=${glAccountId}, type=${entry.transaction_type}): [${postRes.status}] ${errText}`);
                recordError(entry, postRes.status, errText, { gl_account_id: glAccountId, ownership_account_id: ownershipAccountId });
                continue;
              }

              const chargeResult = await postRes.json();
              const buildiumId = String(chargeResult?.Id || "");

              // Mark as pushed
              await supabase.from("owner_ledger_entries").update({
                reference_type: "buildium_pushed",
                reference_id: buildiumId || `pushed_${entry.id}`,
              }).eq("id", entry.id);

              pushed++;
            } else if (entry.credit > 0) {
              // Push as payment
              if (!defaultBankAccountId) { recordSkip(entry, "no_default_bank_account"); continue; }

              const paymentMemo = entry.description || "Payment from management system";
              const paymentBody = {
                Date: entry.date,
                PaymentMethod: "Check",
                Memo: paymentMemo,
                Lines: [
                  {
                    Amount: entry.credit,
                    GLAccountId: null, // Auto-allocate
                    Memo: paymentMemo,
                  },
                ],
                BankAccountId: defaultBankAccountId,
              };

              const postRes = await fetch(`${BUILDIUM_BASE}/v1/associations/ownershipaccounts/${ownershipAccountId}/payments`, {
                method: "POST",
                headers: {
                  "x-buildium-client-id": clientId,
                  "x-buildium-client-secret": clientSecret,
                  "Content-Type": "application/json",
                  Accept: "application/json",
                },
                body: JSON.stringify(paymentBody),
              });

              if (!postRes.ok) {
                const errText = await postRes.text();
                console.warn(`Push payment failed for entry ${entry.id}: [${postRes.status}] ${errText}`);
                recordError(entry, postRes.status, errText, { ownership_account_id: ownershipAccountId });
                continue;
              }

              const paymentResult = await postRes.json();
              const buildiumId = String(paymentResult?.Id || "");

              await supabase.from("owner_ledger_entries").update({
                reference_type: "buildium_pushed",
                reference_id: buildiumId || `pushed_${entry.id}`,
              }).eq("id", entry.id);

              pushed++;
            }
          } catch (e) {
            const msg = e instanceof Error ? e.message : String(e);
            console.warn(`Push error for entry ${entry.id}: ${msg}`);
            recordError(entry, null, msg);
          }
        }
      }

      results.push = { pushed, skipped: pushSkipped, errors: pushErrors, errorSamples, skipSamples };
    }

    // ===== PULL BUDGETS FROM BUILDIUM =====
    if (syncType === "budgets") {
      // Use the year of dateFrom if provided, otherwise current year
      const yr = ledgerDateFrom ? Number(ledgerDateFrom.slice(0, 4)) : new Date().getFullYear();

      const { bIdToLocalId } = await getAssociationMaps();
      let totalImported = 0;
      let totalUpdated = 0;
      let totalFetched = 0;

      for (const [buildiumAssocId, localAssocId] of bIdToLocalId.entries()) {
        if (!isSelected(localAssocId)) continue;

        try {
          // Buildium /v1/budgets — filter by AssociationId and fiscal year
          const budgets = await buildiumFetchAll("/v1/budgets", clientId, clientSecret, {
            associationids: String(buildiumAssocId),
            fiscalyear: String(yr),
          });
          totalFetched += budgets.length;

          for (const bud of budgets) {
            const details = Array.isArray(bud.Details) ? bud.Details : [];
            for (const detail of details) {
              const accountName = String(
                detail.GLAccountName ||
                detail.AccountName ||
                detail.Name ||
                "Uncategorized"
              );
              // Buildium budget detail months: Monthly amounts array of 12 entries
              const monthly: number[] = Array.isArray(detail.MonthlyAmounts)
                ? detail.MonthlyAmounts.map((m: any) => Number(m) || 0)
                : [];
              const annualBudget = monthly.length > 0
                ? monthly.reduce((s, n) => s + n, 0)
                : Number(detail.AnnualAmount || detail.TotalAmount || 0);

              if (!annualBudget && !accountName) continue;

              const { data: existing } = await supabase
                .from("budgets")
                .select("id")
                .eq("association_id", localAssocId)
                .eq("fiscal_year", yr)
                .eq("category", accountName)
                .maybeSingle();

              if (existing?.id) {
                await supabase.from("budgets").update({
                  budgeted_amount: annualBudget,
                  updated_at: new Date().toISOString(),
                }).eq("id", existing.id);
                totalUpdated++;
              } else {
                await supabase.from("budgets").insert({
                  association_id: localAssocId,
                  fiscal_year: yr,
                  category: accountName,
                  budgeted_amount: annualBudget,
                  actual_amount: 0,
                  notes: "Imported from Buildium",
                });
                totalImported++;
              }
            }
          }
        } catch (e) {
          console.warn(`Budgets pull for assoc ${localAssocId} failed: ${(e as Error).message}`);
        }
      }

      results.budgets = { fetched: totalFetched, imported: totalImported, updated: totalUpdated, fiscal_year: yr };
    }

    if (syncType === "bills") {
      const { bIdToLocalId } = await getAssociationMaps();

      // 1) Sync vendors so bills can be linked
      const buildiumVendors = await buildiumFetchAll("/v1/vendors", clientId, clientSecret);
      // Paginated fetch so large vendor sets don't get truncated at 1000 rows
      const existingVendors: any[] = [];
      {
        const PAGE = 1000;
        for (let from = 0; ; from += PAGE) {
          const { data, error } = await supabase
            .from("vendors")
            .select("id, name, association_id, association_ids, buildium_vendor_id")
            .order("created_at", { ascending: true })
            .range(from, from + PAGE - 1);
          if (error || !data?.length) break;
          existingVendors.push(...data);
          if (data.length < PAGE) break;
        }
      }
      const vendorByBuildiumId = new Map<string, any>();
      const vendorByAssocName = new Map<string, any>();
      const vendorByName = new Map<string, any>();
      for (const v of existingVendors) {
        if (v.buildium_vendor_id) vendorByBuildiumId.set(String(v.buildium_vendor_id), v);
        vendorByAssocName.set(`${v.association_id}|${norm(v.name)}`, v);
        // Prefer the earliest-created row as the canonical record per name
        if (!vendorByName.has(norm(v.name))) vendorByName.set(norm(v.name), v);
      }

      let vendorsCreated = 0;
      let vendorsLinked = 0;

      // Helper to ensure a local vendor exists for a buildium vendor + association
      async function ensureVendor(bVendor: any, assocLocalId: string): Promise<string | null> {
        if (!bVendor) return null;
        const bvid = String(bVendor.Id);
        const existingByBId = vendorByBuildiumId.get(bvid);
        if (existingByBId) return existingByBId.id;

        const name = (bVendor.CompanyName || bVendor.Name || `${bVendor.FirstName || ""} ${bVendor.LastName || ""}`).trim() || "Unknown Vendor";
        const byName = vendorByAssocName.get(`${assocLocalId}|${norm(name)}`);
        if (byName) {
          await supabase.from("vendors").update({ buildium_vendor_id: bvid }).eq("id", byName.id);
          byName.buildium_vendor_id = bvid;
          vendorByBuildiumId.set(bvid, byName);
          vendorsLinked++;
          return byName.id;
        }

        // Fall back to a global name match — same vendor across associations should
        // be linked, not duplicated. Append the association_id to association_ids.
        const globalMatch = vendorByName.get(norm(name));
        if (globalMatch) {
          const currentIds: string[] = Array.isArray(globalMatch.association_ids) ? globalMatch.association_ids : [];
          const mergedIds = currentIds.includes(assocLocalId) ? currentIds : [...currentIds, assocLocalId];
          await supabase
            .from("vendors")
            .update({ buildium_vendor_id: bvid, association_ids: mergedIds })
            .eq("id", globalMatch.id);
          globalMatch.buildium_vendor_id = bvid;
          globalMatch.association_ids = mergedIds;
          vendorByBuildiumId.set(bvid, globalMatch);
          vendorByAssocName.set(`${assocLocalId}|${norm(name)}`, globalMatch);
          vendorsLinked++;
          return globalMatch.id;
        }

        const insertRow = {
          association_id: assocLocalId,
          association_ids: [assocLocalId],
          name,
          email: bVendor.PrimaryEmail || bVendor.Email || null,
          phone: bVendor.PhoneNumbers?.[0]?.Number || null,
          address: formatAddress(bVendor.Address) || null,
          buildium_vendor_id: bvid,
          is_active: bVendor.IsActive !== false,
        };
        const { data: inserted, error } = await supabase.from("vendors").insert(insertRow).select("id, association_id, association_ids, name, buildium_vendor_id").single();
        if (error || !inserted) {
          console.warn(`Vendor insert failed for ${name}: ${error?.message}`);
          return null;
        }
        vendorsCreated++;
        vendorByBuildiumId.set(bvid, inserted);
        vendorByAssocName.set(`${assocLocalId}|${norm(name)}`, inserted);
        vendorByName.set(norm(name), inserted);
        return inserted.id;
      }

      const buildiumVendorById = new Map<string, any>();
      for (const bv of buildiumVendors) buildiumVendorById.set(String(bv.Id), bv);

      // 2) Fetch bills (both paid and unpaid)
      // Buildium requires BOTH FromPaidDate and ToPaidDate when results may include paid bills.
      const today = new Date().toISOString().slice(0, 10);
      const defaultPaidFrom = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
      const params: Record<string, string> = {
        frompaiddate: ledgerDateFrom || defaultPaidFrom,
        topaiddate: ledgerDateTo || today,
      };
      if (ledgerDateFrom) params.duedatefrom = ledgerDateFrom;
      if (ledgerDateTo) params.duedateto = ledgerDateTo;
      const buildiumBills = await buildiumFetchAll("/v1/bills", clientId, clientSecret, Object.keys(params).length > 0 ? params : undefined);

      // Existing bills by buildium id
      const { data: existingBills } = await supabase.from("bills").select("id, buildium_bill_id").not("buildium_bill_id", "is", null);
      const billByBuildiumId = new Map<string, string>();
      for (const b of existingBills || []) billByBuildiumId.set(String(b.buildium_bill_id), b.id);

      // Chart of accounts lookup per association (account_number -> id)
      const coaCache = new Map<string, Map<string, string>>();
      async function getCoa(assocLocalId: string) {
        if (coaCache.has(assocLocalId)) return coaCache.get(assocLocalId)!;
        const { data } = await supabase.from("chart_of_accounts").select("id, account_number").eq("association_id", assocLocalId);
        const m = new Map<string, string>();
        for (const a of data || []) m.set(String(a.account_number), a.id);
        coaCache.set(assocLocalId, m);
        return m;
      }

      let billsCreated = 0;
      let billsUpdated = 0;
      let billsSkipped = 0;
      let billsLinkedDuplicate = 0;

      // Cache of existing bills per association for fuzzy duplicate detection
      const existingBillsByAssoc = new Map<string, Array<{ id: string; vendor_id: string | null; amount: number | null; invoice_number: string | null; bill_date: string | null; buildium_bill_id: string | null }>>();
      async function getAssocBills(assocLocalId: string) {
        if (existingBillsByAssoc.has(assocLocalId)) return existingBillsByAssoc.get(assocLocalId)!;
        const { data } = await supabase
          .from("bills")
          .select("id, vendor_id, amount, invoice_number, bill_date, buildium_bill_id")
          .eq("association_id", assocLocalId);
        const list = (data || []) as any[];
        existingBillsByAssoc.set(assocLocalId, list);
        return list;
      }
      const dupKey = (s: any) => String(s || "").trim().toLowerCase().replace(/[^a-z0-9]/g, "");

      let billsDebugLogged = 0;
      for (const bb of buildiumBills) {
        const bbid = String(bb.Id);
        // Buildium bills often expose the association/property via AccountingEntity on each line
        // (AccountingEntityType = "Association" | "Rental"), not at the top level. Fall back accordingly.
        const lineEntity = Array.isArray(bb.Lines)
          ? bb.Lines.map((l: any) => l?.AccountingEntity).find((e: any) => e && (e.Id || e.AssociationId))
          : null;
        const buildiumAssocId = String(
          bb.AssociationId
            || bb.Association?.Id
            || lineEntity?.Id
            || lineEntity?.AssociationId
            || bb.PropertyId
            || ""
        );
        const assocLocalId = bIdToLocalId.get(buildiumAssocId) || null;
        if (!assocLocalId || !isSelected(assocLocalId)) {
          if (billsDebugLogged < 3) {
            console.log(`Skipping bill ${bbid}: buildiumAssocId="${buildiumAssocId}", topLevelKeys=${Object.keys(bb).join(",")}, lineEntity=${JSON.stringify(lineEntity)}`);
            billsDebugLogged++;
          }
          billsSkipped++;
          continue;
        }

        const buildiumVendor = buildiumVendorById.get(String(bb.VendorId)) || null;
        const vendorId = await ensureVendor(buildiumVendor, assocLocalId);

        // Pick first line's GL account as expense account if available
        const firstLine = Array.isArray(bb.Lines) && bb.Lines.length > 0 ? bb.Lines[0] : null;
        let expenseAccountId: string | null = null;
        if (firstLine?.GLAccountId) {
          const coa = await getCoa(assocLocalId);
          expenseAccountId = coa.get(String(firstLine.GLAccountId)) || null;
        }

        let amount = Number(bb.TotalAmount ?? bb.Amount ?? 0);
        if ((!amount || amount === 0) && Array.isArray(bb.Lines)) {
          amount = bb.Lines.reduce((sum: number, l: any) => sum + Number(l?.Amount ?? l?.TotalAmount ?? 0), 0);
        }
        const billDate = (bb.Date || bb.BillDate || new Date().toISOString().slice(0, 10)).slice(0, 10);
        const dueDate = bb.DueDate ? String(bb.DueDate).slice(0, 10) : null;
        const invoiceNumber = bb.ReferenceNumber || bb.InvoiceNumber || null;
        const description = bb.Memo || bb.Description || null;
        const isPaid = !!bb.PaidDate
          || String(bb.Status || "").toLowerCase() === "paid"
          || String(bb.ApprovalStatus || "").toLowerCase() === "paid"
          || (bb.PaidAmount && Number(bb.PaidAmount) >= amount && amount > 0);
        // Route unpaid imports through Bill Approvals so staff can review before posting.
        const status = isPaid ? "paid" : "pending";

        const payload: Record<string, any> = {
          association_id: assocLocalId,
          vendor_id: vendorId,
          invoice_number: invoiceNumber,
          bill_date: billDate,
          due_date: dueDate,
          amount,
          amount_paid: Number(bb.PaidAmount || (isPaid ? amount : 0)),
          expense_account_id: expenseAccountId,
          description,
          status,
          buildium_bill_id: bbid,
        };
        if (isPaid) payload.paid_date = (bb.PaidDate ? String(bb.PaidDate).slice(0, 10) : billDate);

        let existingId = billByBuildiumId.get(bbid);

        // Fuzzy duplicate detection: match a previously-created bill (e.g. manually entered)
        // by association + vendor + amount + (invoice# OR bill_date) when no Buildium ID match exists.
        if (!existingId) {
          const assocBills = await getAssocBills(assocLocalId);
          const invKey = dupKey(invoiceNumber);
          const dup = assocBills.find((b) => {
            if (b.buildium_bill_id) return false; // already linked to a different Buildium bill
            if (b.vendor_id !== vendorId) return false;
            const amtMatch = Math.abs(Number(b.amount || 0) - amount) < 0.01;
            if (!amtMatch) return false;
            const invMatch = invKey && dupKey(b.invoice_number) === invKey;
            const dateMatch = b.bill_date && String(b.bill_date).slice(0, 10) === billDate;
            return invMatch || dateMatch;
          });
          if (dup) {
            existingId = dup.id;
            dup.buildium_bill_id = bbid;
            billByBuildiumId.set(bbid, dup.id);
            billsLinkedDuplicate++;
          }
        }

        let finalBillId: string | null = null;
        if (existingId) {
          const { error } = await supabase.from("bills").update(payload).eq("id", existingId);
          if (error) console.warn(`Bill update ${bbid} failed: ${error.message}`);
          else { billsUpdated++; finalBillId = existingId; }
        } else {
          const { data: inserted, error } = await supabase.from("bills").insert(payload).select("id, vendor_id, amount, invoice_number, bill_date, buildium_bill_id").single();
          if (error) console.warn(`Bill insert ${bbid} failed: ${error.message}`);
          else {
            billsCreated++;
            if (inserted) {
              const list = await getAssocBills(assocLocalId);
              list.push(inserted as any);
              billByBuildiumId.set(bbid, (inserted as any).id);
              finalBillId = (inserted as any).id;
            }
          }
        }

        // Ensure a pending bill_approvals row exists so unpaid imports show up in Bill Approvals
        if (finalBillId && !isPaid) {
          const { data: existingApproval } = await supabase
            .from("bill_approvals")
            .select("id")
            .eq("bill_id", finalBillId)
            .limit(1)
            .maybeSingle();
          if (!existingApproval) {
            const { error: aErr } = await supabase.from("bill_approvals").insert({
              bill_id: finalBillId,
              association_id: assocLocalId,
              vendor_name: buildiumVendor?.Name || buildiumVendor?.CompanyName || "Buildium Vendor",
              amount,
              status: "pending",
            });
            if (aErr) console.warn(`Bill approval insert for ${bbid} failed: ${aErr.message}`);
          }
        }

        // Download bill attachments from Buildium and link the first one to the bill
        if (finalBillId) {
          try {
            const { data: existingBill } = await supabase
              .from("bills")
              .select("attachment_url")
              .eq("id", finalBillId)
              .maybeSingle();
            if (!existingBill?.attachment_url) {
              const filesRes = await fetch(`${BUILDIUM_BASE}/v1/bills/${bbid}/files`, {
                headers: {
                  "x-buildium-client-id": clientId,
                  "x-buildium-client-secret": clientSecret,
                  Accept: "application/json",
                },
              });
              if (filesRes.ok) {
                const billFiles = await filesRes.json();
                let firstUrl: string | null = null;
                for (const bf of (Array.isArray(billFiles) ? billFiles : [])) {
                  const fileId = bf.Id;
                  if (!fileId) continue;
                  const dlReq = await fetch(`${BUILDIUM_BASE}/v1/bills/${bbid}/files/${fileId}/downloadrequest`, {
                    method: "POST",
                    headers: {
                      "x-buildium-client-id": clientId,
                      "x-buildium-client-secret": clientSecret,
                      Accept: "application/json",
                    },
                  });
                  if (!dlReq.ok) continue;
                  const dl = await dlReq.json();
                  const downloadUrl: string | undefined = dl.DownloadUrl || dl.Url;
                  if (!downloadUrl) continue;
                  const fileRes = await fetch(downloadUrl);
                  if (!fileRes.ok) continue;
                  const blob = await fileRes.blob();
                  const contentType = fileRes.headers.get("content-type") || "application/octet-stream";
                  const fileName: string = bf.PhysicalFileName || bf.Title || `bill-${bbid}-${fileId}`;
                  const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
                  const storagePath = `${assocLocalId}/bills/${bbid}-${fileId}-${safeName}`;
                  const { error: upErr } = await supabase.storage.from("files").upload(storagePath, blob, {
                    upsert: true, contentType,
                  });
                  if (upErr) { console.warn(`Bill file upload ${bbid}/${fileId}: ${upErr.message}`); continue; }
                  const { data: urlData } = supabase.storage.from("files").getPublicUrl(storagePath);
                  if (!firstUrl) firstUrl = urlData.publicUrl;
                }
                if (firstUrl) {
                  await supabase.from("bills").update({ attachment_url: firstUrl }).eq("id", finalBillId);
                }
              }
            }
          } catch (e) {
            console.warn(`Bill attachments for ${bbid} failed: ${(e as Error).message}`);
          }
        }
      }

      results.bills = {
        fetched: buildiumBills.length,
        created: billsCreated,
        updated: billsUpdated,
        skipped: billsSkipped,
        linked_duplicates: billsLinkedDuplicate,
        vendors_created: vendorsCreated,
        vendors_linked: vendorsLinked,
      };

      await supabase.from("company_settings").upsert(
        { key: "buildium_last_sync_bills", value: new Date().toISOString() },
        { onConflict: "key" }
      );
    }

    if (syncType === "documents") {
      let totalFetched = 0;
      let totalCreated = 0;
      let totalSkipped = 0;
      const errors: string[] = [];

      const { data: existingDocs } = await supabase
        .from("documents")
        .select("id, file_name, association_id")
        .eq("category", "Buildium");
      const existingKey = new Set<string>(
        (existingDocs || []).map((d: any) => `${d.association_id ?? "company"}|${String(d.file_name || "").toLowerCase()}`)
      );

      // Build list of (buildiumEntityType, buildiumEntityId, localAssocId|null, label) targets
      type Target = { entityType: string | null; entityId: string | null; localAssocId: string | null; label: string };
      const targets: Target[] = [];

      if (documentScope === "company") {
        // Company-level files: not associated with any property
        targets.push({ entityType: null, entityId: null, localAssocId: null, label: "company" });
      } else {
        const { bIdToLocalId } = await getAssociationMaps();
        for (const [buildiumAssocId, localAssocId] of bIdToLocalId.entries()) {
          if (!isSelected(localAssocId)) continue;
          targets.push({ entityType: "Association", entityId: String(buildiumAssocId), localAssocId, label: `assoc ${localAssocId}` });
        }
      }

      for (const tgt of targets) {
        let files: any[] = [];
        try {
          const params: Record<string, string> = {};
          if (tgt.entityType && tgt.entityId) {
            params.entitytype = tgt.entityType;
            params.entityid = tgt.entityId;
          }
          // For company scope, fetch unfiltered and filter client-side (Buildium /v1/files requires entityid when entitytype is set)
          files = await buildiumFetchAll("/v1/files", clientId, clientSecret, Object.keys(params).length ? params : undefined);
          // For company scope, keep only files attached to the Company entity (or no entity at all)
          if (documentScope === "company") {
            files = files.filter((f: any) => {
              const et = f?.EntityType ?? f?.PhysicalFileEntity?.EntityType ?? null;
              if (!et) return true;
              const norm = String(et).toLowerCase();
              return norm === "company" || norm === "none" || norm === "general";
            });
          }
        } catch (e) {
          errors.push(`${tgt.label}: list failed: ${(e as Error).message}`);
          continue;
        }
        totalFetched += files.length;

        const filesToProcess = files.slice(documentOffset, documentOffset + documentLimit);
        const dedupAssocKey = tgt.localAssocId ?? "company";

        for (const f of filesToProcess) {
          const fileId = f.Id;
          const fileName: string = f.PhysicalFileName || f.Title || `buildium-${fileId}`;
          const title: string = f.Title || fileName;
          const dedupKey = `${dedupAssocKey}|${fileName.toLowerCase()}`;
          if (existingKey.has(dedupKey)) { totalSkipped++; continue; }

          try {
            const dlReq = await fetch(`${BUILDIUM_BASE}/v1/files/${fileId}/downloadrequest`, {
              method: "POST",
              headers: {
                "x-buildium-client-id": clientId,
                "x-buildium-client-secret": clientSecret,
                Accept: "application/json",
              },
            });
            if (!dlReq.ok) { errors.push(`file ${fileId}: downloadrequest [${dlReq.status}]`); continue; }
            const dl = await dlReq.json();
            const downloadUrl: string | undefined = dl.DownloadUrl || dl.Url;
            if (!downloadUrl) { errors.push(`file ${fileId}: no DownloadUrl`); continue; }

            const fileRes = await fetch(downloadUrl);
            if (!fileRes.ok) { errors.push(`file ${fileId}: download [${fileRes.status}]`); continue; }
            const blob = await fileRes.blob();
            const contentType = fileRes.headers.get("content-type") || "application/octet-stream";

            const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
            const folderPrefix = tgt.localAssocId ?? "company";
            const storagePath = `${folderPrefix}/Buildium/${Date.now()}-${fileId}-${safeName}`;
            const { error: upErr } = await supabase.storage.from("files").upload(storagePath, blob, {
              upsert: false, contentType,
            });
            if (upErr) { errors.push(`file ${fileId}: upload ${upErr.message}`); continue; }

            const { data: urlData } = supabase.storage.from("files").getPublicUrl(storagePath);

            const { error: insErr } = await supabase.from("documents").insert({
              title: title.replace(/\.[^/.]+$/, ""),
              file_name: fileName,
              file_url: urlData.publicUrl,
              file_size: Number(f.FileSize || blob.size || 0),
              category: "Buildium",
              association_id: tgt.localAssocId,
            });
            if (insErr) { errors.push(`file ${fileId}: insert ${insErr.message}`); continue; }

            existingKey.add(dedupKey);
            totalCreated++;
          } catch (e) {
            errors.push(`file ${fileId}: ${(e as Error).message}`);
          }
        }

        results.documents = {
          fetched: totalFetched,
          created: totalCreated,
          skipped: totalSkipped,
          processed: totalCreated + totalSkipped + errors.length,
          nextOffset: documentOffset + documentLimit < files.length ? documentOffset + documentLimit : null,
          errors: errors.slice(0, 20),
        };
      }

      if (!results.documents) {
        results.documents = { fetched: 0, created: 0, skipped: 0, processed: 0, nextOffset: null, errors };
      }

      await supabase.from("company_settings").upsert(
        { key: "buildium_last_sync_documents", value: new Date().toISOString() },
        { onConflict: "key" }
      );
    }

    return new Response(JSON.stringify({ success: true, results }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } });
  } catch (error: unknown) {
    console.error("Buildium sync error:", error);
    const msg = error instanceof Error ? error.message : "Unknown error";
    return new Response(JSON.stringify({ error: msg }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
  }
});

function mapGLAccountType(buildiumType: string): string {
  const typeMap: Record<string, string> = { Asset: "asset", Liability: "liability", Equity: "equity", Income: "income", Expense: "expense", Revenue: "income" };
  return typeMap[buildiumType] || "expense";
}
