// src/integrations/serviceChannel/client.js

import axios from 'axios';
import { Mutex } from 'async-mutex';
import qs from 'querystring';
import { loadSecrets } from '../../config/secrets.js';
import { logger } from '../../utils/logger.js';

const secrets = loadSecrets();

const mutex = new Mutex();

let cachedToken = null;
let tokenExpiresAt = 0;

// Short-lived cache of the LAST token fetch failure. When SC credentials are
// invalid/expired, a single approval webhook can otherwise trigger 6+ token
// fetches (one per SC call in the flow), each hitting the OAuth endpoint and
// spamming the log. Cache the failure for a short window and fast-fail without
// hitting SC again.
let lastTokenError = null;
let lastTokenErrorAt = 0;
const TOKEN_FAIL_CACHE_MS = 30 * 1000; // 30 s

// ──────────────────────────────────────────────
// Dedicated axios instance for ServiceChannel
// ──────────────────────────────────────────────
export const scAxios = axios.create({
  baseURL: secrets.serviceChannel.baseUrl,
  timeout: 60000,
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
});

// Automatic token injection + refresh on 401
scAxios.interceptors.request.use(async (cfg) => {
  if (!cfg.headers.Authorization) {
    const token = await getServiceChannelToken();
    cfg.headers.Authorization = `Bearer ${token}`;
  }
  return cfg;
});

/**
 * Best-effort short summary of an axios error's response body for log lines.
 * SC 4xx bodies are usually JSON like { "Message": "...", "ModelState": {...} }
 * or plain text. We cap the length so a stack trace doesn't blow up the log.
 */
function summarizeAxiosErrorBody(error) {
  const data = error?.response?.data;
  if (data == null) return null;
  let s;
  if (typeof data === 'string') {
    s = data;
  } else if (Buffer.isBuffer(data)) {
    s = data.toString('utf8');
  } else {
    try { s = JSON.stringify(data); } catch { s = String(data); }
  }
  s = s.replace(/\s+/g, ' ').trim();
  return s.length > 500 ? s.substring(0, 500) + `…(${s.length} chars)` : s;
}

scAxios.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401) {
      logger('scAxios', '401 detected → forcing token refresh');
      await getServiceChannelToken(true); // force refresh
      // Retry once with new token
      const originalRequest = error.config;
      if (!originalRequest._retry) {
        originalRequest._retry = true;
        originalRequest.headers.Authorization = `Bearer ${cachedToken}`;
        return scAxios(originalRequest);
      }
    }

    // For every non-401 4xx/5xx, enrich the error message with SC's response
    // body preview + the HTTP method+path so downstream loggers get the *why*
    // (SC returns detailed ModelState / Message JSON on 400s) instead of the
    // generic axios "Request failed with status code 400".
    const status = error?.response?.status;
    if (status && status !== 401) {
      const method = String(error?.config?.method || '').toUpperCase();
      const url = error?.config?.url || '';
      const body = summarizeAxiosErrorBody(error);
      const suffix = ` [${method} ${url} → ${status}${body ? ` body=${body}` : ''}]`;
      // Keep the original axios message but append ours so nothing breaks
      // that greps for the classic "Request failed with status code XXX".
      if (typeof error.message === 'string' && !error.message.includes(suffix)) {
        error.message = error.message + suffix;
      }
    }
    return Promise.reject(error);
  }
);

