// OP Central — Permissions matrix + tasks engine
// Defines what each role can see and do, and converts state into role-specific tasks.

// Every nav id opcNavGroups (shell.jsx) knows about — the Flow hub and the
// keyboard palette both read it — minus 'platform' (gated separately, to
// platform master accounts, never to an app role). Org Admin and Managing
// Director both get literally every module — the one definition of
// "everything", so the two lists can never quietly drift apart.
const ALL_MODULES = ['dashboard','inbox','client-requests','sourcing','sales-orders','customers','godown','scm','pool','transfers','rfq','vendor-pos','grn','three-way','vendors','mapping','invoices','collections','products','settings','audit','onboarding'];

const PERMISSIONS = {
  'Org Admin': {
    nav: ALL_MODULES,
    primary: { route: 'dashboard', label: 'Dashboard' },
    can: { all: true },
  },
  'Sales': {
    nav: ['dashboard','inbox','sourcing','sales-orders','customers','products','godown'],
    primary: { route: 'sourcing', label: 'Sourcing / Inquiries', icon: 'bookmark' },
    can: {
      createSO: true,
      createSourcing: true,
      editOwnDraft: true,
      viewProducts: true,
      viewCustomers: true,
    },
  },
  'Pre-sales': {
    nav: ['dashboard','inbox','sourcing','sales-orders','customers','vendors','products','godown'],
    primary: { route: 'sourcing', label: 'Sourcing / Inquiries' },
    // Pre-sales does the inquiry's vendor sourcing + margin (the step Purchase
    // used to do on inquiries) -- it does NOT originate the inquiry. Sales
    // creates it (SourcingNew's own submit() sends it straight to role
    // 'Pre-sales', status 'Sent to Pre-sales'); Pre-sales carrying
    // createSourcing too meant Pre-sales could create one and send it to
    // itself. Purchase keeps post-SO procurement unchanged.
    can: { doSourcing: true, selectVendor: true, viewVendors: true, viewCost: true, viewProducts: true, viewCustomers: true },
  },
  'Project Manager': {
    nav: ['dashboard','inbox','sourcing','sales-orders','customers','godown','pool','transfers','rfq','vendor-pos','grn','invoices'],
    primary: { route: 'inbox', label: 'My Approvals', icon: 'bell' },
    can: {
      approveSO: true, editSO: true, viewCost: true,
      authDispatch: true, initiateTransfer: true,
      viewProducts: true, viewCustomers: true,
    },
  },
  'Purchase': {
    // No 'transfers' -- Cross-SO Transfers is initiated/approved/confirmed
    // entirely by Project Manager (+ MD/Org Admin); Purchase has no
    // interactive role on that screen anywhere in the app.
    nav: ['dashboard','inbox','client-requests','sourcing','sales-orders','godown','rfq','vendor-pos','grn','vendors','pool','products'],
    primary: { route: 'sourcing', label: 'Sourcing', icon: 'bookmark' },
    can: {
      createRFQ: true, selectVendor: true, createVendorPO: true, doSourcing: true,
      viewVendors: true, viewCost: true, viewProducts: true,
      // Matches a client request's free-typed items to the catalogue and turns
      // it into a real Sales Order — the ONLY way Purchase creates an SO here;
      // they do not carry the general createSO capability. See
      // docs/client-requests.md.
      convertClientRequest: true,
    },
  },
  'Stores': {
    // No 'inbox' -- this role never appears as a task owner in buildTasks(),
    // so My Tasks would always be an empty page.
    nav: ['dashboard','vendor-pos','grn','godown','pool','products'],
    primary: { route: 'grn', label: 'GRN', icon: 'package' },
    can: { createGRN: true, reconcileSurplus: true, viewProducts: true },
  },
  'Billing': {
    nav: ['dashboard','inbox','sales-orders','three-way','invoices','vendor-pos','godown'],
    primary: { route: 'three-way', label: '3-Way Match', icon: 'check' },
    can: {
      do3way: true, raiseInvoice: true, generateEWB: true,
      viewCost: true,
    },
  },
  'Managing Director': {
    nav: ALL_MODULES,
    primary: { route: 'inbox', label: 'Approvals queue', icon: 'bell' },
    can: { approveAll: true, viewCost: true, viewMD: true },
  },
  'Collections': {
    nav: ['dashboard','inbox','collections','customers','invoices','godown'],
    primary: { route: 'collections', label: 'Collections', icon: 'cash' },
    can: { logFollowup: true, viewCustomers: true },
  },
  // ---- A company whose stores is two teams --------------------------------
  // Inward and outward are different people: one receives against purchase
  // orders, the other picks and dispatches to the customer. Neither is a
  // smaller 'Stores' — they do different halves of the job.
  //
  // WHICH of them receives is NOT decided here. It is the workflow profile's
  // `receiving_requester_roles` / `receiving_approver_roles`, because a company
  // that splits its stores may still put either half on either side of the
  // hand-off. See docs/workflow-profiles.md.
  'Stores In': {
    // No 'inbox' -- never a task owner in buildTasks().
    nav: ['dashboard','vendor-pos','grn','godown','scm','pool','products'],
    primary: { route: 'grn', label: 'GRN', icon: 'package' },
    can: { createGRN: true, reconcileSurplus: true, viewProducts: true },
  },
  'Stores Out': {
    // No 'inbox' -- never a task owner in buildTasks().
    nav: ['dashboard','sales-orders','godown','scm','pool','products'],
    primary: { route: 'scm', label: 'Dispatch', icon: 'truck' },
    can: { dispatch: true, viewProducts: true },
  },
  // The desk the customer actually talks to: takes the order, chases the
  // invoice. Deliberately NO cost or margin — that is Purchase's business, and
  // a customer-facing screen is the easiest place for a buy price to be read
  // out loud by accident.
  //
  // Does NOT create a Sales Order. They write down what the client wants —
  // free text, helped by recommendations from past orders — and send it to
  // Purchase, who does the item-name mapping and creates the real SO. See
  // docs/client-requests.md.
  //
  // Item Requests and SCM Tracking, by request (pure quantities —
  // ordered/on PO/in transit/received/dispatched — no cost, no vendor
  // price, so it is safe for this desk the same way the rest of its access
  // always excluded cost), plus Dashboard and Virtual Godowns, which every
  // role keeps. VG's main table is quantities only, same as SCM Tracking;
  // nothing here grants this role a view of vendor cost or margin.
  'Client Facing': {
    nav: ['dashboard', 'client-requests', 'scm', 'godown'],
    // NOT 'client-requests' — that route is workflow-gated and off for most
    // organizations, and a role's primary must always be a route it can
    // actually open (roles-check asserts this for every role). 'scm' carries
    // no such gate.
    primary: { route: 'scm', label: 'SCM Tracking', icon: 'repeat' },
    can: { createClientRequest: true, viewCustomers: true,
           viewProducts: true, logFollowup: true },
  },
  // Site supervisor for Supply+Implementation / Implementation-only orders. Sees
  // ONLY the SOs assigned to them (enforced by filtering on extra.implementation.supervisor_id).
  'Supervisor': {
    // No 'inbox' -- never a task owner in buildTasks().
    nav: ['dashboard','sourcing','sales-orders','godown'],
    primary: { route: 'sales-orders', label: 'My Sites', icon: 'briefcase' },
    can: { superviseImpl: true, viewProducts: true },
  },
};

