import 'dotenv/config';
import PocketBase from 'pocketbase';
import { writeFileSync } from 'fs';
import path from 'path';

declare const fetch: typeof globalThis.fetch;

function requireEnv(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`Missing required env: ${name}`);
  return v;
}

// Excel-only fields (not synced to PocketBase)
const EXCEL_ONLY_FIELDS = new Set([
  'Contact_Notes', 'Clean_Phone_Number', 'Fax', 'Phone_Number_Extn',
  'Secondary_HO', 'Primary_SL', 'POC_Name',
]);

// Excel-authoritative fields (Excel always overwrites PB - calculated/dynamic)
const EXCEL_AUTHORITATIVE_FIELDS = new Set([
  'Due_Date_Counter', 'Active',
]);

const AUTHORITATIVE_FIELDS = new Set([
  'Job_Folder_Link', 'Flag', 'Due_Date', 'Job_Full_Name',
  'Job_QB_Link', 'Voxer_Link', 'Job_Codes', 'Estimate_Draft', 'Job_Status'
]);

const BOOLEAN_FIELDS = new Set(['Flag', 'Active', 'Estimate_Draft', 'Tax_Exempt']);
const SENTINEL_ASAP_DATE = '1990-01-01T00:00:00.000Z';

function isDateHeader(name: string): boolean { return name.toLowerCase().includes('date'); }
function isBooleanHeader(name: string): boolean {
  const lower = name.toLowerCase();
  return lower.startsWith('is_') || lower.endsWith('_flag') || lower === 'active' || lower.includes('tax_exempt') || lower === 'estimate_draft';
}
function isPocketBaseIdHeader(name: string): boolean {
  const lower = name.toLowerCase().trim();
  return lower === 'pb_id' || lower === 'pv_id' || lower === 'pvid';
}

function excelSerialToIso(num: number): string {
  const ms = (num - 25569) * 86400 * 1000;
  return new Date(ms).toISOString();
}

function toUtcIso(val: any): string {
  if (val === undefined || val === null || val === '') return '';
  if (typeof val === 'number' && !Number.isNaN(val)) return excelSerialToIso(val);
  if (typeof val === 'string') {
    const trimmed = val.trim();
    const numeric = parseFloat(trimmed);
    if (!Number.isNaN(numeric) && /^\d+(\.\d+)?$/.test(trimmed)) return excelSerialToIso(numeric);
    const d = new Date(trimmed);
    if (!Number.isNaN(d.getTime())) {
      return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds())).toISOString();
    }
    return trimmed;
  }
  return String(val);
}

function normalizeValueByHeader(header: string, val: any) {
  if (isDateHeader(header)) return toUtcIso(val);
  if (isBooleanHeader(header)) return normalizeBoolean(val);
  if (val === undefined || val === null) return '';
  return typeof val === 'string' ? val.trim() : val;
}

function normalizeBoolean(val: any): boolean {
  if (val === true) return true;
  if (val === false) return false;
  const s = String(val).toLowerCase();
  return s === 'true' || s === '1' || s === 'yes' || s === 'y' || s === 't';
}

async function acquireAppOnlyToken(): Promise<string> {
  const clientId = requireEnv('CLIENT_ID');
  const clientSecret = requireEnv('CLIENT_SECRET');
  const tenantId = requireEnv('TENANT_ID');
  const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
  const params = new URLSearchParams({
    client_id: clientId,
    client_secret: clientSecret,
    scope: 'https://graph.microsoft.com/.default',
    grant_type: 'client_credentials',
  });
  const resp = await fetch(tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: params.toString(),
  });
  if (!(resp as any).ok) throw new Error(`Token failed: ${(resp as any).status}`);
  const data = await (resp as any).json();
  return data.access_token;
}