// ──────────────────────────────────────────────
// Token management – cached + mutex-protected
// ──────────────────────────────────────────────
export async function getServiceChannelToken(forceRefresh = false) {
  if (!secrets.serviceChannel.clientId || !secrets.serviceChannel.clientSecret) {
    throw new Error(
      'ServiceChannel credentials are not configured. ' +
      'Set SC_CLIENT_ID, SC_CLIENT_SECRET, SC_USERNAME, and SC_PASSWORD environment variables.'
    );
  }

  const release = await mutex.acquire();

  // Fast-fail if the last fetch just failed. Prevents a cascade of retries
  // (and OAuth calls) when credentials are broken.  Handled OUTSIDE the
  // try/catch so we don't double-log or double-wrap the error message.
  {
    const now = Date.now();
    if (!forceRefresh && lastTokenError && (now - lastTokenErrorAt) < TOKEN_FAIL_CACHE_MS) {
      release();
      throw new Error(lastTokenError);
    }
  }

  try {
    const now = Date.now();

    if (!forceRefresh && cachedToken && now < tokenExpiresAt) {
      return cachedToken;
    }

    logger('getServiceChannelToken', 'Fetching new token');

    const basicAuth = Buffer.from(
      `${secrets.serviceChannel.clientId}:${secrets.serviceChannel.clientSecret}`
    ).toString('base64');

    const response = await axios.post(
      secrets.serviceChannel.oauthUrl,
      qs.stringify({
        grant_type: 'password',
        username: secrets.serviceChannel.username,
        password: secrets.serviceChannel.password,
      }),
      {
        headers: {
          'Authorization': `Basic ${basicAuth}`,
          'Content-Type': 'application/x-www-form-urlencoded',
        },
        timeout: 10000,
      }
    );

    const { access_token, expires_in } = response.data;

    cachedToken = access_token;
    tokenExpiresAt = now + (expires_in * 1000) - 300_000; // refresh 5 min early
    lastTokenError = null;
    lastTokenErrorAt = 0;

    logger('getServiceChannelToken', `New token acquired (expires in ~${expires_in / 60} min)`);
    return access_token;
  } catch (err) {
    const msg = err.response
      ? `${err.response.status} – ${JSON.stringify(err.response.data)}`
      : err.message;

    // Cache the failure so subsequent calls in the burst fail fast (no more
    // OAuth hits) instead of each retrying and spamming the log.
    const wrapped = `ServiceChannel token fetch failed: ${msg}`;
    lastTokenError = wrapped;
    lastTokenErrorAt = Date.now();

    logger('getServiceChannelToken', `Failed: ${msg}`);

    // ServiceChannel returns HTTP 400 with the literal string
    // "Object reference not set to an instance of an object." when its OAuth
    // endpoint receives credentials that don't map to a user (rotated password,
    // disabled client, etc.). Surface a clear hint the first time this happens
    // so it's obvious in the log what needs fixing (rotate SC_PASSWORD /
    // SC_CLIENT_SECRET in .env).
    if (
      err.response?.status === 400 &&
      typeof err.response.data === 'string' &&
      err.response.data.toLowerCase().includes('object reference not set')
    ) {
      logger(
        'getServiceChannelToken',
        'HINT: 400 "Object reference not set..." from SC OAuth almost always ' +
        'means SC_CLIENT_ID/SC_CLIENT_SECRET/SC_USERNAME/SC_PASSWORD are stale. ' +
        'Rotate credentials in ServiceChannel and update .env, then restart.',
        'warn'
      );
    }

    throw new Error(wrapped);
  } finally {
    release();
  }
}

// ──────────────────────────────────────────────
// Simple health-check / token validation
// ──────────────────────────────────────────────
export async function validateToken() {
  try {
    await scAxios.get('/workorders?$top=1');
    return true;
  } catch (err) {
    logger('validateToken', `Token validation failed: ${err.message}`);
    return false;
  }
}

// src/integrations/serviceChannel/client.js
// ... your existing code ...

/**
 * Get AV tickets that have been in "COMPLETED" + "CONFIRMED" status for at least X days
 * Used for automatic Webex space cleanup
 */
export async function getTicketsReadyForSpaceCleanup(daysAfterCompletion = 7) {
  console.log(`[SC-CLIENT] Checking for tickets ready for cleanup (${daysAfterCompletion} days after completion)`);

  try {
    const cutoffDate = new Date();
    cutoffDate.setDate(cutoffDate.getDate() - daysAfterCompletion);
    const cutoffStr = cutoffDate.toISOString().split('T')[0]; // YYYY-MM-DD

    const response = await scAxios.get('/workorders', {
      params: {
        'trade': 'Audio',                    // Change to 'Audio & Video' if needed
        'status': 'Completed'              // Primary status
      }
    });

    const tickets = response.data?.value || response.data || [];
    console.log(`Completed WorkOrders: ${tickets.length}`)
    // Strict filter: only Completed + Confirmed (or Completed + Completed)
    const readyTickets = tickets.filter(ticket => {
      const primary = ticket.Status?.Primary?.toUpperCase();
      const extended = ticket.Status?.Extended?.toUpperCase();
      return primary === 'COMPLETED' && 
             (extended === 'CONFIRMED' || extended === 'COMPLETED');
    });

    console.log(`[SC-CLIENT] Found ${readyTickets.length} tickets ready for space cleanup`);

    return readyTickets.map(ticket => ({
      workOrderId: ticket.Id,
      woNumber: ticket.WorkorderNumber || ticket.Id,
      primaryStatus: ticket.Status?.Primary,
      extendedStatus: ticket.Status?.Extended,
      updatedDate: ticket.UpdatedDate,
      description: ticket.Description?.substring(0, 100) || ''
    }));
  } catch (err) {
    console.error('[SC-CLIENT] Error fetching tickets for cleanup:', err.message);
    if (err.response) {
      console.log('[SC-CLIENT] Response status:', err.response.status);
      console.log('[SC-CLIENT] Response body:', JSON.stringify(err.response.data, null, 2));
    }
    return [];
  }
}