// Resolve a role's permissions. DB-backed config (window.__opcPerms, loaded from
// Supabase by the store) takes precedence when present so admins can customize
// roles; the built-in PERMISSIONS constant is the exact fallback, so default
// behavior is unchanged until an admin actually edits a role.
// What an unrecognised role gets. NOT Org Admin, which is what it used to be:
// a typo in a role name, a role removed from a customisation, or a browser
// running yesterday's bundle against a company that has just invented a role
// would all have silently handed out full administrative access. The safe
// direction for "I do not know who this is" is the dashboard and nothing else.
const PERM_UNKNOWN = {
  nav: ['dashboard'],
  primary: { route: 'dashboard', label: 'Dashboard' },
  can: {},
};

function perm(role) {
  const base = PERMISSIONS[role] || PERM_UNKNOWN;
  const overrides = (typeof window !== 'undefined' && window.__opcPerms) || null;
  const o = overrides && overrides[role];
  if (!o || typeof o !== 'object') return base;
  // MERGE over the built-in shape rather than returning the override verbatim.
  // A customisation that only carries `can`, or an older blob written before a
  // key existed, used to reach callers as { nav: undefined } — and canAccess
  // then did undefined.includes(...), which threw inside the Sidebar and blanked
  // EVERY page. A partial customisation must degrade, never crash.
  return {
    ...base, ...o,
    nav: Array.isArray(o.nav) ? o.nav : base.nav,
    can: (o.can && typeof o.can === 'object') ? o.can : base.can,
    primary: (o.primary && typeof o.primary === 'object') ? o.primary : base.primary,
  };
}
// ===== Per-organization feature flags =====
// Maps a feature_key (organization_features) to the nav routes it controls.
// A route disappears ONLY when its feature row explicitly says enabled=false.
// An absent key inherits (visible), so an org with no rows is never blank.
const FEATURE_ROUTES = {
  presales:          ['sourcing'],
  scm_tracking:      ['scm'],
  item_mapping:      ['mapping'],
  sales_desk:        ['sales-orders', 'customers'],
  stores:            ['godown', 'grn'],
  surplus_pool:      ['pool'],
  cross_so_transfer: ['transfers'],
  rfq_email:         ['rfq'],
  partial_invoicing: [],            // capability, not a nav item
  implementation:    [],
  e_invoice:         [],
  e_way_bill:        [],
  whatsapp:          [],
  sms:               [],
};

// Is a capability on for the active org? Absent => inherit (true).
function featureOn(key) {
  const f = (typeof window !== 'undefined' && window.__opcFeatures) || null;
  if (!f || !(key in f)) return true;
  return !!f[key];
}

// Is a nav route blocked by a disabled feature?
function featureBlocks(route) {
  const f = (typeof window !== 'undefined' && window.__opcFeatures) || null;
  if (!f) return false;
  const root = String(route || '').split('/')[0];
  for (const key of Object.keys(FEATURE_ROUTES)) {
    if (!(key in f) || f[key]) continue;           // absent or on -> no block
    if (FEATURE_ROUTES[key].indexOf(root) !== -1) return true;
  }
  return false;
}