async function createSession(userToken: string, persist: boolean = true): Promise<string> {
  const driveId = requireEnv('DRIVE_ID');
  const itemId = requireEnv('ITEM_ID');
  const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/workbook/createSession`;
  const resp = await fetch(url, {
    method: 'POST',
    headers: { Authorization: `Bearer ${userToken}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ persistChanges: persist }),
  });
  if (!(resp as any).ok) throw new Error(`CreateSession failed: ${(resp as any).status}`);
  return (await (resp as any).json())?.id as string;
}

async function getTableColumns(userToken: string, sessionId: string): Promise<string[]> {
  const driveId = requireEnv('DRIVE_ID');
  const itemId = requireEnv('ITEM_ID');
  const tableName = requireEnv('EXCEL_TABLE').split('/').pop() as string;
  const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/workbook/tables/${encodeURIComponent(tableName)}/columns`;
  const resp = await fetch(url, {
    method: 'GET',
    headers: { Authorization: `Bearer ${userToken}`, 'workbook-session-id': sessionId },
  });
  if (!(resp as any).ok) throw new Error(`GetTableColumns failed: ${(resp as any).status}`);
  const data = await (resp as any).json();
  return (data?.value ?? []).map((col: any) => col.name);
}

async function getTableRows(userToken: string, sessionId: string) {
  const driveId = requireEnv('DRIVE_ID');
  const itemId = requireEnv('ITEM_ID');
  const tableName = requireEnv('EXCEL_TABLE').split('/').pop() as string;
  const url = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/workbook/tables/${encodeURIComponent(tableName)}/rows`;
  const resp = await fetch(url, {
    method: 'GET',
    headers: { Authorization: `Bearer ${userToken}`, 'workbook-session-id': sessionId },
  });
  if (!(resp as any).ok) throw new Error(`GetTableRows failed: ${(resp as any).status}`);
  const data = await (resp as any).json();
  const rows: any[] = [];
  for (const row of (data?.value as any[]) ?? []) {
    rows.push((row?.values?.[0] ?? []) as any);
  }
  return { values: rows };
}

function mapRowToObject(headers: string[], row: any[]): Record<string, any> {
  const obj: Record<string, any> = {};
  for (let i = 0; i < headers.length; i++) {
    const key = headers[i];
    if (!key) continue;
    obj[key] = row[i];
  }
  return obj;
}

function getExcelPocketBaseId(headers: string[], excelObj: Record<string, any> | undefined): string | undefined {
  if (!excelObj) return undefined;
  for (const h of headers) {
    if (!h || !isPocketBaseIdHeader(h)) continue;
    const v = excelObj[h];
    if (v === undefined || v === null) return undefined;
    return String(v).trim();
  }
  return undefined;
}

function diffRecords(headers: string[], excelObj: Record<string, any> | undefined, pbObj: Record<string, any> | undefined) {
  const diffs: Array<{ field: string; excel: any; pocketbase: any }> = [];
  for (const h of headers) {
    if (!h || isExcelOnlyField(h) || isExcelAuthoritativeField(h)) continue;
    const exVal = excelObj ? normalizeValueByHeader(h, excelObj[h]) : undefined;
    const pbRaw = pbObj ? (isPocketBaseIdHeader(h) ? (pbObj as any).id : (pbObj as any)[h]) : undefined;
    const pbVal = pbObj ? normalizeValueByHeader(h, pbRaw) : undefined;
    const isBool = isBooleanHeader(h);
    
    // Special handling for Notes: check if merge would be needed
    if (h === 'Notes') {
      if (notesMergeNeeded(exVal, pbVal)) {
        diffs.push({ field: h, excel: exVal, pocketbase: pbVal });
      }
      continue;
    }
    
    const left = isBool ? exVal : (exVal === undefined ? '' : String(exVal).trim());
    const right = isBool ? pbVal : (pbVal === undefined ? '' : String(pbVal).trim());
    let equal = left === right;
    if (!isBool && h === 'Due_Date') {
      const exStr = String(left).trim().toUpperCase();
      const pbStr = String(right).trim();
      if (exStr === 'ASAP' && (pbStr === '' || pbStr === SENTINEL_ASAP_DATE)) equal = true;
    }
    if (!equal) diffs.push({ field: h, excel: exVal, pocketbase: pbVal });
  }
  return diffs;
}