/**
 * Get current status of a specific work order
 */
export async function getWorkOrderStatus(woId) {
  try {
    const response = await scAxios.get(`/workorders/${woId}`, {
      params: {
        $select: 'Id,WorkorderNumber,Status,UpdatedDate,Description,Provider,ProviderName,ProviderId,IsInvoiced,Invoice,ApprovalCode,Category',
      },
    });

    const ticket = response.data;
    const invoiceId = ticket.Invoice?.Id ?? null;
    let invoiceTotal = ticket.Invoice?.InvoiceTotal ?? ticket.Invoice?.Total ?? null;

    if (invoiceId && (invoiceTotal == null || Number(invoiceTotal) === 0)) {
      const inv = await getInvoice(invoiceId);
      if (inv) {
        invoiceTotal = inv.InvoiceTotal ?? inv.Total ?? invoiceTotal;
      }
    }

    return {
      workOrderId: ticket.Id,
      woNumber: ticket.WorkorderNumber || ticket.Id,
      primaryStatus: ticket.Status?.Primary,
      extendedStatus: ticket.Status?.Extended,
      updatedDate: ticket.UpdatedDate,
      description: ticket.Description?.substring(0, 100) || '',
      providerName: ticket.Provider?.Name || ticket.ProviderName || '',
      providerId: ticket.Provider?.Id ?? ticket.ProviderId ?? null,
      isInvoiced: ticket.IsInvoiced === true,
      invoiceId,
      invoiceNumber: ticket.Invoice?.Number ?? null,
      invoiceTotal: invoiceTotal != null ? Number(invoiceTotal) : null,
      approvalCode: ticket.ApprovalCode ?? '',
      category: ticket.Category ?? '',
    };
  } catch (err) {
    console.error(`[SC-CLIENT] Error getting status for WO ${woId}:`, err.message);
    return null;
  }
}

/**
 * Update work order primary/extended status.
 * PUT /workorders/{woId}/status
 */
export async function updateWorkOrderStatus(woId, { primary, extended, note }) {
  if (!woId || !primary) {
    throw new Error('woId and primary status are required');
  }

  const body = {
    Status: {
      Primary: primary,
      Extended: extended || '',
    },
    Note: String(note || '').trim(),
  };

  await scAxios.put(`/workorders/${woId}/status`, body);
  logger(
    'sc:updateWorkOrderStatus',
    `WO ${woId} status → ${primary}${extended ? ` / ${extended}` : ''}`
  );
}

/**
 * Approve an invoice in ServiceChannel.
 * PUT /invoices/{invoiceId}/approve
 */
export async function approveInvoice(invoiceId, { approvalCode = '', comments = '', category = '' } = {}) {
  if (!invoiceId) {
    throw new Error('invoiceId is required to approve');
  }

  const params = new URLSearchParams();
  params.set('approvalCode', String(approvalCode ?? ''));
  params.set('comments', String(comments ?? ''));
  params.set('category', String(category ?? ''));

  await scAxios.put(`/invoices/${invoiceId}/approve?${params.toString()}`);
  logger('sc:approveInvoice', `Approved invoice ${invoiceId}`);
}

/**
 * Reject an invoice in ServiceChannel.
 * PUT /invoices/{invoiceId}/reject
 */
export async function rejectInvoice(invoiceId, { comments = '', isNotifyProvider = true } = {}) {
  if (!invoiceId) {
    throw new Error('invoiceId is required to reject');
  }

  const params = new URLSearchParams();
  params.set('comments', String(comments ?? ''));
  params.set('isNotifyProvider', String(isNotifyProvider));

  await scAxios.put(`/invoices/${invoiceId}/reject?${params.toString()}`);
  logger('sc:rejectInvoice', `Rejected invoice ${invoiceId}`);
}

/**
 * Fetch invoice details by id.
 * GET /invoices/{invoiceId}
 */