// Same idea as FEATURE_ROUTES/featureBlocks, for a route that only exists
// under a WORKFLOW key rather than a feature flag — a route that is part of
// one company's process, not a screen every company could opt into. Absent
// (or false) blocks it, same "off means hidden" rule as everywhere else, so a
// role carrying the nav id for an org that never turned the key on still sees
// nothing.
const WORKFLOW_ROUTES = {
  client_order_requests: ['client-requests'],
};
function workflowBlocks(route) {
  const root = String(route || '').split('/')[0];
  for (const key of Object.keys(WORKFLOW_ROUTES)) {
    if (WORKFLOW_ROUTES[key].indexOf(root) === -1) continue;
    if (!wfOn(key)) return true;
  }
  return false;
}

// ===== Per-organization WORKFLOW =====
// Feature flags decide what an org can SEE. These decide how its PROCESS runs.
// Resolved server-side (preset defaults + per-org overrides) and delivered by
// opc_my_context. Every fallback below is the historic behaviour, so a failed
// load, an old cached tab, or an org with no profile behaves exactly as before.
const WORKFLOW_FALLBACK = {
  receiving_flow:      'purchase_to_stores',
  po_item_language:    'ours',
  intransit_tracking:  false,
  customer_language:   false,
  supervisor_signoff:  true,
  auto_invoice_on_grn: true,
  outward_dispatch:    false,
  // Off everywhere until an organization turns it on. See soClientReview() in
  // utils.jsx and docs/client-acceptance.md.
  client_acceptance:   false,
  // Off everywhere until an organization turns it on. See docs/client-requests.md.
  client_order_requests: false,
  // Off everywhere until an organization turns it on — a BOQ org's default
  // stays "bill only when the whole group ships" (see docs/boq-billing.md).
  // On, every dispatch bills what shipped right away, AND a BOQ completing
  // still gets its own ₹0 confirmation record. See docs/boq-billing.md.
  boq_partial_on_dispatch: false,
};

function wf(key) {
  const w = (typeof window !== 'undefined' && window.__opcWorkflow) || null;
  if (w && key in w && w[key] != null) return w[key];
  return WORKFLOW_FALLBACK[key];
}
function wfOn(key) { return !!wf(key); }

// Who ticks what physically arrived, and who accepts it and posts the GRN.
// The ACCEPTOR can also post directly (no point asking themselves); everyone
// else raises a request that the acceptor approves. Swapping the direction is
// therefore one setting, not a second code path.
function wfReceiving() {
  const reversed = wf('receiving_flow') === 'stores_to_purchase';
  const base = reversed
    ? { mode: 'stores_to_purchase',
        requesterLabel: 'Stores',   requesterRoles: ['Stores'],
        approverLabel:  'Purchase', approverRoles:  ['Purchase', 'Org Admin'],
        note: 'Stores confirms what arrived · Purchase accepts and posts the GRN' }
    : { mode: 'purchase_to_stores',
        requesterLabel: 'Purchase', requesterRoles: ['Purchase', 'Project Manager'],
        approverLabel:  'Stores',   approverRoles:  ['Stores', 'Org Admin'],
        note: 'Purchase marks material received · Stores accepts and posts the GRN' };

  // A company does not have to call its stores team 'Stores'. One that splits
  // inward from outward has no role of that name at all, and the hand-off would
  // have had no requester — the tick boxes simply would not appear for anybody.
  //
  // ABSENT MEANS THE HISTORIC ROLES, so every organization without these keys
  // behaves exactly as it did.
  const req = wf('receiving_requester_roles');
  const app = wf('receiving_approver_roles');
  // Filter FIRST, then ask whether anything survived: [''] filters down to [],
  // and an empty array is truthy, so returning it would leave nobody able to
  // receive at all. A malformed override must fall back, never empty the list.
  const list = (v) => {
    if (!Array.isArray(v)) return null;
    const out = v.filter(x => typeof x === 'string' && x.trim());
    return out.length ? out : null;
  };
  return {
    ...base,
    requesterRoles: list(req) || base.requesterRoles,
    approverRoles: list(app) || base.approverRoles,
    requesterLabel: wf('receiving_requester_label') || base.requesterLabel,
    approverLabel: wf('receiving_approver_label') || base.approverLabel,
  };
}
function wfCanAcceptReceipt(role) { return wfReceiving().approverRoles.indexOf(role) !== -1; }

// Everyone with a hand in receiving: whoever ticks what arrived, whoever posts
// the GRN, and the administrator. Read from the profile so a company that has
// renamed or split its stores team is not locked out of its own godown.
function wfReceivingRoles() {
  const r = wfReceiving();
  const out = [];
  [].concat(r.requesterRoles || [], r.approverRoles || [], ['Org Admin'])
    .forEach(x => { if (x && out.indexOf(x) === -1) out.push(x); });
  return out;
}
function wfCanReceive(role) { return wfReceivingRoles().indexOf(role) !== -1; }

function canDo(role, capability) {
  const p = perm(role).can || {};
  return !!(p.all || p[capability]);
}
// Routes added for the procurement-only flow. They are not in the historic
// PERMISSIONS constant, so allow them for the roles that own that flow.
const SCM_ROUTES = ['scm', 'mapping'];
const SCM_ROLES = ['Purchase', 'Stores', 'Org Admin', 'Managing Director'];