function isExcelOnlyField(field: string): boolean { return EXCEL_ONLY_FIELDS.has(field); }
function isExcelAuthoritativeField(field: string): boolean { return EXCEL_AUTHORITATIVE_FIELDS.has(field); }

async function fetchExcelMap() {
  const tokenInfo = await acquireAppOnlyToken();
  const sessionId = await createSession(tokenInfo, true);
  const headers = await getTableColumns(tokenInfo, sessionId);
  if (!headers.includes('Job_Number')) throw new Error('Job_Number header required');
  const rows = await getTableRows(tokenInfo, sessionId);
  const byKey = new Map<string, Record<string, any>>();
  for (const r of rows.values.map((row, i) => mapRowToObject(headers, row))) {
    const key = r['Job_Number'];
    if (key) byKey.set(String(key), r);
  }
  return { headers, byKey };
}

async function fetchPocketBaseMap(collection: string) {
  const pbUrl = (() => {
    const explicit = process.env.PB_DB;
    if (explicit) return explicit;
    const redirect = process.env.REDIRECT_URI;
    if (!redirect) throw new Error('Missing PB_DB or REDIRECT_URI');
    const u = new URL(redirect);
    return `${u.protocol}//${u.host}`;
  })();
  const pb = new PocketBase(pbUrl);
  await pb.collection(process.env.PB_AUTH_COLLECTION || 'Users').authWithPassword(
    requireEnv('PB_AGENT_EMAIL'),
    requireEnv('PB_AGENT_PASSWORD')
  );
  const records = await pb.collection(collection).getFullList(200);
  const byKey = new Map<string, Record<string, any>>();
  for (const rec of records as any[]) {
    const key = rec['Job_Number'];
    if (key) byKey.set(String(key), rec);
  }
  return byKey;
}

async function runAudit() {
  const collection = requireEnv('PB_COLLECTION');
  const { headers, byKey: excelMap } = await fetchExcelMap();
  const pbMap = await fetchPocketBaseMap(collection);

  const commonKeys = [...excelMap.keys()].filter(k => pbMap.has(k));
  const mismatches: Array<{ key: string; diffs: Array<{ field: string; excel: any; pocketbase: any }> }> = [];

  for (const k of commonKeys) {
    const diffs = diffRecords(headers, excelMap.get(k), pbMap.get(k));
    if (diffs.length > 0) mismatches.push({ key: k, diffs });
  }

  console.log(JSON.stringify({
    summary: {
      excelCount: excelMap.size,
      pocketbaseCount: pbMap.size,
      mismatched: mismatches.length,
    },
    mismatches,
  }, null, 2));
}

function normalizeNotes(val: any): string {
  return (val ?? '').toString().replace(/\r\n/g, '\n').trim();
}

function mergeNotes(excelNotes: any, pbNotes: any): string {
  const excel = normalizeNotes(excelNotes);
  const pb = normalizeNotes(pbNotes);
  if (!excel && !pb) return '';
  if (!excel) return pb;
  if (!pb) return excel;
  if (excel === pb) return excel;
  return `${excel}\n\n${pb}`;
}

function notesMergeNeeded(excelNotes: any, pbNotes: any): boolean {
  const merged = mergeNotes(excelNotes, pbNotes);
  const pbStr = normalizeNotes(pbNotes);
  const exStr = normalizeNotes(excelNotes);
  // If normalized strings already equal, no merge needed
  if (exStr === pbStr) return false;
  // Otherwise, merge changes PB content
  return merged !== pbStr;
}

function normalizeBoolean2(val: any): boolean {
  if (val === true) return true;
  if (val === false) return false;
  const s = String(val).trim().toLowerCase();
  return s === 'true' || s === 't' || s === 'yes' || s === 'y' || s === '1';
}

// Columns that contain formulas and should never be overwritten
const FORMULA_COLUMNS = new Set(['Active', 'Due_Date_Counter']);