export async function getInvoice(invoiceId) {
  if (!invoiceId) return null;
  try {
    const res = await fetchWithRetry(`/invoices/${invoiceId}`, { timeout: 30000 });
    return res.data;
  } catch (err) {
    logger('sc:getInvoice', `Failed for invoice ${invoiceId}: ${err.message}`, 'warn');
    return null;
  }
}
export default scAxios;

// ──────────────────────────────────────────────
// Resilient fetch helper (for bulk operations)
// ──────────────────────────────────────────────

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * GET with retry for rate limits (429) and transient server errors (5xx).
 *
 * - Uses the shared scAxios instance (automatic token + 401 refresh).
 * - Respects `Retry-After` header when the server provides one.
 * - Exponential backoff + jitter to avoid thundering herd.
 * - Safe default: 5 retries, starting ~750ms base delay.
 *
 * Use this (instead of raw scAxios.get) for any high-volume or bulk calls.
 */
export async function fetchWithRetry(
  url,
  config = {},
  { maxRetries = 5, baseDelayMs = 750 } = {}
) {
  let attempt = 0;

  while (true) {
    try {
      return await scAxios.get(url, config);
    } catch (err) {
      const status = err.response?.status;
      const isRetryable = status === 429 || (status >= 500 && status < 600);

      if (isRetryable && attempt < maxRetries) {
        const retryAfterHeader = err.response?.headers?.['retry-after'];
        let delayMs = baseDelayMs * Math.pow(2, attempt);

        if (retryAfterHeader) {
          const parsed = parseInt(retryAfterHeader, 10);
          if (!Number.isNaN(parsed) && parsed > 0) {
            delayMs = parsed * 1000;
          }
        }

        // Jitter: 75%–125% of calculated delay (prevents synchronized retries)
        delayMs *= 0.75 + Math.random() * 0.5;
        delayMs = Math.min(Math.max(300, Math.round(delayMs)), 30000);

        logger(
          'sc:fetchRetry',
          `${status} on ${url} (attempt ${attempt + 1}/${maxRetries}) → retry in ${delayMs}ms`
        );

        await sleep(delayMs);
        attempt++;
        continue;
      }

      // Non-retryable or exhausted retries — let caller handle
      throw err;
    }
  }
}

export { sleep };

// ──────────────────────────────────────────────
// New: Proposal & NTE approval support (for WAITING FOR APPROVAL webhooks + Adaptive Card flow)
// ──────────────────────────────────────────────

/**
 * Fetch proposals associated with a work order.
 * Now uses the proper/recommended endpoint:
 * GET /proposals/GetProposalsAssociatedWithWorkOrderAsync?trackingNumber={woId}
 * (The old /workorders/{id}/proposals had reliability issues.)
 */
export async function getWorkOrderProposals(woId) {
  return getProposalsAssociatedWithWorkOrder(woId);
}

/**
 * Lightweight fetch for NTE + basic status (no full notes).
 */
export async function getWorkOrderForNte(woId) {
  if (!woId) return null;
  try {
    const res = await fetchWithRetry(`/workorders/${woId}`, {
      params: { $select: 'Id,WorkorderNumber,Nte,Status,LocationName,LocationStoreId' },
      timeout: 30000,
    });
    return res.data;
  } catch (err) {
    logger('sc:getForNte', `Failed for WO ${woId}: ${err.message}`, 'warn');
    return null;
  }
}

/**
 * Update the Nte (Not-To-Exceed) value on a work order.
 * Uses PATCH on the root workorder resource.
 */
export async function updateWorkOrderNte(woId, nteValue) {
  const nte = Number(nteValue);
  if (!woId || !Number.isFinite(nte)) {
    throw new Error('woId and numeric nteValue are required');
  }
  await scAxios.patch(`/workorders/${woId}`, { Nte: nte });
  logger('sc:updateNte', `WO ${woId} Nte set to ${nte}`);
}

/**
 * Add a note/comment to a work order.
 * POST /workorders/{workorderId}/notes — body requires `Note` (string).
 */
export async function addWorkOrderNote(woId, noteText, options = {}) {
  const text = String(noteText || '').trim();
  if (!woId || !text) {
    throw new Error('woId and noteText are required');
  }

  const body = {
    Note: text,
    ActionRequired: options.actionRequired === true,
  };
  if (options.mailedTo) body.MailedTo = options.mailedTo;

  await scAxios.post(`/workorders/${woId}/notes`, body);
  logger('sc:addWorkOrderNote', `Added note to WO ${woId}`);
}