function canAccess(role, route) {
  // Strip subpaths
  const root = String(route || '').split('/')[0];
  // Belt and braces: perm() now guarantees an array, but canAccess runs inside
  // opcNavGroups and the App route guard — places where a throw takes the
  // whole page down — so it must not depend on that guarantee being kept.
  const p = perm(role) || {};
  const allowed = Array.isArray(p.nav) ? p.nav : [];
  // The platform console is gated on being a PLATFORM admin, not on an app role.
  if (root === 'platform') return !!(typeof window !== 'undefined' && window.__opcIsMaster);
  // The flow hub is a navigation aid, not a module with its own data or
  // capability — every role can open it, the same way every role already
  // has 'dashboard'. It only ever links to routes the viewer's own role can
  // already reach (FlowStage filters by the real nav list), so this grants
  // no access that did not already exist.
  if (root === 'flow') return true;
  // A capability switched off for this organization hides its routes entirely.
  if (featureBlocks(root)) return false;
  // A route that only exists under a workflow this organization has not turned on.
  if (workflowBlocks(root)) return false;
  // SCM_ROLES is the historic list, kept so nothing that worked stops working.
  // A role may also opt in by carrying the route in its own nav, which is how
  // every other screen is decided — one fewer list to keep in step.
  if (SCM_ROUTES.indexOf(root) !== -1) {
    return SCM_ROLES.indexOf(role) !== -1 || allowed.indexOf(root) !== -1;
  }
  // A specific SO's invoice (invoices/<soId>[/<invId>]) is opened from the SO or
  // its Virtual Godown, so anyone who can see the SO / VG may view it — even
  // without the Invoices list in their nav. The bare 'invoices' list stays gated.
  if (root === 'invoices' && route.indexOf('/') !== -1 && (allowed.includes('sales-orders') || allowed.includes('godown'))) return true;
  // Allow detail routes of any allowed parent
  return allowed.includes(root) || allowed.includes(route);
}