async function updateExcelRow(userToken: string, sessionId: string, jobNumber: string, mergedNotes: string, headers: string[]) {
  const driveId = requireEnv('DRIVE_ID');
  const itemId = requireEnv('ITEM_ID');
  const tableName = requireEnv('EXCEL_TABLE').split('/').pop() as string;
  
  // Get all rows and find the Job_Number match
  const rowUrl = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/workbook/tables/${encodeURIComponent(tableName)}/rows`;
  const rowResp = await fetch(rowUrl, {
    method: 'GET',
    headers: { Authorization: `Bearer ${userToken}`, 'workbook-session-id': sessionId },
  });
  if (!(rowResp as any).ok) throw new Error(`GetTableRows failed: ${(rowResp as any).status}`);
  const rowData = await (rowResp as any).json();
  
  const jobRow = (rowData?.value ?? []).find((r: any) => {
    const values = r?.values?.[0] ?? [];
    return String(values[0]).trim() === String(jobNumber).trim();
  });
  
  if (!jobRow) return; // Job not found
  
  // Find the Notes column index
  const notesIdx = headers.indexOf('Notes');
  if (notesIdx === -1) return; // Notes column not found
  
  console.log(`\n→ Writing to Excel Job ${jobNumber}: ${mergedNotes.length} chars`);
  console.log(`  Content: ${JSON.stringify(mergedNotes)}`);
  
  // Use table column's DataBodyRange to update specific cell
  const driveId2 = requireEnv('DRIVE_ID');
  const itemId2 = requireEnv('ITEM_ID');
  const tableName2 = requireEnv('EXCEL_TABLE').split('/').pop() as string;
  
  // Access the Notes column's DataBodyRange, then use row/cell API
  const colRangeUrl = `https://graph.microsoft.com/v1.0/drives/${driveId2}/items/${itemId2}/workbook/tables/${encodeURIComponent(tableName2)}/columns/${encodeURIComponent('Notes')}/dataBodyRange/cell(row=${jobRow.index},column=0)`;
  const updateResp = await fetch(colRangeUrl, {
    method: 'PATCH',
    headers: { Authorization: `Bearer ${userToken}`, 'workbook-session-id': sessionId, 'Content-Type': 'application/json' },
    body: JSON.stringify({ values: [[mergedNotes]] }),
  });
  if (!(updateResp as any).ok) {
    throw new Error(`Excel cell update failed: ${(updateResp as any).status} - ${await (updateResp as any).text()}`);
  }
}