/**
 * DEPRECATED. Use addWorkOrderNote() instead.
 *
 * @deprecated
 */
export async function addApprovalNote(woId, text) {
  return addWorkOrderNote(woId, text);
}

/**
 * Fetch a specific proposal by its ID/Number (parsed from work order notes like "Proposal #94434 has been created").
 * Tries common SC v3 endpoints. Returns the proposal object which should include charges, Total/Amount,
 * and itemized list (Items / LineItems / ProposalItems) with parts + labor breakout.
 */
export async function getProposal(proposalId) {
  if (!proposalId) return null;
  const pid = String(proposalId).trim();

  // Common patterns for ServiceChannel proposal lookup
  const urlAttempts = [
    `/proposals/${pid}`,
    `/proposals?proposalNumber=${pid}`,
    `/proposals?Number=${pid}`,
    `/proposals?Id=${pid}`,
    // Fallback: search within workorders if we had woId, but here we use direct
  ];

  for (const url of urlAttempts) {
    try {
      const res = await fetchWithRetry(url, { timeout: 30000 });
      let data = res.data;
      if (data?.value && Array.isArray(data.value)) {
        data = data.value[0] || null;
      }
      if (data && (data.Id || data.Number || data.ProposalNumber)) {
        logger('sc:getProposal', `Successfully fetched proposal ${pid} via ${url}`);
        return data;
      }
    } catch (err) {
      // If the token itself is broken, all remaining URL attempts will fail
      // the same way — abort the loop immediately instead of hammering.
      if (err.message && err.message.startsWith('ServiceChannel token fetch failed')) {
        logger('sc:getProposal', `Aborting proposal lookup for ${pid}: ${err.message}`, 'warn');
        return null;
      }
      // try next
      if (err.response && err.response.status !== 404) {
        logger('sc:getProposal', `Attempt ${url} failed for ${pid}: ${err.message}`, 'warn');
      }
    }
  }

  logger('sc:getProposal', `Could not locate proposal ${pid} after multiple attempts`, 'warn');
  return null;
}

/**
 * Proper way to find proposals associated with a work order (per ServiceChannel API).
 * Uses GET /proposals/GetProposalsAssociatedWithWorkOrderAsync?trackingNumber={woId}
 * Returns array of lightweight proposal refs { ID, ProposalNumber, Status, ... }
 */
export async function getProposalsAssociatedWithWorkOrder(woId) {
  if (!woId) return [];
  try {
    const res = await fetchWithRetry(
      `/proposals/GetProposalsAssociatedWithWorkOrderAsync?trackingNumber=${woId}`,
      { timeout: 30000 }
    );
    return res.data || [];
  } catch (err) {
    logger('sc:getAssociatedProposals', `Failed for WO ${woId}: ${err.message}`, 'warn');
    return [];
  }
}

/**
 * Fetch full proposal details using the OData endpoint.
 * GET /odata/proposals?$filter=Id eq {proposalId}
 * This returns the rich object with AmountCategories (Materials, Installation Labor, etc.),
 * Amount (total), Description, Status, Recommendation, etc.
 */
export async function getProposalByIdOdata(proposalId) {
  if (!proposalId) return null;
  try {
    const res = await fetchWithRetry(
      `/odata/proposals?$filter=Id eq ${proposalId}`,
      { timeout: 30000 }
    );
    const data = res.data;
    if (data?.value && Array.isArray(data.value) && data.value.length > 0) {
      logger('sc:getProposalOdata', `Fetched OData details for proposal ${proposalId}`);
      return data.value[0];
    }
    return null;
  } catch (err) {
    logger('sc:getProposalOdata', `Failed for ${proposalId}: ${err.message}`, 'warn');
    return null;
  }
}

/**
 * Approve a specific proposal using the official endpoint.
 * PUT /proposals/{proposalId}/approve
 * This properly approves the proposal (instead of just updating WO Nte + note).
 * Body fields as per ServiceChannel API (Comments, ReasonString, AttachmentsToWO, etc.).
 */
export async function approveProposal(proposalId, body) {
  if (!proposalId) {
    throw new Error('proposalId is required to approve');
  }
  await scAxios.put(`/proposals/${proposalId}/approve`, body);
  logger('sc:approveProposal', `Successfully approved proposal ${proposalId}`);
}