// ===== Tasks engine =====
// Convert state into a list of role-targeted tasks. The inbox uses this.
// Each task: { id, role, kind, ref, refId, by, byUser, amount, detail, gate, action(), reject(), info() }
function buildTasks(state, mutate, navigate, toast) {
  const tasks = [];

  // 1. SOs in Pending Approval → PM
  state.sales_orders.filter(s => s.status === 'Pending Approval').forEach(so => {
    const cust = state.customers.find(c => c.id === so.customer_id);
    const value = so.lines.reduce((sum, l) => sum + l.bundle_qty * l.unit_price, 0);
    const by = state.users.find(u => u.role === 'Sales');
    tasks.push({
      id: `task-so-approve-${so.id}`,
      role: 'Project Manager',
      kind: 'Sales Order',
      ref: so.so_no,
      refId: so.id,
      by: by?.name || 'Sales',
      amount: value,
      detail: `${cust?.name} · PO ${so.customer_po} · ${so.lines.length} bundle(s)`,
      gate: 'PM approval',
      icon: 'receipt',
      navigateTo: `sales-orders/${so.id}`,
      approve: () => {
        mutate(s => ({
          ...s,
          sales_orders: s.sales_orders.map(x => x.id === so.id ? { ...x, status: 'Approved' } : x),
          notifications: [
            { id: 'n-app-' + Date.now(), kind: 'so', text: `${so.so_no} approved · ready for procurement`, date: window.TODAY, read: false, role: 'Purchase' },
            ...s.notifications,
          ],
        }));
        toast(`${so.so_no} approved · sent to Purchase`, 'success');
      },
      reject: () => {
        mutate(s => ({ ...s, sales_orders: s.sales_orders.map(x => x.id === so.id ? { ...x, status: 'Draft' } : x) }));
        toast(`${so.so_no} sent back to Sales`);
      },
    });
  });

  // 2. Approved SOs → Purchase needs to float RFQ
  state.sales_orders.filter(s => s.status === 'Approved').forEach(so => {
    const cust = state.customers.find(c => c.id === so.customer_id);
    const value = so.lines.reduce((sum, l) => sum + l.bundle_qty * l.unit_price, 0);
    tasks.push({
      id: `task-rfq-${so.id}`,
      role: 'Purchase',
      kind: 'RFQ to Float',
      ref: so.so_no,
      refId: so.id,
      by: state.users.find(u => u.role === 'Project Manager')?.name || 'PM',
      amount: value,
      detail: `${cust?.name} · approved · awaiting RFQ to vendors`,
      gate: 'Purchase action',
      icon: 'cart',
      navigateTo: `sales-orders/${so.id}`,
      approve: () => {
        // mark as Procurement Started
        mutate(s => ({
          ...s,
          sales_orders: s.sales_orders.map(x => x.id === so.id ? { ...x, status: 'Procurement Started' } : x),
        }));
        toast(`Procurement started for ${so.so_no}`, 'success');
        navigate('rfq');
      },
      approveLabel: 'Float RFQ',
    });
  });

  // 2b. Sourcing inquiries → Pre-sales compares vendors & returns the margin.
  // Accepts the legacy 'Sent to Purchase' status too, so older inquiries still surface.
  (state.sourcings || []).filter(x => x.status === 'Sent to Pre-sales' || x.status === 'Sent to Purchase').forEach(src => {
    const cust = state.customers.find(c => c.id === src.customer_id);
    const value = (src.lines || []).reduce((sum, l) => sum + (l.bundle_qty || 0) * (l.unit_price || 0), 0);
    tasks.push({
      id: `task-src-${src.id}`,
      role: 'Pre-sales',
      kind: 'Vendor Sourcing',
      ref: src.src_no,
      refId: src.id,
      by: state.users.find(u => u.role === 'Sales')?.name || 'Sales',
      amount: value,
      detail: `${cust?.name || ''} · compare vendors per item & return the margin`,
      gate: 'Pre-sales action',
      icon: 'bookmark',
      navigateTo: `sourcing/${src.id}`,
      approve: () => navigate(`sourcing/${src.id}`),
      approveLabel: 'Open sourcing',
    });
  });

  // 2c. Sourcing costed & sent back → Sales to raise the SO
  (state.sourcings || []).filter(x => x.status === 'Sent to Sales').forEach(src => {
    const cust = state.customers.find(c => c.id === src.customer_id);
    const m = src.margin || {};
    tasks.push({
      id: `task-src-sales-${src.id}`,
      role: 'Sales',
      kind: 'Raise SO from inquiry',
      ref: src.src_no,
      refId: src.id,
      by: state.users.find(u => u.role === 'Purchase')?.name || 'Purchase',
      amount: m.sell || 0,
      detail: `${cust?.name || ''} · vendor quotation ready · margin ${m.marginPct !== undefined ? (m.marginPct >= 0 ? '+' : '') + m.marginPct.toFixed(1) + '%' : '—'}`,
      gate: 'Sales action',
      icon: 'receipt',
      navigateTo: `sourcing/${src.id}`,
      approve: () => navigate(`sourcing/${src.id}`),
      approveLabel: 'Review & raise SO',
    });
  });

  // 3. RFQ with selected vendor → MD approval (above threshold)
  state.rfqs.filter(r => r.selected_vendor && r.status === 'Responses In').forEach(rfq => {
    const selected = rfq.quotes.find(q => q.vendor_id === rfq.selected_vendor);
    if (!selected) return;
    const vendor = state.vendors.find(v => v.id === rfq.selected_vendor);
    const mdThreshold = (state.config && state.config.vendor_po_md_threshold != null) ? state.config.vendor_po_md_threshold : 500000;
    const lppThreshold = (state.config && state.config.lpp_threshold != null) ? state.config.lpp_threshold : 10;
    const lppOver = Math.abs(Number(selected.lpp_variance) || 0) > lppThreshold;
    const needsMD = selected.total > mdThreshold;
    if (needsMD) {
      tasks.push({
        id: `task-rfq-md-${rfq.id}`,
        role: 'Managing Director',
        kind: 'Vendor Selection',
        ref: rfq.rfq_no,
        refId: rfq.id,
        by: state.users.find(u => u.role === 'Purchase')?.name || 'Purchase',
        amount: selected.total,
        detail: `${vendor?.name} selected · ${rfq.items_label} · LPP ${selected.lpp_variance > 0 ? '+' : ''}${selected.lpp_variance}%${lppOver ? ` ⚠ over ${lppThreshold}% LPP limit` : ''}`,
        gate: `> ${window.inrK ? window.inrK(mdThreshold) : '₹5L'} · MD approval`,
        icon: 'cart',
        navigateTo: 'rfq',
        approve: () => {
          mutate(s => ({
            ...s,
            rfqs: s.rfqs.map(r => r.id === rfq.id ? { ...r, status: 'Vendor Approved' } : r),
            notifications: [
              { id: 'n-rfq-' + Date.now(), kind: 'po', text: `${rfq.rfq_no} approved · draft Vendor PO`, date: window.TODAY, read: false, role: 'Purchase' },
              ...s.notifications,
            ],
          }));
          toast(`Vendor approved · Purchase can issue PO`, 'success');
        },
      });
    }
  });

  // 3b. Vendor POs over the threshold → MD approval
  (state.vendor_pos || []).filter(p => p.status === 'Pending MD Approval').forEach(po => {
    const vendor = state.vendors.find(v => v.id === po.vendor_id);
    const so = state.sales_orders.find(s => s.id === po.so_id);
    tasks.push({
      id: `task-po-md-${po.id}`,
      role: 'Managing Director',
      kind: 'Vendor PO Approval',
      ref: po.po_no,
      refId: po.id,
      by: state.users.find(u => u.role === 'Purchase')?.name || 'Purchase',
      amount: po.pending_change ? po.pending_change.amount : po.amount,
      detail: po.pending_change ? `Vendor change → ${state.vendors.find(v => v.id === po.pending_change.vendor_id)?.name || ''} · ${so?.so_no || ''} · cheaper by ${window.inrK ? window.inrK(po.pending_change.old_amount - po.pending_change.amount) : ''}` : `${vendor?.name || ''} · ${so?.so_no || ''} · over approval threshold`,
      gate: po.pending_change ? 'Vendor change · MD approval' : '> ₹5L · MD approval',
      icon: 'cart',
      navigateTo: `vendor-pos/${po.id}`,
      approve: () => {
        mutate(s => ({
          ...s,
          vendor_pos: s.vendor_pos.map(x => x.id === po.id ? (window.applyPOReview ? window.applyPOReview(x, true) : { ...x, status: 'Issued' }) : x),
          notifications: [{ id: 'n-pomd-' + Date.now(), kind: 'po', text: `${po.po_no} ${po.pending_change ? 'vendor change approved' : 'approved'} by MD · ready to receive`, date: window.TODAY, read: false, role: 'Purchase' }, ...s.notifications],
        }));
        toast(`${po.po_no} approved`, 'success');
      },
      reject: () => {
        mutate(s => ({ ...s, vendor_pos: s.vendor_pos.map(x => x.id === po.id ? (window.applyPOReview ? window.applyPOReview(x, false) : { ...x, status: 'Rejected' }) : x) }));
        toast(`${po.po_no} ${po.pending_change ? 'change declined' : 'rejected'}`);
      },
    });
  });

  // 4. Cross-SO Transfer Requests → source SO's PM
  state.transfer_requests.filter(t => t.status === 'Pending').forEach(tr => {
    const fromSO = state.sales_orders.find(s => s.id === tr.from_so);
    const toSO = state.sales_orders.find(s => s.id === tr.to_so);
    const by = state.users.find(u => u.id === tr.requested_by);
    tasks.push({
      id: `task-transfer-${tr.id}`,
      role: 'Project Manager',
      kind: 'Cross-SO Transfer',
      ref: tr.id,
      refId: tr.id,
      by: by?.name || 'PM',
      amount: 0,
      detail: `${fromSO?.so_no} → ${toSO?.so_no} · ${tr.items.length} item(s) · ${tr.reason}`,
      gate: 'Source PM approval · backend only',
      icon: 'arrowLeftRight',
      navigateTo: 'transfers',
      approve: () => {
        mutate(s => ({
          ...s,
          transfer_requests: s.transfer_requests.map(x => x.id === tr.id ? { ...x, status: 'Approved' } : x),
          notifications: [
            { id: 'n-tr-' + Date.now(), kind: 'transfer', text: `Transfer approved · ${fromSO?.so_no} → ${toSO?.so_no}`, date: window.TODAY, read: false, role: 'Project Manager' },
            ...s.notifications,
          ],
        }));
        toast('Transfer approved · items re-allocated', 'success');
      },
      reject: () => {
        mutate(s => ({ ...s, transfer_requests: s.transfer_requests.map(x => x.id === tr.id ? { ...x, status: 'Rejected' } : x) }));
        toast('Transfer rejected');
      },
    });
  });

  // 5. 3-Way match exceptions → Billing
  state.vendor_invoices.filter(vi => vi.status === 'Pending 3-Way Match').forEach(vi => {
    const vendor = state.vendors.find(v => v.id === vi.vendor_id);
    tasks.push({
      id: `task-3way-${vi.id}`,
      role: 'Billing',
      kind: '3-Way Match',
      ref: vi.vendor_invoice_no,
      refId: vi.id,
      by: vendor?.name || 'Vendor',
      amount: vi.amount,
      detail: vi.tolerance === 'within' ? `Within ±2% tolerance · ready to book` : `Outside ±2% tolerance · PM review recommended`,
      gate: vi.tolerance === 'within' ? 'Auto-book ready' : 'Exception · review',
      icon: 'check',
      navigateTo: `three-way/${vi.id}`,
      approve: () => {
        mutate(s => ({
          ...s,
          vendor_invoices: s.vendor_invoices.map(x => x.id === vi.id ? { ...x, status: 'Booked' } : x),
        }));
        toast('Invoice booked · payment scheduled', 'success');
      },
    });
  });

  // 6. SOs ready to dispatch → Billing to raise invoice
  state.sales_orders.filter(s => s.status === 'Ready to Dispatch').forEach(so => {
    const cust = state.customers.find(c => c.id === so.customer_id);
    // The real billed subtotal (window.soBilledSubtotal also nets out
    // non-billable value, which this task card's own amount preview did
    // not) and the real weighted GST rate from this order's own lines —
    // the same numbers the actual invoice will be built with.
    const billedSub = window.soNonBillableValue
      ? Math.max(0, so.lines.reduce((sum, l) => sum + l.bundle_qty * l.unit_price, 0) - window.soNonBillableValue(so, state.products) - (so.bill_adjustments || []).reduce((s, a) => s + (Number(a.amount) || 0), 0))
      : Math.max(0, so.lines.reduce((sum, l) => sum + l.bundle_qty * l.unit_price, 0) - (so.bill_adjustments || []).reduce((s, a) => s + (Number(a.amount) || 0), 0));
    const previewPct = window.soWeightedGstPct ? window.soWeightedGstPct(so, state) : 18;
    const value = billedSub + billedSub * previewPct / 100;
    tasks.push({
      id: `task-inv-${so.id}`,
      role: 'Billing',
      kind: 'Raise Invoice',
      ref: so.so_no,
      refId: so.id,
      by: state.users.find(u => u.role === 'Project Manager')?.name || 'PM',
      amount: value,
      detail: `${cust?.name} · dispatch authorised · raise tax invoice + e-Way Bill`,
      gate: 'Billing action',
      icon: 'receipt',
      navigateTo: `sales-orders/${so.id}`,
      // Delegates to the exact same builder every other invoicing surface
      // uses (real per-line GST, a proper so.invoices[] entry, the shared
      // document-numbering sequence) — not a third hand-rolled invoice with
      // its own random number and flat, unrounded rate. getProduct/getUser
      // are reconstructed locally since buildTasks() is never given the
      // store's own versions.
      approve: () => {
        if (!window.raiseSOInvoice) return;
        const getProduct = id => (state.products || []).find(p => p.id === id);
        const getUser = id => (state.users || []).find(u => u.id === id);
        window.raiseSOInvoice(so.id, { mode: 'final' }, { mutate, toast, currentUser: null, getUser, getProduct });
        navigate(`invoices/${so.id}`);
      },
      approveLabel: 'Raise invoice',
    });
  });

  // 6. SOs in Procurement Started → Purchase task: confirm material received
  state.sales_orders.filter(s => s.status === 'Procurement Started').forEach(so => {
    const cust = state.customers.find(c => c.id === so.customer_id);
    const value = so.lines.reduce((sum, l) => sum + l.bundle_qty * l.unit_price, 0);
    tasks.push({
      id: `task-recv-${so.id}`,
      role: 'Purchase',
      kind: 'Material Receipt',
      ref: so.so_no,
      refId: so.id,
      by: state.users.find(u => u.role === 'Stores')?.name || 'Stores',
      amount: value,
      detail: `${cust?.name} · vendor POs issued · material arriving · confirm receipt`,
      gate: 'Purchase action',
      icon: 'package',
      navigateTo: `sales-orders/${so.id}`,
      approve: () => {
        mutate(s => ({
          ...s,
          sales_orders: s.sales_orders.map(x => x.id === so.id ? { ...x, status: 'Material Received' } : x),
          notifications: [
            { id: 'n-recv-' + Date.now(), kind: 'so', text: `${so.so_no} material received · PM to authorize dispatch`, date: window.TODAY, read: false, role: 'Project Manager' },
            ...s.notifications,
          ],
        }));
        toast(`Material received for ${so.so_no} · PM notified`, 'success');
      },
      approveLabel: 'Mark Received',
    });
  });

  // 7. SOs in Material Received → PM task: authorize dispatch
  state.sales_orders.filter(s => s.status === 'Material Received').forEach(so => {
    const cust = state.customers.find(c => c.id === so.customer_id);
    const value = so.lines.reduce((sum, l) => sum + l.bundle_qty * l.unit_price, 0);
    tasks.push({
      id: `task-dispatch-${so.id}`,
      role: 'Project Manager',
      kind: 'Dispatch Authorization',
      ref: so.so_no,
      refId: so.id,
      by: state.users.find(u => u.role === 'Purchase')?.name || 'Purchase',
      amount: value,
      detail: `${cust?.name} · all items received · authorize dispatch`,
      gate: 'PM authorization',
      icon: 'truck',
      navigateTo: `sales-orders/${so.id}`,
      approve: () => {
        mutate(s => ({
          ...s,
          sales_orders: s.sales_orders.map(x => x.id === so.id ? { ...x, status: 'Ready to Dispatch' } : x),
          notifications: [
            { id: 'n-disp-' + Date.now(), kind: 'so', text: `${so.so_no} ready to dispatch · raise invoice`, date: window.TODAY, read: false, role: 'Billing' },
            ...s.notifications,
          ],
        }));
        toast(`Dispatch authorized for ${so.so_no} · Billing notified`, 'success');
      },
      approveLabel: 'Authorize Dispatch',
    });
  });

  // 8. Invoiced (not yet paid, not overdue) → Collections task: track payment
  state.sales_orders.filter(s => (s.status === 'Invoiced' || s.status === 'Payment Pending') && !(s.days_overdue > 0)).forEach(so => {
    const cust = state.customers.find(c => c.id === so.customer_id);
    const isOverdue = so.days_overdue > 0;
    tasks.push({
      id: `task-pay-${so.id}`,
      role: 'Collections',
      kind: 'Payment Tracking',
      ref: so.invoice_no || so.so_no,
      refId: so.id,
      by: 'Billing',
      amount: so.invoice_amount || (so.lines.reduce((sum, l) => sum + l.bundle_qty * l.unit_price, 0) * 1.18),
      detail: `${cust?.name} · ${so.so_no} · ${so.payment_terms} · awaiting payment`,
      gate: 'Collections action',
      icon: 'cash',
      navigateTo: `customers/${cust?.id}/ledger`,
      approve: () => {
        mutate(s => ({
          ...s,
          sales_orders: s.sales_orders.map(x => x.id === so.id ? { ...x, status: 'Fully Paid' } : x),
          notifications: [
            { id: 'n-paid-' + Date.now(), kind: 'so', text: `${so.invoice_no || so.so_no} paid in full · close & reconcile`, date: window.TODAY, read: false, role: 'Project Manager' },
            ...s.notifications,
          ],
        }));
        toast(`Payment recorded · ${so.so_no} fully paid`, 'success');
      },
      approveLabel: 'Record Payment',
    });
  });

  // 9. Fully Paid → PM task: close & reconcile surplus
  state.sales_orders.filter(s => s.status === 'Fully Paid').forEach(so => {
    const cust = state.customers.find(c => c.id === so.customer_id);
    tasks.push({
      id: `task-close-${so.id}`,
      role: 'Project Manager',
      kind: 'Close & Reconcile',
      ref: so.so_no,
      refId: so.id,
      by: 'Collections',
      amount: 0,
      detail: `${cust?.name} · paid · close SO · surplus items return to Master Pool`,
      gate: 'Surplus reconciliation',
      icon: 'check',
      navigateTo: `godown/${so.id}`,
      approve: () => {
        mutate(s => ({
          ...s,
          sales_orders: s.sales_orders.map(x => x.id === so.id ? { ...x, status: 'Closed' } : x),
        }));
        toast(`${so.so_no} closed · surplus returned to pool`, 'success');
      },
      approveLabel: 'Close SO',
    });
  });

  // 10. Overdue invoices → Collections (existing block below, but mark id distinct)
  state.sales_orders.filter(s => s.status === 'Payment Pending' && (s.days_overdue || 0) > 0).forEach(so => {
    const cust = state.customers.find(c => c.id === so.customer_id);
    tasks.push({
      id: `task-coll-${so.id}`,
      role: 'Collections',
      kind: 'Overdue Follow-up',
      ref: so.invoice_no,
      refId: so.id,
      by: 'System',
      amount: so.invoice_amount,
      detail: `${cust?.name} · ${so.days_overdue} days overdue · ${cust?.contact} ${cust?.phone}`,
      gate: 'Action required',
      icon: 'cash',
      navigateTo: `customers/${cust?.id}/ledger`,
      approve: () => { toast(`Follow-up logged for ${cust?.name}`, 'success'); },
      approveLabel: 'Log follow-up',
    });
  });

  // 11. Client rejected something and nothing has been re-ordered for it yet
  // → Purchase floats RFQ / picks a vendor again, through the exact same
  // vendor-comparison screen (or the manual Vendor PO / Assign vendors &
  // prices tool) every other requirement already uses. A rejection used to
  // be a dead end here — recorded, notified once, then nothing pointed
  // Purchase back at it. soRejectedOutstanding is {} for every organization
  // not running client_acceptance, so this task never exists for them. See
  // docs/client-acceptance.md.
  state.sales_orders.forEach(so => {
    const owed = window.soRejectedOutstanding ? window.soRejectedOutstanding(state, so) : {};
    const pids = Object.keys(owed);
    if (!pids.length) return;
    const cust = state.customers.find(c => c.id === so.customer_id);
    const items = pids.map(pid => {
      const p = state.products.find(x => x.id === pid);
      return `${qty(owed[pid])}× ${p ? p.name : pid}`;
    }).join(', ');
    const sourcing = window.soSourcing ? window.soSourcing(state, so.id) : null;
    tasks.push({
      id: `task-reject-reorder-${so.id}`,
      role: 'Purchase',
      kind: 'Rejected — re-order',
      ref: so.so_no,
      refId: so.id,
      by: 'Client',
      amount: 0,
      detail: `${cust?.name || ''} · client rejected · ${items}`,
      gate: 'Purchase action',
      icon: 'cart',
      navigateTo: `sales-orders/${so.id}`,
      approve: () => navigate(sourcing ? `sourcing/${sourcing.id}` : `sales-orders/${so.id}`),
      approveLabel: sourcing ? 'Float RFQ / pick vendor' : 'Open Procurement tab',
    });
  });

  // Inventory write-off (MD)
  if (!state.dismissed_writeoff) {
    tasks.push({
      id: 'task-writeoff-1',
      role: 'Managing Director',
      kind: 'Inventory Write-off',
      ref: 'WO-2026-04',
      refId: null,
      by: state.users.find(u => u.role === 'Stores')?.name || 'Stores',
      amount: 32000,
      detail: '2× motherboards damaged in transit · TechSource batch · debit note raised',
      gate: '> ₹25k · MD approval',
      icon: 'alert',
      navigateTo: 'pool',
      approve: () => { mutate(s => ({ ...s, dismissed_writeoff: true })); toast('Write-off approved · debit note finalised', 'success'); },
      reject: () => { mutate(s => ({ ...s, dismissed_writeoff: true })); toast('Write-off rejected'); },
    });
  }

  return tasks;
}