async function runApply() {
  // First, run a fresh audit to get current mismatches
  // This ensures we have the latest data, not stale cached data
  console.log('🔍 Running fresh audit before apply...');
  const collection = requireEnv('PB_COLLECTION');
  const { headers, byKey: excelMap } = await fetchExcelMap();
  const pbMap = await fetchPocketBaseMap(collection);

  const commonKeys = [...excelMap.keys()].filter(k => pbMap.has(k));
  const mismatches: Array<any> = [];

  for (const k of commonKeys) {
    const diffs = diffRecords(headers, excelMap.get(k), pbMap.get(k));
    if (diffs.length > 0) mismatches.push({ key: k, diffs });
  }

  console.log(`✓ Fresh audit complete: ${mismatches.length} mismatches found`);

  const pbUrl = (() => {
    const explicit = process.env.PB_DB;
    if (explicit) return explicit;
    const redirect = process.env.REDIRECT_URI;
    if (!redirect) throw new Error('Missing PB_DB or REDIRECT_URI');
    const u = new URL(redirect);
    return `${u.protocol}//${u.host}`;
  })();
  const pb = new PocketBase(pbUrl);
  await pb.collection(process.env.PB_AUTH_COLLECTION || 'Users').authWithPassword(
    requireEnv('PB_AGENT_EMAIL'),
    requireEnv('PB_AGENT_PASSWORD')
  );

  // Get Excel access for writing merged Notes back
  const excelToken = await acquireAppOnlyToken();
  const excelSessionId = await createSession(excelToken, true);
  const excelHeaders = await getTableColumns(excelToken, excelSessionId);

  let updated = 0, skipped = 0;

  for (const job of mismatches) {
    const key = job.key || job.jobNumber;
    const diffs = job.diffs || [{ field: job.field, excel: job.to, pocketbase: job.from }];
    const updatePayload: Record<string, any> = {};
    let mergedNotes: string | null = null;

    for (const d of diffs) {
      const field = d.field;
      const excelVal = d.excel;
      const pbVal = d.pocketbase;
      if (field === 'Notes') {
        const merged = mergeNotes(excelVal, pbVal);
        console.log(`\n=== JOB ${key} NOTES MERGE ===`);
        console.log(`Excel (${(excelVal ?? '').toString().length} chars): ${JSON.stringify(excelVal)}`);
        console.log(`PB (${(pbVal ?? '').toString().length} chars): ${JSON.stringify(pbVal)}`);
        console.log(`Merged (${merged.length} chars): ${JSON.stringify(merged)}`);
        
        if (merged !== (pbVal ?? '').toString().trim()) {
          console.log(`✓ Merge needed - updating PB`);
          updatePayload[field] = merged;
        } else {
          console.log(`✗ Merge not needed`);
        }
        mergedNotes = merged;
      } else if (AUTHORITATIVE_FIELDS.has(field)) {
        if (field === 'Due_Date') {
          const exStr = String(excelVal ?? '').trim();
          if (exStr.toUpperCase() === 'ASAP') {
            if (String(pbVal ?? '') !== SENTINEL_ASAP_DATE) updatePayload[field] = SENTINEL_ASAP_DATE;
          } else {
            if (String(excelVal ?? '') !== String(pbVal ?? '')) updatePayload[field] = excelVal;
          }
        } else if (BOOLEAN_FIELDS.has(field)) {
          const excelBool = normalizeBoolean2(excelVal);
          const pbBool = normalizeBoolean2(pbVal);
          if (excelBool !== pbBool) updatePayload[field] = excelBool;
        } else {
          if (String(excelVal ?? '') !== String(pbVal ?? '')) updatePayload[field] = excelVal;
        }
      }
    }

    if (Object.keys(updatePayload).length === 0) {
      skipped++;
      continue;
    }

    const filter = `Job_Number=\"${String(key).replace(/\"/g, '\\\"')}\"`;
    const found = await pb.collection(collection).getList(1, 1, { filter });
    if (!found?.items?.length) continue;

    const existing = found.items[0];
    await pb.collection(collection).update(existing.id, updatePayload);
    updated++;
    
    // If Notes were merged, also update Excel to keep chain synchronized
    if (mergedNotes !== null) {
      try {
        await updateExcelRow(excelToken, excelSessionId, String(key), mergedNotes, excelHeaders);
        console.log(`✓ Job ${key}: ${Object.keys(updatePayload).join(', ')} (chain synced to both)`);
      } catch (err: any) {
        console.error(`✗ Job ${key}: Excel write failed - ${err?.message || err}`);
        console.log(`✓ Job ${key}: ${Object.keys(updatePayload).join(', ')} (PocketBase updated, Excel write failed)`);
      }
    } else {
      console.log(`✓ Job ${key}: ${Object.keys(updatePayload).join(', ')}`);
    }
  }

  console.log(`\n=== Summary: ${updated} updated, ${skipped} skipped ===`);
}

async function main() {
  const mode = process.argv[2];
  if (mode === '--audit') {
    await runAudit();
  } else if (mode === '--apply') {
    await runApply();
  } else {
    console.log('Usage: bun index.ts [--audit|--apply]');
    console.log('  --audit  Compare Excel vs PocketBase (read-only)');
    console.log('  --apply  Apply updates from deltas.json to PocketBase');
    process.exit(1);
  }
}

main().catch(err => {
  console.error('✗ Error:', err?.message || err);
  process.exit(1);
});