/**
 * Fetch proposals that must be rejected before approving a revised proposal.
 * GET /proposals/GetProposalsToReject?trackingNumber={woId}
 */
export async function getProposalsToReject(woId) {
  if (!woId) return [];
  try {
    const res = await fetchWithRetry(
      `/proposals/GetProposalsToReject?trackingNumber=${woId}`,
      { timeout: 30000 }
    );
    const data = res.data;
    if (Array.isArray(data)) return data;
    if (data?.value && Array.isArray(data.value)) return data.value;
    return [];
  } catch (err) {
    // 502 "Proposals to Reject not found" is normal when no superseded proposals exist
    if (err.response?.status === 400 || err.response?.status === 502) {
      logger('sc:getProposalsToReject', `No proposals to reject for WO ${woId}`, 'info');
      return [];
    }
    logger('sc:getProposalsToReject', `Failed for WO ${woId}: ${err.message}`, 'warn');
    return [];
  }
}

let _rejectionReasonsCache = null;
let _rejectionReasonsCachedAt = 0;
const REJECTION_REASONS_TTL_MS = 60 * 60 * 1000; // 1 hour

/** SC returns RejectionReasons as { "1": "desc", "2": "..." } — not an array. */
function _parseRejectionReasonsResponse(data) {
  if (Array.isArray(data)) return data;
  if (data?.value && Array.isArray(data.value)) return data.value;
  if (data && typeof data === 'object') {
    return Object.entries(data).map(([id, desc]) => ({
      Id: Number(id),
      Description: typeof desc === 'string' ? desc : String(desc ?? ''),
    })).filter((r) => Number.isFinite(r.Id) && r.Id > 0);
  }
  return [];
}

/**
 * Fetch valid proposal rejection reason codes.
 * GET /proposals/RejectionReasons
 */
export async function getProposalRejectionReasons() {
  const now = Date.now();
  if (_rejectionReasonsCache && (now - _rejectionReasonsCachedAt) < REJECTION_REASONS_TTL_MS) {
    return _rejectionReasonsCache;
  }
  try {
    const res = await fetchWithRetry('/proposals/RejectionReasons', { timeout: 30000 });
    const reasons = _parseRejectionReasonsResponse(res.data);
    _rejectionReasonsCache = reasons;
    _rejectionReasonsCachedAt = now;
    return reasons;
  } catch (err) {
    logger('sc:getRejectionReasons', `Failed: ${err.message}`, 'warn');
    return _rejectionReasonsCache || [];
  }
}

const REJECT_REASON_KEYWORDS = ['revised', 'superseded', 'replacement', 'replaced', 'updated', 'scope'];

/** Default when env/keyword lookup fails — "Change in scope of work" on most tenants. */
const DEFAULT_REJECT_REASON_ID = 2;

/**
 * Resolve RejectReasonCodeId: env override, keyword match on SC reasons, or sensible default.
 */
export async function resolveRejectReasonCodeId() {
  const envId = process.env.SC_PROPOSAL_REJECT_REASON_ID;
  if (envId != null && envId !== '') {
    const n = Number(envId);
    if (Number.isFinite(n) && n > 0) return n;
  }

  const reasons = await getProposalRejectionReasons();
  if (!reasons.length) {
    return DEFAULT_REJECT_REASON_ID;
  }

  for (const kw of REJECT_REASON_KEYWORDS) {
    const match = reasons.find((r) => {
      const text = `${r.Description || ''} ${r.Name || ''} ${r.Reason || ''}`.toLowerCase();
      return text.includes(kw);
    });
    if (match) {
      const id = match.Id ?? match.RejectReasonCodeId ?? match.ReasonCodeId;
      if (id != null) {
        return Number(id);
      }
    }
  }

  const first = reasons[0];
  const resolved = Number(first?.Id ?? first?.RejectReasonCodeId ?? first?.ReasonCodeId ?? DEFAULT_REJECT_REASON_ID);
  return resolved;
}

/**
 * Reject a specific proposal.
 * PUT /proposals/{proposalId}/reject
 */
export async function rejectProposal(proposalId, body) {
  if (!proposalId) {
    throw new Error('proposalId is required to reject');
  }
  await scAxios.put(`/proposals/${proposalId}/reject`, body);
  logger('sc:rejectProposal', `Successfully rejected proposal ${proposalId}`);
}

/**
 * Fetch proposal details by display number via OData.
 * Used when associated-proposals list is empty but the webhook note parsed a proposal #.
 */