// What tasks does THIS user have?
function tasksForRole(state, role, mutate, navigate, toast) {
  const all = buildTasks(state, mutate, navigate, toast);
  if (role === 'Org Admin' || role === 'Managing Director' && perm(role).can.approveAll) {
    if (role === 'Org Admin') return all;
  }
  return all.filter(t => t.role === role);
}

window.PERMISSIONS = PERMISSIONS;
window.ALL_MODULES = ALL_MODULES;
window.featureOn = featureOn;
window.featureBlocks = featureBlocks;
window.wf = wf;
window.wfOn = wfOn;
window.wfReceiving = wfReceiving;
window.wfCanAcceptReceipt = wfCanAcceptReceipt;
window.wfReceivingRoles = wfReceivingRoles;
window.wfCanReceive = wfCanReceive;
window.PERM_UNKNOWN = PERM_UNKNOWN;
window.WORKFLOW_FALLBACK = WORKFLOW_FALLBACK;
window.featureBlocks = featureBlocks;
window.workflowBlocks = workflowBlocks;
window.canAccess = canAccess;
window.SCM_ROUTES_SET = { scm: true, mapping: true };
window.SCM_ROLES = SCM_ROLES;
window.FEATURE_ROUTES = FEATURE_ROUTES;
window.perm = perm;
window.canDo = canDo;
window.canAccess = canAccess;
window.buildTasks = buildTasks;
window.tasksForRole = tasksForRole;