export async function getProposalByNumberOdata(proposalNumber) {
  if (proposalNumber == null || proposalNumber === '') return null;
  const num = String(proposalNumber).trim();
  const filters = [`Number eq ${num}`, `Number eq '${num}'`];

  for (const filter of filters) {
    try {
      const res = await fetchWithRetry(
        `/odata/proposals?$filter=${encodeURIComponent(filter)}`,
        { timeout: 30000 }
      );
      const row = res.data?.value?.[0];
      if (row) {
        logger('sc:getProposalOdata', `Fetched OData details for proposal #${num}`);
        return row;
      }
    } catch (err) {
      logger('sc:getProposalOdata', `Number filter "${filter}" failed for #${num}: ${err.message}`, 'warn');
    }
  }

  return null;
}

/**
 * OData fetch with optional $expand=AmountCategories (may surface nested detail on some tenants).
 * Note: $expand=AmountCategories fails on many SC tenants — prefer getProposalByIdOdata.
 */
export async function getProposalByIdOdataExpanded(proposalId) {
  if (!proposalId) return null;
  try {
    const res = await fetchWithRetry(
      `/odata/proposals?$filter=Id eq ${proposalId}&$expand=AmountCategories`,
      { timeout: 30000 }
    );
    const data = res.data;
    if (data?.value?.length > 0) {
      logger('sc:getProposalOdata', `Fetched expanded OData for proposal ${proposalId}`);
      return data.value[0];
    }
    return null;
  } catch (err) {
    logger('sc:getProposalOdata', `Expanded fetch failed for ${proposalId}: ${err.message}`, 'warn');
    return null;
  }
}

/**
 * Normalize a user-entered store number to the SC storeIdentifier format.
 * AE stores use 6-digit zero-padded IDs (e.g. 305 → 000305).
 */
export function normalizeStoreIdentifier(raw, padLength = 6) {
  const digits = String(raw ?? '').trim().replace(/\D/g, '');
  if (!digits) throw new Error('store number is required');
  if (digits.length > padLength) {
    throw new Error(`store number must be at most ${padLength} digits`);
  }
  return digits.padStart(padLength, '0');
}

/**
 * Resolve a store identifier to ServiceChannel LocationId + canonical StoreId.
 */
export async function resolveLocationByStoreIdentifier(raw, { padLength = 6 } = {}) {
  const normalized = normalizeStoreIdentifier(raw, padLength);

  const res = await scAxios.get('/stores', {
    params: { storeIdentifier: normalized },
  });

  const loc = res.data?.Locations?.[0] ?? null;
  if (!loc?.Id) {
    throw new Error(`Store ${normalized} was not found in ServiceChannel`);
  }

  return {
    locationId: loc.Id,
    storeId: loc.StoreId ?? normalized,
    name: loc.Name ?? loc.ShortName ?? '',
  };
}

/**
 * Resolve the exact SC trade name assigned to a location.
 */
export async function resolveTradeNameForLocation(locationId, { preferredTrade = 'AUDIO & VIDEO' } = {}) {
  const res = await scAxios.get(`/locations/${locationId}/trades`);
  const trades = Array.isArray(res.data) ? res.data : (res.data?.value ?? []);
  if (!trades.length) {
    throw new Error(`No trades are assigned to location ${locationId}`);
  }

  const prefUpper = String(preferredTrade || '').toUpperCase();
  const exact = trades.find((t) => String(t.Name || '').toUpperCase() === prefUpper);
  if (exact?.Name) return exact.Name;

  const fuzzyAudioVideo = trades.find((t) => {
    const name = String(t.Name || '').toUpperCase();
    return name.includes('AUDIO') && name.includes('VIDEO');
  });
  if (fuzzyAudioVideo?.Name) return fuzzyAudioVideo.Name;

  throw new Error(`Trade "${preferredTrade}" is not assigned to this location`);
}

/**
 * Resolve location and trade for WO creation. Provider is omitted so SC
 * selects the contracted provider for the location + trade.
 */
export async function resolveCreateContractInfo(rawStoreId, {
  padLength = 6,
  preferredTrade = 'AUDIO & VIDEO',
} = {}) {
  const location = await resolveLocationByStoreIdentifier(rawStoreId, { padLength });
  const tradeName = await resolveTradeNameForLocation(location.locationId, { preferredTrade });

  return {
    ...location,
    tradeName,
  };
}

/**
 * Build a POST /workorders payload (Classic + optional IssueList fields).
 */
export function buildCreateWorkOrderPayload({
  storeId,
  locationId = null,
  description,
  category,
  priority,
  nte,
  tradeName,
  providerId = null,
  problemCode = null,
  callDate = null,
  scheduledDate = null,
  issueRequestInfo = null,
  status = null,
} = {}) {
  const store = String(storeId ?? '').trim();
  const desc = String(description ?? '').trim();
  const trade = String(tradeName ?? '').trim();
  const provider = providerId != null ? Number(providerId) : null;
  const nteValue = Number(nte);
  const resolvedLocationId = locationId != null ? Number(locationId) : null;

  if (!store && !Number.isFinite(resolvedLocationId)) throw new Error('storeId or locationId is required');
  if (!desc) throw new Error('description is required');
  if (!trade) throw new Error('tradeName is required');
  if (!category) throw new Error('category is required');
  if (!priority) throw new Error('priority is required');
  if (!Number.isFinite(nteValue) || nteValue < 0) throw new Error('nte must be a non-negative number');

  const contractInfo = {
    TradeName: trade,
  };

  if (Number.isFinite(provider) && provider > 0) {
    contractInfo.ProviderId = provider;
  }

  if (Number.isFinite(resolvedLocationId) && resolvedLocationId > 0) {
    contractInfo.LocationId = resolvedLocationId;
  } else {
    contractInfo.StoreId = store;
  }

  const payload = {
    ContractInfo: contractInfo,
    Category: String(category).trim(),
    Priority: String(priority).trim(),
    Nte: nteValue,
    CallDate: callDate || new Date().toISOString(),
    Description: desc,
    Status: status || { Primary: 'Open', Extended: '' },
  };

  if (scheduledDate) payload.ScheduledDate = scheduledDate;
  if (problemCode) payload.ProblemCode = String(problemCode).trim();
  if (issueRequestInfo && typeof issueRequestInfo === 'object') {
    payload.IssueRequestInfo = issueRequestInfo;
  }

  return payload;
}

/**
 * Create a work order in ServiceChannel.
 * POST /workorders — returns the created WO object (201).
 */
export async function createWorkOrder(payload) {
  if (!payload || typeof payload !== 'object') {
    throw new Error('createWorkOrder requires a payload object');
  }

  const res = await scAxios.post('/workorders', payload);
  const woId = res.data?.Id ?? res.data?.id ?? null;
  logger('sc:createWorkOrder', `Created WO ${woId ?? '(unknown id)'}`);
  return res.data;
}

function _pickCreateFieldSample(row) {
  return {
    workOrderId: row.Id ?? row.id ?? null,
    storeId: row.LocationStoreId ?? row.StoreId ?? null,
    trade: row.Trade ?? null,
    providerId: row.ProviderId ?? row.Provider?.Id ?? null,
    providerName: row.ProviderName ?? row.Provider?.Name ?? null,
    category: row.Category ?? null,
    priority: row.Priority ?? null,
    problemCode: row.ProblemCode ?? null,
    nte: row.Nte ?? null,
  };
}

function _uniqSorted(values) {
  return [...new Set(values.filter((v) => v != null && String(v).trim() !== '').map(String))].sort();
}

/**
 * Inspect recent work orders to suggest create-form defaults for this tenant.
 * Does not create or mutate anything.
 */
export async function discoverWorkOrderCreateFields({ trade = null, limit = 25 } = {}) {
  const params = {
    $top: Math.min(Math.max(Number(limit) || 25, 1), 50),
    $orderby: 'UpdatedDate desc',
  };
  if (trade) params.trade = trade;

  const res = await scAxios.get('/workorders', { params });
  const rows = res.data?.value || (Array.isArray(res.data) ? res.data : []);

  const samples = rows.slice(0, params.$top).map(_pickCreateFieldSample);
  const categories = _uniqSorted(samples.map((s) => s.category));
  const priorities = _uniqSorted(samples.map((s) => s.priority));
  const problemCodes = _uniqSorted(samples.map((s) => s.problemCode));
  const trades = _uniqSorted(samples.map((s) => s.trade));
  const providerIds = _uniqSorted(samples.map((s) => s.providerId));

  return {
    tradeFilter: trade || null,
    sampleCount: samples.length,
    samples,
    suggested: {
      categories,
      priorities,
      problemCodes,
      trades,
      providerIds,
    },
  };
}
