// ============================================
// Reliq — Centralised frontend state module
// Load order: after shared.jsx, before all other components.
//
// Provides:
//   RELIQ_KEYS       — all localStorage key constants
//   RELIQ_USER_STATE — user state enum
//   PLAN_FEATURES    — per-plan feature matrix
//   PLAN_META        — plan names/prices
//   reliqSubscription — subscription read/write helpers
//   reliqGates       — feature-gate check functions
//   reliqValidate    — form validation helpers
//   reliqIds         — ID-prefix helpers (scn_ / rep_)
//   reliqNav         — programmatic navigation helpers
// ============================================

// ── Namespaced localStorage keys ─────────────────────────────────────────────
// Every component that touches localStorage MUST use these constants.
const RELIQ_KEYS = {
  // Auth
  AUTH_USER:                "reliq_auth_user",
  AUTH_STATUS:              "reliq_auth_status",
  AUTH_PROVIDER:            "reliq_auth_provider",
  // ── Redirect keys — each flow owns its own key ───────────────────────────
  // Auth redirect: the route a user attempted before being sent to signin.
  // Written by the auth guard in index.html via reliqAuth.setPendingRedirect().
  AUTH_PENDING_REDIRECT:    "reliq_auth_pending_redirect",
  // Preview redirect: the free-scan result route to return to after signup.
  // Written by free-scan.jsx via reliqSubscription.setVisitorPreview().
  PREVIEW_PENDING_REDIRECT: "reliq_preview_pending_redirect",
  // Payment redirect: the route a pending_payment user tried to reach.
  // Written by the payment guard in index.html via reliqSubscription.setPaymentPendingRedirect().
  PAYMENT_PENDING_REDIRECT: "reliq_payment_pending_redirect",
  // DEPRECATED — legacy key shared across all redirect flows before Step 3.
  // Read-only fallback for sessions created before the redirect key migration.
  // No new code may write to this key. See "Redirect Ownership" in PRODUCT_APPLICATION_STATE.md.
  PENDING_REDIRECT:         "reliq_pending_redirect",
  // Subscription / trial
  TRIAL_STATUS:         "reliq_trial_status",
  TRIAL_PLAN:           "reliq_trial_plan",
  TRIAL_SOURCE:         "reliq_trial_source",
  TRIAL_DAYS:           "reliq_trial_days",
  TRIAL_STARTED_AT:     "reliq_trial_started_at",
  TRIAL_ENDS_AT:        "reliq_trial_ends_at",
  RENEWAL_PRICE:        "reliq_renewal_price",
  SUBSCRIPTION_PLAN:    "reliq_subscription_plan",
  SUBSCRIPTION_STATUS:  "reliq_subscription_status",
  SELECTED_PLAN:        "reliq_selected_plan",
  PAYMENT_ADDED:        "reliq_payment_method_added",
  PAYMENT_REQUIRED:     "reliq_payment_method_required",
  // Preview scan / report
  PREVIEW_SCAN_ID:      "reliq_preview_scan_id",
  PREVIEW_REPORT_ID:    "reliq_preview_report_id",
  SIGNUP_SOURCE:        "reliq_signup_source",
  // Theme (use existing keys — do NOT change)
  THEME:                "reliq-theme",
  THEME_MODE:           "reliq-theme-mode",
  // Notifications
  NOTIF_READ:           "reliq_notif_read",        // array of read notification IDs
  NOTIFICATIONS:        "reliq_notifications",      // full persisted notification list (future use)
  // Search
  RECENT_SEARCH:        "reliq_recent_search",      // array of recently selected search items (max 5)
  SEARCH_LAST_QUERY:    "reliq_search_last_query",  // last typed search query (session hint)
  // Developer integrations (Step 7)
  API_KEYS:             "reliq_api_keys",            // array of mock API key objects
  WEBHOOK_ENDPOINTS:    "reliq_webhook_endpoints",   // array of mock webhook endpoint objects
  WEBHOOK_DELIVERIES:   "reliq_webhook_deliveries",  // array of mock delivery history objects
  // Usage / billing (Step 8)
  USAGE_SNAPSHOT:       "reliq_usage_snapshot",      // usage metrics snapshot object
  // Team & workspace governance (Step 9)
  WORKSPACE:            "reliq_workspace",            // workspace settings object
  TEAM_MEMBERS:         "reliq_team_members",         // array of team member objects
  SSO_SETTINGS:         "reliq_sso_settings",         // SSO configuration object
  AUDIT_LOG:            "reliq_audit_log",             // array of audit log event objects
  // Security, settings & account lifecycle (Step 10)
  USER_PROFILE:         "reliq_user_profile",          // account profile object
  SECURITY_SETTINGS:    "reliq_security_settings",     // 2FA + password mock state
  ACTIVE_SESSIONS:      "reliq_active_sessions",       // array of active session objects
  SECURITY_EVENTS:      "reliq_security_events",       // array of security event objects
  NOTIFICATION_PREFS:   "reliq_notification_preferences", // notification preference flags
  SCAN_DEFAULTS:        "reliq_scan_defaults",          // scan default toggle settings
};

// ── Legacy localStorage keys ──────────────────────────────────────────────────
// These keys predate the centralised state model. They are still written by
// legacy code paths and cleaned up by activateTrial() / logout(). Do not write
// new code that reads these keys as primary sources — use the canonical keys above.
//
//  reliq_trial_plan    (RELIQ_KEYS.TRIAL_PLAN)
//    Written by landing.jsx startTrial() as a belt-and-suspenders alongside the
//    URL ?plan= param. Cleaned by activateTrial() and logout().
//    Fallback: readPaymentPlan() in payment.jsx reads this after reliq_selected_plan.
//
//  reliq_trial_source  (RELIQ_KEYS.TRIAL_SOURCE)
//    Written by landing.jsx startTrial() as "pricing". Read by getTrialContext()
//    in signup.jsx as the localStorage fallback for pricing trial detection.
//    Cleaned by activateTrial() and logout().
//
//  reliq_pending_redirect  (RELIQ_KEYS.PENDING_REDIRECT)  ← DEPRECATED
//    The old shared redirect key used by all flows (auth guard, preview context,
//    payment). Replaced in Step 3 by three purpose-specific keys:
//      reliq_auth_pending_redirect    — auth guard / signin return
//      reliq_preview_pending_redirect — free-scan preview result
//      reliq_payment_pending_redirect — post-payment destination
//    No new code may write to reliq_pending_redirect. It is read as a legacy
//    fallback for sessions created before the migration (getBestAuthRedirect,
//    getBestPreviewRedirect). Cleaned by clearPreviewContext() and logout().
//
// ── User state constants ──────────────────────────────────────────────────────
// The 7 states a Reliq user can occupy at any given time.
// Unauthenticated states have no prefix. Authenticated states use "authenticated_"
// to make it immediately obvious in conditional code that auth is required.
// Always compare via these constants — never against the raw string values.
const RELIQ_USER_STATE = {
  VISITOR:         "visitor",                        // unauthenticated · no preview
  VISITOR_PREVIEW: "visitor_preview",                // unauthenticated · has preview scan/report
  PENDING_PAYMENT: "authenticated_pending_payment",  // authenticated · payment method not yet added
  TRIALING:        "authenticated_trialing",         // authenticated · within 7-day trial window
  ACTIVE:          "authenticated_active",           // authenticated · paid subscription active
  PAST_DUE:        "authenticated_past_due",         // authenticated · payment failed
  CANCELED:        "authenticated_canceled",         // authenticated · subscription canceled
};

// ── Plan feature matrix ───────────────────────────────────────────────────────
// Source of truth for what each plan unlocks. "free" = preview/no-plan.
const PLAN_FEATURES = {
  free: {
    scansPerMonth:      3,
    pdfExport:          false,
    shareLinks:         false,
    rescan:             false,
    apiKeys:            false,
    githubAction:       false,
    webhooks:           false,
    teamFeatures:       false,
    whiteLabelReports:  false,
    slackLinear:        false,
    dangerZone:         false,
    // Tab-level gates
    tabSdk:             false,
    tabPrivacy:         false,
    tabListing:         false,
    tabSecurity:        false,
    tabExport:          false,
  },
  starter: {
    scansPerMonth:      25,
    pdfExport:          true,
    shareLinks:         true,
    rescan:             true,
    apiKeys:            false,
    githubAction:       false,
    webhooks:           false,
    teamFeatures:       false,
    whiteLabelReports:  false,
    slackLinear:        false,
    dangerZone:         true,
    tabSdk:             true,
    tabPrivacy:         true,
    tabListing:         true,
    tabSecurity:        true,
    tabExport:          true,
  },
  pro: {
    scansPerMonth:      100,
    pdfExport:          true,
    shareLinks:         true,
    rescan:             true,
    apiKeys:            true,
    githubAction:       true,
    webhooks:           true,
    teamFeatures:       false,
    whiteLabelReports:  false,
    slackLinear:        true,
    dangerZone:         true,
    tabSdk:             true,
    tabPrivacy:         true,
    tabListing:         true,
    tabSecurity:        true,
    tabExport:          true,
  },
  team: {
    scansPerMonth:      Infinity,
    pdfExport:          true,
    shareLinks:         true,
    rescan:             true,
    apiKeys:            true,
    githubAction:       true,
    webhooks:           true,
    teamFeatures:       true,
    whiteLabelReports:  true,
    slackLinear:        true,
    dangerZone:         true,
    tabSdk:             true,
    tabPrivacy:         true,
    tabListing:         true,
    tabSecurity:        true,
    tabExport:          true,
  },
};

// ── Plan display metadata ─────────────────────────────────────────────────────
const PLAN_META = {
  free:    { name: "Free Preview",  price: 0,   priceStr: "Free"  },
  starter: { name: "Starter",       price: 29,  priceStr: "$29"   },
  pro:     { name: "Pro",           price: 79,  priceStr: "$79"   },
  team:    { name: "Team",          price: 199, priceStr: "$199"  },
};

// ── Central plan model (Step 8) ───────────────────────────────────────────────
// Single source of truth for plan names, prices, and limits.
// Keys: "preview" | "starter" | "pro" | "team"
// Limits: null = unlimited, 0 = none/blocked
const RELIQ_PLANS = {
  preview: {
    name: "Free Preview", price: 0, priceStr: "Free",
    scanLimit: 3, appLimit: 1, reportLimit: 1,
    apiKeys: 0, webhooks: 0, seats: 1,
    features: ["free_preview", "new_scan", "policy_library"],
  },
  starter: {
    name: "Starter", price: 29, priceStr: "$29/mo",
    scanLimit: 25, appLimit: 3, reportLimit: 25,
    apiKeys: 0, webhooks: 0, seats: 1,
    features: [
      "free_preview", "new_scan", "full_report",
      "export_pdf", "export_data", "share_report", "policy_library",
    ],
  },
  pro: {
    name: "Pro", price: 79, priceStr: "$79/mo",
    scanLimit: 100, appLimit: 10, reportLimit: 100,
    apiKeys: 3, webhooks: 5, seats: 3,
    features: [
      "free_preview", "new_scan", "full_report",
      "export_pdf", "export_data", "share_report",
      "api_keys", "webhooks", "github_action", "policy_library",
    ],
  },
  team: {
    name: "Team", price: 199, priceStr: "$199/mo",
    scanLimit: 500, appLimit: null, reportLimit: 500,
    apiKeys: 10, webhooks: 20, seats: 10,
    features: [
      "free_preview", "new_scan", "full_report",
      "export_pdf", "export_data", "share_report",
      "api_keys", "webhooks", "github_action", "policy_library",
      "team_members", "roles", "sso", "audit_logs", "priority_support",
    ],
  },
};

// ── Feature key constants (Step 8) ───────────────────────────────────────────
// Use these in canUseFeature() calls — never compare raw strings.
const FEATURE_KEYS = {
  FREE_PREVIEW:     "free_preview",
  NEW_SCAN:         "new_scan",
  FULL_REPORT:      "full_report",
  EXPORT_PDF:       "export_pdf",
  EXPORT_DATA:      "export_data",
  SHARE_REPORT:     "share_report",
  API_KEYS:         "api_keys",
  WEBHOOKS:         "webhooks",
  GITHUB_ACTION:    "github_action",
  POLICY_LIBRARY:   "policy_library",
  TEAM_MEMBERS:     "team_members",
  ROLES:            "roles",
  SSO:              "sso",
  AUDIT_LOGS:       "audit_logs",
  PRIORITY_SUPPORT: "priority_support",
};

// ── Role constants (Step 9) ───────────────────────────────────────────────────
// Five workspace roles. Each role carries the full list of permission keys it grants.
// Source of truth — never compare role strings directly; use reliqRoles helpers.
const RELIQ_ROLES = {
  owner: {
    key: "owner",
    label: "Owner",
    description: "Full access including billing, workspace settings, member management, and workspace deletion.",
    permissions: [
      "manage_workspace","manage_billing","manage_team","manage_roles",
      "manage_api_keys","manage_webhooks","run_scans","view_scans",
      "view_reports","export_reports","share_reports","view_audit_logs",
      "manage_sso","delete_workspace",
    ],
  },
  admin: {
    key: "admin",
    label: "Admin",
    description: "Manage members, roles, integrations, and scans. Cannot delete the workspace.",
    permissions: [
      "manage_workspace","manage_billing","manage_team","manage_roles",
      "manage_api_keys","manage_webhooks","run_scans","view_scans",
      "view_reports","export_reports","share_reports","view_audit_logs",
      "manage_sso",
    ],
  },
  developer: {
    key: "developer",
    label: "Developer",
    description: "Run scans, view reports, and manage API keys and webhooks.",
    permissions: [
      "manage_api_keys","manage_webhooks","run_scans",
      "view_scans","view_reports","export_reports","share_reports",
    ],
  },
  viewer: {
    key: "viewer",
    label: "Viewer",
    description: "Read-only access to scans and reports. Cannot modify anything.",
    permissions: ["view_scans","view_reports"],
  },
  billing: {
    key: "billing",
    label: "Billing",
    description: "Manage billing and plans. Cannot run scans, manage team, or access API keys.",
    permissions: ["manage_billing","view_reports","view_audit_logs"],
  },
};

// ── Permission constants (Step 9) ─────────────────────────────────────────────
// 14 granular permission keys with labels and descriptions.
// Read via reliqRoles.hasPermission(permissionKey) — never compare raw strings.
const RELIQ_PERMISSIONS = {
  manage_workspace: { label: "Workspace settings", description: "Edit workspace name, slug, and contact emails." },
  manage_billing:   { label: "Billing & plans",    description: "Manage subscription, plan changes, and invoices." },
  manage_team:      { label: "Manage team",         description: "Invite, remove, and deactivate workspace members." },
  manage_roles:     { label: "Manage roles",        description: "Assign and change member roles." },
  manage_api_keys:  { label: "API keys",            description: "Create and revoke workspace API keys." },
  manage_webhooks:  { label: "Webhooks",            description: "Add, remove, and test webhook endpoints." },
  run_scans:        { label: "Run scans",           description: "Upload builds and trigger new compliance scans." },
  view_scans:       { label: "View scans",          description: "View scan results, findings, and fix plans." },
  view_reports:     { label: "View reports",        description: "View full compliance reports." },
  export_reports:   { label: "Export PDFs",         description: "Download full, summary, and fix-plan PDFs." },
  share_reports:    { label: "Share reports",       description: "Generate and copy public share links." },
  view_audit_logs:  { label: "Audit logs",          description: "View the workspace audit log." },
  manage_sso:       { label: "SSO",                 description: "Configure SAML SSO settings and providers." },
  delete_workspace: { label: "Delete workspace",    description: "Permanently delete the workspace. Owner only." },
};

// ── Default workspace object (Step 9) ─────────────────────────────────────────
// Used as initial value for reliq_workspace localStorage key.
const DEFAULT_WORKSPACE = {
  id: "ws_roamly",
  name: "Roamly",
  slug: "roamly",
  ownerUserId: "usr_zola",
  planKey: "pro",
  createdAt: "2025-08-14T09:00:00.000Z",
  timezone: "Africa/Johannesburg",
  billingEmail: "zola@holt.africa",
  securityContactEmail: "zola@holt.africa",
  isMock: true,
};

// ── Initial team members (Step 9) ─────────────────────────────────────────────
// Fallback used when reliq_team_members is absent. After first load, team.jsx
// persists this array; subsequent mutations update localStorage only.
const INITIAL_TEAM_MEMBERS = [
  { id:"mbr_owner",  name:"Zola Lena",      email:"zola@holt.africa",  role:"owner",     status:"active",  avatar:"ZL", grad:"linear-gradient(135deg,#A78BFA,#5B9DFF)", joinedAt:"2025-08-14", lastActiveAt:"just now",    isCurrentUser:true,  isMock:true },
  { id:"mbr_admin",  name:"Sasha Pillai",   email:"sasha@roamly.app",  role:"admin",     status:"active",  avatar:"SP", grad:"linear-gradient(135deg,#00D4AA,#5B9DFF)", joinedAt:"2025-08-16", lastActiveAt:"12 min ago",  isCurrentUser:false, isMock:true },
  { id:"mbr_dev1",   name:"Marcus Botha",   email:"marcus@roamly.app", role:"developer", status:"active",  avatar:"MB", grad:"linear-gradient(135deg,#F59E0B,#EF4444)", joinedAt:"2025-08-22", lastActiveAt:"2 hours ago", isCurrentUser:false, isMock:true },
  { id:"mbr_dev2",   name:"Aisha Ndlovu",   email:"aisha@roamly.app",  role:"developer", status:"active",  avatar:"AN", grad:"linear-gradient(135deg,#5B9DFF,#00D4AA)", joinedAt:"2025-09-01", lastActiveAt:"Yesterday",   isCurrentUser:false, isMock:true },
  { id:"mbr_viewer", name:"Theo Pretorius", email:"theo@roamly.app",   role:"viewer",    status:"invited", avatar:"TP", grad:"linear-gradient(135deg,#7C828D,#565B65)", joinedAt:null,         lastActiveAt:null,          isCurrentUser:false, isMock:true },
];

// ── Initial audit log (Step 9) ────────────────────────────────────────────────
// Fallback used when reliq_audit_log is absent.
const INITIAL_AUDIT_LOG = [
  { id:"aud_001", event:"api_key_rotated",  actor:"Sasha Pillai", target:"sk_live_••••N9pA", timestamp:"2026-05-26T13:48:00Z", metadata:{},                            isMock:true },
  { id:"aud_002", event:"scan_run",         actor:"Zola Lena",    target:"Roamly v1.4.0",     timestamp:"2026-05-26T12:00:00Z", metadata:{},                            isMock:true },
  { id:"aud_003", event:"member_invited",   actor:"Marcus Botha", target:"theo@roamly.app",   timestamp:"2026-05-26T10:00:00Z", metadata:{ role:"viewer" },             isMock:true },
  { id:"aud_004", event:"role_changed",     actor:"Sasha Pillai", target:"Aisha Ndlovu",      timestamp:"2026-05-25T16:00:00Z", metadata:{ from:"viewer", to:"developer" }, isMock:true },
  { id:"aud_005", event:"webhook_enabled",  actor:"Zola Lena",    target:"Linear webhook",     timestamp:"2026-05-24T10:00:00Z", metadata:{},                            isMock:true },
  { id:"aud_006", event:"pdf_exported",     actor:"Marcus Botha", target:"QuickInvoice scan",  timestamp:"2026-05-23T09:00:00Z", metadata:{},                            isMock:true },
];

// ── Default user profile (Step 10) ───────────────────────────────────────────
// Initial value for reliq_user_profile. Populated from auth state on first load.
const DEFAULT_USER_PROFILE = {
  id:                "usr_zola",
  name:              "Zola Lena",
  email:             "zola@holt.africa",
  avatarInitials:    "ZL",
  timezone:          "Africa/Johannesburg",
  notificationEmail: "zola@holt.africa",
  authProvider:      "email",     // "email" | "github"
  roleDisplay:       "Owner",     // read-only; derived from team role
  isMock:            true,
};

// ── Default security settings (Step 10) ──────────────────────────────────────
// Initial value for reliq_security_settings. All values are mock/frontend only.
const DEFAULT_SECURITY_SETTINGS = {
  twoFactorEnabled:    true,       // mock 2FA state
  twoFactorMethod:     "app",      // "app" | "sms" — mock
  passwordLastChanged: "2026-03-15T10:00:00.000Z", // display only
  githubConnected:     false,      // mirrors authProvider === "github"
  isMock:              true,
};

// ── Initial active sessions (Step 10) ────────────────────────────────────────
// Initial value for reliq_active_sessions.
const INITIAL_ACTIVE_SESSIONS = [
  {
    id:            "sess_current",
    label:         "Current session",
    device:        "macOS · Chrome",
    location:      "Johannesburg, ZA",
    ip:            "196.25.x.x",
    startedAt:     new Date().toISOString(),
    lastActiveAt:  new Date().toISOString(),
    isCurrent:     true,
    isMock:        true,
  },
  {
    id:            "sess_mobile",
    label:         "Mobile",
    device:        "iOS · Safari",
    location:      "Cape Town, ZA",
    ip:            "41.185.x.x",
    startedAt:     "2026-05-25T08:00:00.000Z",
    lastActiveAt:  "2026-05-26T14:22:00.000Z",
    isCurrent:     false,
    isMock:        true,
  },
  {
    id:            "sess_ci",
    label:         "CI environment",
    device:        "Linux · Reliq CLI",
    location:      "us-east-1 (AWS)",
    ip:            "18.206.x.x",
    startedAt:     "2026-05-20T00:00:00.000Z",
    lastActiveAt:  "2026-05-27T06:15:00.000Z",
    isCurrent:     false,
    isMock:        true,
  },
];

// ── Initial security events (Step 10) ────────────────────────────────────────
// Initial value for reliq_security_events. Display-only; mock data.
const INITIAL_SECURITY_EVENTS = [
  { id:"sevt_001", event:"login_success",      actor:"zola@holt.africa", device:"macOS · Chrome",   location:"Johannesburg, ZA", timestamp:"2026-05-27T09:14:00.000Z", isMock:true },
  { id:"sevt_002", event:"2fa_verified",       actor:"zola@holt.africa", device:"macOS · Chrome",   location:"Johannesburg, ZA", timestamp:"2026-05-27T09:14:01.000Z", isMock:true },
  { id:"sevt_003", event:"api_key_created",    actor:"zola@holt.africa", device:"macOS · Chrome",   location:"Johannesburg, ZA", timestamp:"2026-05-26T13:48:00.000Z", isMock:true },
  { id:"sevt_004", event:"login_success",      actor:"zola@holt.africa", device:"iOS · Safari",     location:"Cape Town, ZA",    timestamp:"2026-05-25T08:00:00.000Z", isMock:true },
  { id:"sevt_005", event:"password_changed",   actor:"zola@holt.africa", device:"macOS · Chrome",   location:"Johannesburg, ZA", timestamp:"2026-03-15T10:00:00.000Z", isMock:true },
];

// ── Default notification preferences (Step 10) ────────────────────────────────
// Initial value for reliq_notification_preferences.
// billingAlerts and securityAlerts are always true and cannot be disabled.
const DEFAULT_NOTIFICATION_PREFS = {
  scanCompleted:      true,
  highRiskFinding:    true,
  reportGenerated:    true,
  webhookFailure:     true,
  billingAlerts:      true,   // required — cannot be disabled
  teamInvites:        true,
  policyUpdates:      true,
  securityAlerts:     true,   // required — cannot be disabled
  productUpdates:     false,
  emailNotifications: true,
  inAppNotifications: true,
};

// ── Default scan preferences (Step 10) ───────────────────────────────────────
// Initial value for reliq_scan_defaults.
const DEFAULT_SCAN_DEFAULTS = {
  autoRescan:   true,
  publicReports: false,
  aiChecks:     true,
  deepScan:     false,
  storeBeta:    false,
};

// ── Workspace helper (Step 9) ─────────────────────────────────────────────────
// Read/write workspace settings, team members, audit log, and SSO settings from localStorage.
// All methods are synchronous; no real backend calls.
const reliqWorkspace = {
  _load(key, initial) {
    try {
      const raw = localStorage.getItem(key);
      if (raw) { const p = JSON.parse(raw); if (p !== null && p !== undefined) return p; }
    } catch (_) {}
    return typeof initial === "function" ? initial() : initial;
  },
  _save(key, val) {
    try { localStorage.setItem(key, JSON.stringify(val)); } catch (_) {}
  },

  // ── Workspace settings ──────────────────────────────────────────────────────
  getWorkspace() {
    return this._load(RELIQ_KEYS.WORKSPACE, DEFAULT_WORKSPACE);
  },
  saveWorkspace(updates) {
    const current = this.getWorkspace();
    const updated = { ...current, ...updates };
    this._save(RELIQ_KEYS.WORKSPACE, updated);
    return updated;
  },

  // ── Team members ────────────────────────────────────────────────────────────
  getTeamMembers() {
    return this._load(RELIQ_KEYS.TEAM_MEMBERS, INITIAL_TEAM_MEMBERS);
  },
  saveTeamMembers(members) {
    this._save(RELIQ_KEYS.TEAM_MEMBERS, members);
  },

  // ── Audit log ───────────────────────────────────────────────────────────────
  getAuditLog() {
    return this._load(RELIQ_KEYS.AUDIT_LOG, INITIAL_AUDIT_LOG);
  },
  appendAuditEvent(event, actor, target, metadata = {}) {
    const log = this.getAuditLog();
    const entry = {
      id: `aud_${Date.now().toString(36)}`,
      event,
      actor,
      target,
      timestamp: new Date().toISOString(),
      metadata,
      isMock: true,
    };
    const updated = [entry, ...log].slice(0, 200);
    this._save(RELIQ_KEYS.AUDIT_LOG, updated);
    return entry;
  },

  // ── SSO settings ────────────────────────────────────────────────────────────
  getSsoSettings() {
    return this._load(RELIQ_KEYS.SSO_SETTINGS, {
      enabled: false, provider: "okta", domain: "roamly.app",
      ssoUrl: "", entityId: "", certificateStatus: "none",
      lastTestedAt: null, isMock: true,
    });
  },
  saveSsoSettings(updates) {
    const current = this.getSsoSettings();
    const updated = { ...current, ...updates };
    this._save(RELIQ_KEYS.SSO_SETTINGS, updated);
    return updated;
  },
};

// ── Role helper (Step 9) ──────────────────────────────────────────────────────
// Role and permission checks. All checks are read-only / side-effect free.
// Permission checks resolve the current user's role from reliqWorkspace.getTeamMembers().
const reliqRoles = {
  // Returns the current user's role key from localStorage.
  // Falls back to "viewer" if the user is not found in team members.
  getCurrentUserRole() {
    const members = reliqWorkspace.getTeamMembers();
    const current = members.find(m => m.isCurrentUser);
    return current?.role || "viewer";
  },

  // Returns the role definition object for a given role key.
  getRole(roleKey) {
    return RELIQ_ROLES[roleKey] || null;
  },

  // Returns the permission keys array for a given role.
  getPermissionsForRole(roleKey) {
    return RELIQ_ROLES[roleKey]?.permissions || [];
  },

  // Does the given role (or current user's role) have a specific permission?
  hasPermission(permissionKey, roleKey) {
    const role = roleKey || this.getCurrentUserRole();
    return this.getPermissionsForRole(role).includes(permissionKey);
  },

  canManageTeam()         { return this.hasPermission("manage_team"); },
  canManageBilling()      { return this.hasPermission("manage_billing"); },
  canManageIntegrations() { return this.hasPermission("manage_api_keys"); },
  canDeleteWorkspace()    { return this.hasPermission("delete_workspace"); },
  canManageSso()          { return this.hasPermission("manage_sso"); },
  canViewAuditLogs()      { return this.hasPermission("view_audit_logs"); },
};

// ── Subscription store ────────────────────────────────────────────────────────
// Centralised read/write for all trial + subscription localStorage state.
const reliqSubscription = {
  _get(key, fallback = null) {
    try { return localStorage.getItem(key) ?? fallback; } catch (_) { return fallback; }
  },
  _set(key, value) {
    try { localStorage.setItem(key, String(value)); } catch (_) {}
  },
  _del(key) {
    try { localStorage.removeItem(key); } catch (_) {}
  },

  // ── Getters ─────────────────────────────────────────────────────────────────
  getTrialStatus()         { return this._get(RELIQ_KEYS.TRIAL_STATUS); },
  getSubscriptionStatus()  { return this._get(RELIQ_KEYS.SUBSCRIPTION_STATUS); },
  getPlan()                { return this._get(RELIQ_KEYS.SUBSCRIPTION_PLAN); },
  // Returns the selected plan key written by setPendingPayment() — present for
  // pending_payment users before activateTrial() promotes it to SUBSCRIPTION_PLAN.
  getSelectedPlan()        { return this._get(RELIQ_KEYS.SELECTED_PLAN); },
  getAuthProvider()        { return this._get(RELIQ_KEYS.AUTH_PROVIDER, "email"); },
  getPreviewScanId()       { return this._get(RELIQ_KEYS.PREVIEW_SCAN_ID); },
  getPreviewReportId()     { return this._get(RELIQ_KEYS.PREVIEW_REPORT_ID); },
  hasPaymentMethod()       { return this._get(RELIQ_KEYS.PAYMENT_ADDED) === "true"; },
  hasPendingPayment()      { return this._get(RELIQ_KEYS.TRIAL_STATUS) === "pending_payment"; },
  isTrialing()             { return this._get(RELIQ_KEYS.SUBSCRIPTION_STATUS) === "trialing"; },
  isPastDue()              { return this._get(RELIQ_KEYS.SUBSCRIPTION_STATUS) === "past_due"; },
  isCanceled()             { return this._get(RELIQ_KEYS.SUBSCRIPTION_STATUS) === "canceled"; },

  // Auth helper — avoids importing reliqGates. Never call from reliqGates to prevent circular use.
  _isAuthed() {
    return window.reliqAuth ? window.reliqAuth.isAuthed() : false;
  },

  // Reads signup source written by setVisitorPreview() or setPendingPayment().
  // Values: "free-scan" | "pricing" | "direct" | "github-action" | null
  getSignupSource() { return this._get(RELIQ_KEYS.SIGNUP_SOURCE); },

  // ── Redirect helpers — each flow owns its own key ─────────────────────────────
  // No flow may read or write another flow's redirect key.
  // The legacy key (PENDING_REDIRECT) is read-only fallback for pre-migration sessions.

  // Auth redirect — the route a user tried to reach before the auth guard bounced them.
  // Written via reliqAuth.setPendingRedirect() in signin.jsx (called by index.html).
  getAuthPendingRedirect()   { return this._get(RELIQ_KEYS.AUTH_PENDING_REDIRECT); },
  setAuthPendingRedirect(r)  { this._set(RELIQ_KEYS.AUTH_PENDING_REDIRECT, r); },
  clearAuthPendingRedirect() { this._del(RELIQ_KEYS.AUTH_PENDING_REDIRECT); },

  // Preview redirect — the free-scan result route stored for post-signup return.
  // Written via setVisitorPreview() below.
  getPreviewPendingRedirect()   { return this._get(RELIQ_KEYS.PREVIEW_PENDING_REDIRECT); },
  setPreviewPendingRedirect(r)  { this._set(RELIQ_KEYS.PREVIEW_PENDING_REDIRECT, r); },
  clearPreviewPendingRedirect() { this._del(RELIQ_KEYS.PREVIEW_PENDING_REDIRECT); },

  // Payment redirect — the route a pending_payment user tried to reach.
  // Written by the payment guard in index.html to capture their intended destination.
  getPaymentPendingRedirect()   { return this._get(RELIQ_KEYS.PAYMENT_PENDING_REDIRECT); },
  setPaymentPendingRedirect(r)  { this._set(RELIQ_KEYS.PAYMENT_PENDING_REDIRECT, r); },
  clearPaymentPendingRedirect() { this._del(RELIQ_KEYS.PAYMENT_PENDING_REDIRECT); },

  // Legacy key — read-only. Never call setLegacyPendingRedirect() in new code.
  getLegacyPendingRedirect()   { return this._get(RELIQ_KEYS.PENDING_REDIRECT); },
  clearLegacyPendingRedirect() { this._del(RELIQ_KEYS.PENDING_REDIRECT); },

  // Convenience "best" getters — read the specific key first, fall back to the
  // legacy shared key for sessions created before the Step 3 migration.
  getBestAuthRedirect()    { return this.getAuthPendingRedirect()    || this.getLegacyPendingRedirect() || null; },
  getBestPreviewRedirect() { return this.getPreviewPendingRedirect() || this.getLegacyPendingRedirect() || null; },
  // Payment redirect has no legacy fallback — it is a new concept post-Step 3.
  getBestPaymentRedirect() { return this.getPaymentPendingRedirect() || null; },

  // ── Effective user state ─────────────────────────────────────────────────────
  getUserState(isAuthed) {
    if (!isAuthed) {
      return this.getPreviewScanId()
        ? RELIQ_USER_STATE.VISITOR_PREVIEW
        : RELIQ_USER_STATE.VISITOR;
    }
    if (this.hasPendingPayment()) return RELIQ_USER_STATE.PENDING_PAYMENT;
    const sub = this.getSubscriptionStatus();
    if (sub === "trialing") return RELIQ_USER_STATE.TRIALING;
    if (sub === "past_due") return RELIQ_USER_STATE.PAST_DUE;
    if (sub === "canceled") return RELIQ_USER_STATE.CANCELED;
    // "active" or no subscription status (free-preview users) → treat as active
    return RELIQ_USER_STATE.ACTIVE;
  },

  // Convenience wrapper — resolves auth from window.reliqAuth automatically.
  // Prefer getUserState(bool) in contexts where auth status is already known.
  getProductState() {
    return this.getUserState(this._isAuthed());
  },

  // ── State writers ─────────────────────────────────────────────────────────────
  // Called by payment.jsx on successful payment.
  // GitHub authentication NEVER calls this — only successful payment does.
  activateTrial(planKey) {
    const startAt  = new Date().toISOString();
    const endDate  = new Date();
    endDate.setDate(endDate.getDate() + 7);
    const endsAt   = endDate.toISOString();
    const price    = PLAN_META[planKey]?.price ?? 0;

    this._set(RELIQ_KEYS.SUBSCRIPTION_PLAN,   planKey);
    this._set(RELIQ_KEYS.TRIAL_STATUS,        "active");
    this._set(RELIQ_KEYS.SUBSCRIPTION_STATUS, "trialing");
    this._set(RELIQ_KEYS.TRIAL_DAYS,          "7");
    this._set(RELIQ_KEYS.PAYMENT_ADDED,       "true");
    this._set(RELIQ_KEYS.PAYMENT_REQUIRED,    "false");
    this._set(RELIQ_KEYS.TRIAL_STARTED_AT,    startAt);
    this._set(RELIQ_KEYS.TRIAL_ENDS_AT,       endsAt);
    this._set(RELIQ_KEYS.RENEWAL_PRICE,       String(price));
    this._del(RELIQ_KEYS.TRIAL_PLAN);
    this._del(RELIQ_KEYS.TRIAL_SOURCE);
  },

  // Called by signup.jsx on auth success for a pricing trial.
  // source defaults to "pricing"; pass "github-action" for the docs-page CTA flow.
  setPendingPayment(planKey, provider, source = "pricing") {
    this._set(RELIQ_KEYS.AUTH_PROVIDER,       provider);
    this._set(RELIQ_KEYS.SELECTED_PLAN,       planKey);
    this._set(RELIQ_KEYS.SIGNUP_SOURCE,       source);
    this._set(RELIQ_KEYS.PAYMENT_REQUIRED,    "true");
    this._set(RELIQ_KEYS.PAYMENT_ADDED,       "false");
    this._set(RELIQ_KEYS.TRIAL_STATUS,        "pending_payment");
    this._set(RELIQ_KEYS.SUBSCRIPTION_STATUS, "incomplete");
  },

  // ── Subscription status writers ───────────────────────────────────────────────
  // Called when Stripe marks the subscription as active (post-trial or direct).
  // In production, trigger this from a webhook acknowledgement handler.
  setSubscriptionActive(planKey) {
    this._set(RELIQ_KEYS.SUBSCRIPTION_PLAN,   planKey);
    this._set(RELIQ_KEYS.SUBSCRIPTION_STATUS, "active");
    this._set(RELIQ_KEYS.TRIAL_STATUS,        "active");
    this._set(RELIQ_KEYS.PAYMENT_ADDED,       "true");
    this._set(RELIQ_KEYS.PAYMENT_REQUIRED,    "false");
  },

  // Called when Stripe reports a payment failure (invoice.payment_failed).
  setSubscriptionPastDue() {
    this._set(RELIQ_KEYS.SUBSCRIPTION_STATUS, "past_due");
  },

  // Called when the subscription is canceled by the user or Stripe.
  setSubscriptionCanceled() {
    this._set(RELIQ_KEYS.SUBSCRIPTION_STATUS, "canceled");
  },

  // ── Preview context writers ───────────────────────────────────────────────────
  // Write the visitor-preview context. Called by free-scan.jsx when a result is ready.
  // source defaults to "free-scan". Pass redirect without a leading slash.
  setVisitorPreview(scanId, reportId = null, source = "free-scan", redirect = null) {
    if (scanId)   this._set(RELIQ_KEYS.PREVIEW_SCAN_ID,         scanId);
    if (reportId) this._set(RELIQ_KEYS.PREVIEW_REPORT_ID,       reportId);
    this._set(RELIQ_KEYS.SIGNUP_SOURCE,            source);
    // Store the preview result route under the dedicated preview redirect key.
    // go() strips leading slashes, so store without one for consistency.
    const dest = redirect || (scanId ? "scan/" + scanId : "dashboard");
    this._set(RELIQ_KEYS.PREVIEW_PENDING_REDIRECT, dest);
    // Do NOT write to RELIQ_KEYS.PENDING_REDIRECT — that key is deprecated.
  },

  // Clear all preview context keys (scan ID, report ID, signup source, preview redirect).
  // Also clears the legacy PENDING_REDIRECT for sessions created before Step 3.
  // Called by signup.jsx after successful auth, and by free-scan.jsx on direct sign-in.
  clearPreviewContext() {
    this._del(RELIQ_KEYS.PREVIEW_SCAN_ID);
    this._del(RELIQ_KEYS.PREVIEW_REPORT_ID);
    this._del(RELIQ_KEYS.SIGNUP_SOURCE);
    this._del(RELIQ_KEYS.PREVIEW_PENDING_REDIRECT);
    this._del(RELIQ_KEYS.PENDING_REDIRECT);  // legacy — clear for pre-migration sessions
  },

  // ── Session logout ─────────────────────────────────────────────────────────────
  // Clears auth and subscription state so the UI correctly reflects unauthenticated state.
  // In production, subscription state is re-hydrated from the server on next login.
  // Preview context is intentionally preserved — it is device-scoped, not session-scoped.
  logout() {
    this._del(RELIQ_KEYS.AUTH_USER);
    this._del(RELIQ_KEYS.AUTH_STATUS);
    this._del(RELIQ_KEYS.AUTH_PROVIDER);
    this._del(RELIQ_KEYS.SELECTED_PLAN);
    this._del(RELIQ_KEYS.SUBSCRIPTION_PLAN);
    this._del(RELIQ_KEYS.SUBSCRIPTION_STATUS);
    this._del(RELIQ_KEYS.TRIAL_STATUS);
    this._del(RELIQ_KEYS.TRIAL_DAYS);
    this._del(RELIQ_KEYS.TRIAL_STARTED_AT);
    this._del(RELIQ_KEYS.TRIAL_ENDS_AT);
    this._del(RELIQ_KEYS.RENEWAL_PRICE);
    this._del(RELIQ_KEYS.PAYMENT_ADDED);
    this._del(RELIQ_KEYS.PAYMENT_REQUIRED);
    this._del(RELIQ_KEYS.TRIAL_PLAN);    // legacy key — cleaned on logout
    this._del(RELIQ_KEYS.TRIAL_SOURCE);  // legacy key — cleaned on logout
    // Redirect state — clear auth and payment redirects so a future user/session
    // does not inherit stale navigation targets from the previous session.
    this._del(RELIQ_KEYS.AUTH_PENDING_REDIRECT);
    this._del(RELIQ_KEYS.PAYMENT_PENDING_REDIRECT);
    this._del(RELIQ_KEYS.PENDING_REDIRECT);  // legacy — may hold a stale auth redirect
    // Preview context (PREVIEW_SCAN_ID, PREVIEW_REPORT_ID, SIGNUP_SOURCE,
    // PREVIEW_PENDING_REDIRECT) is intentionally NOT cleared — it belongs to
    // the device, not the session. A returning visitor should still see their scan.
    if (window.reliqAuth) window.reliqAuth.signOut();
  },

  // ── Trial timing helpers ─────────────────────────────────────────────────────
  // Prefer the explicit TRIAL_ENDS_AT stored during activateTrial().
  // Falls back to computing from start date for older sessions.
  getTrialEndDate() {
    const explicit = this._get(RELIQ_KEYS.TRIAL_ENDS_AT);
    if (explicit) return new Date(explicit);
    const start = this._get(RELIQ_KEYS.TRIAL_STARTED_AT);
    const base = start ? new Date(start) : new Date();
    const end = new Date(base);
    end.setDate(end.getDate() + 7);
    return end;
  },
  getTrialStartDate() {
    const s = this._get(RELIQ_KEYS.TRIAL_STARTED_AT);
    return s ? new Date(s) : null;
  },
  getTrialEndStr() {
    return this.getTrialEndDate().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
  },
  getDaysLeft() {
    const diff = Math.ceil((this.getTrialEndDate() - new Date()) / (1000 * 60 * 60 * 24));
    return Math.max(0, diff);
  },
  // Renewal price — stored during activateTrial; falls back to plan meta lookup.
  getRenewalPrice() {
    const stored = this._get(RELIQ_KEYS.RENEWAL_PRICE);
    if (stored !== null) return parseInt(stored, 10);
    return PLAN_META[this.getPlan()]?.price ?? 0;
  },
  getRenewalPriceStr() {
    const p = this.getRenewalPrice();
    return p > 0 ? `$${p}/mo` : "Free";
  },

  // ── Backend state cache (Step 9) ──────────────────────────────────────────
  // _billingCache holds the last BillingSummaryResponse fetched from the backend.
  // In-memory only — not persisted. Guards in index.html read localStorage which
  // loadFromBackend() keeps in sync with the backend truth.
  _billingCache: null,

  // Returns the in-memory billing summary cache, or null if not yet loaded.
  getBillingCache() {
    return this._billingCache;
  },

  // Fetches BillingSummaryResponse from the backend, then:
  //   1. Stores the full response in _billingCache (in-memory, for UI reads)
  //   2. Syncs key fields to localStorage (so payment guard + getProductState stay current)
  //   3. Fires 'reliq-billing-loaded' so billing UI components can re-render
  // Returns the summary on success, null if unreachable or auth fails.
  async loadFromBackend() {
    try {
      const summary = await window.reliqBillingApi?.getBillingSummary?.();
      if (!summary) return null;
      this._billingCache = summary;

      const sub = summary.subscription;
      if (sub && sub.status) {
        // Map backend SubscriptionStatus enum → frontend localStorage status string
        const STATUS_MAP = {
          ACTIVE:             'active',
          TRIALING:           'trialing',
          PAST_DUE:           'past_due',
          CANCELED:           'canceled',
          PENDING_PAYMENT:    'incomplete',
          INCOMPLETE:         'incomplete',
          INCOMPLETE_EXPIRED: 'canceled',
          PAUSED:             'canceled',
        };
        const localStatus = STATUS_MAP[sub.status] || 'active';
        this._set(RELIQ_KEYS.SUBSCRIPTION_STATUS, localStatus);

        if (sub.planKey) {
          this._set(RELIQ_KEYS.SUBSCRIPTION_PLAN, sub.planKey.toLowerCase());
        }

        // Sync trial status for payment guard compatibility
        if (sub.status === 'TRIALING') {
          this._set(RELIQ_KEYS.TRIAL_STATUS, 'active');
          if (sub.trialEndsAt) this._set(RELIQ_KEYS.TRIAL_ENDS_AT, sub.trialEndsAt);
          if (sub.trialStartedAt) this._set(RELIQ_KEYS.TRIAL_STARTED_AT, sub.trialStartedAt);
        } else if (sub.status === 'PENDING_PAYMENT') {
          this._set(RELIQ_KEYS.TRIAL_STATUS, 'pending_payment');
        } else {
          this._set(RELIQ_KEYS.TRIAL_STATUS, localStatus);
        }

        // Sync payment method status
        if (sub.paymentMethodStatus === 'VALID') {
          this._set(RELIQ_KEYS.PAYMENT_ADDED,    'true');
          this._set(RELIQ_KEYS.PAYMENT_REQUIRED, 'false');
        } else if (sub.paymentMethodStatus === 'MISSING') {
          this._set(RELIQ_KEYS.PAYMENT_ADDED,    'false');
          this._set(RELIQ_KEYS.PAYMENT_REQUIRED, 'true');
        }
      }

      window.dispatchEvent(new Event('reliq-billing-loaded'));
      return summary;
    } catch (_) {
      // Backend unreachable or auth failed — use localStorage as fallback.
      return null;
    }
  },
};

// ── Plan + usage helpers (Step 8) ─────────────────────────────────────────────
// Centralised plan-model accessor. Reads from RELIQ_PLANS + subscription state.
// All methods are read-only except setCurrentPlan / changePlan / saveUsageSnapshot.
const reliqPlans = {

  // Returns a plan object by key, falling back to "preview" for unknown keys.
  getPlan(key) {
    return RELIQ_PLANS[key] || RELIQ_PLANS.preview;
  },

  // Returns the current user's plan key ("preview" | "starter" | "pro" | "team").
  // Reads from SUBSCRIPTION_PLAN localStorage key; falls back to "preview".
  getCurrentPlanKey() {
    const p = reliqSubscription.getPlan();
    return (p && RELIQ_PLANS[p]) ? p : "preview";
  },

  // Returns the current user's full plan object.
  getCurrentPlan() {
    return this.getPlan(this.getCurrentPlanKey());
  },

  // Returns a specific numeric limit for a plan key (null = unlimited, 0 = none).
  getPlanLimit(planKey, limitKey) {
    const plan = this.getPlan(planKey);
    const val = plan[limitKey];
    return val !== undefined ? val : null;
  },

  // Does the plan's feature list include featureKey?
  hasFeature(planKey, featureKey) {
    const plan = this.getPlan(planKey);
    return Array.isArray(plan.features) && plan.features.includes(featureKey);
  },

  // Can the current user use featureKey? Checks plan features AND subscription state.
  // past_due / canceled block paid features even if the plan includes them.
  // visitor / visitor_preview can only access free_preview.
  // Public docs / free scan are never blocked — use featureKey "free_preview".
  canUseFeature(featureKey) {
    // free_preview is always allowed — never block public docs or the free scan.
    if (featureKey === FEATURE_KEYS.FREE_PREVIEW) return true;
    const state = reliqSubscription.getProductState();
    // Unauthenticated users: only free_preview (handled above).
    if (state === RELIQ_USER_STATE.VISITOR || state === RELIQ_USER_STATE.VISITOR_PREVIEW) return false;
    // Pending payment: no paid features until payment is set up.
    if (state === RELIQ_USER_STATE.PENDING_PAYMENT) return false;
    // Past due / canceled: block all paid features regardless of plan.
    if (state === RELIQ_USER_STATE.PAST_DUE || state === RELIQ_USER_STATE.CANCELED) return false;
    // Trialing / active: check the plan's feature list.
    return this.hasFeature(this.getCurrentPlanKey(), featureKey);
  },

  // Returns the usage snapshot from localStorage, or sensible mock defaults.
  // Keys: scansThisMonth, appsTracked, reportsGenerated, apiKeys, webhooks, teamSeats.
  getUsageSnapshot() {
    try {
      const raw = localStorage.getItem(RELIQ_KEYS.USAGE_SNAPSHOT);
      if (raw) {
        const parsed = JSON.parse(raw);
        if (parsed && typeof parsed === "object") return parsed;
      }
    } catch (_) {}
    // Default mock usage (matches billing page display defaults).
    return {
      scansThisMonth:   23,
      appsTracked:       3,
      reportsGenerated:  8,
      apiKeys:           1,
      webhooks:          0,
      teamSeats:         1,
    };
  },

  // Persists a usage snapshot object to localStorage.
  saveUsageSnapshot(snapshot) {
    try {
      localStorage.setItem(RELIQ_KEYS.USAGE_SNAPSHOT, JSON.stringify(snapshot));
    } catch (_) {}
  },

  // Is usage at or above 80% of the plan limit for limitKey?
  // Returns false if the limit is null (unlimited) or 0.
  isNearLimit(planKey, limitKey, usageValue) {
    const limit = this.getPlanLimit(planKey, limitKey);
    if (limit === null || limit === 0) return false;
    return usageValue / limit >= 0.8;
  },

  // Is usage at or above 100% of the plan limit for limitKey?
  // Returns false if the limit is null (unlimited).
  isOverLimit(planKey, limitKey, usageValue) {
    const limit = this.getPlanLimit(planKey, limitKey);
    if (limit === null) return false;
    if (limit === 0) return usageValue > 0;
    return usageValue >= limit;
  },

  // Sets the current plan in localStorage and updates the renewal price.
  // Used for mock upgrade/downgrade flows in billing.jsx.
  setCurrentPlan(planKey) {
    const plan = RELIQ_PLANS[planKey];
    if (!plan) return;
    reliqSubscription._set(RELIQ_KEYS.SUBSCRIPTION_PLAN, planKey);
    reliqSubscription._set(RELIQ_KEYS.RENEWAL_PRICE, String(plan.price ?? 0));
  },

  // Returns true if a plan change from fromKey to toKey is valid.
  canChangePlan(fromKey, toKey) {
    if (!fromKey || !toKey || fromKey === toKey) return false;
    return !!RELIQ_PLANS[fromKey] && !!RELIQ_PLANS[toKey];
  },

  // Executes a mock plan change and returns true on success.
  changePlan(toKey) {
    if (!RELIQ_PLANS[toKey]) return false;
    this.setCurrentPlan(toKey);
    return true;
  },
};

// ── Feature gate helpers ──────────────────────────────────────────────────────
// Call these before rendering gated UI. All checks are read-only / side-effect free.
const reliqGates = {
  // Lazy reference to reliqAuth — defined in signin.jsx which loads after this file,
  // but always exists by the time App runs (all scripts load before React renders).
  _isAuthed() {
    return window.reliqAuth ? window.reliqAuth.isAuthed() : false;
  },
  _plan() {
    const p = reliqSubscription.getPlan();
    return (p && PLAN_FEATURES[p]) ? p : "free";
  },
  // Feature key map: frontend PLAN_FEATURES camelCase → backend BillingFeatureKey snake_case.
  // Used when backend billing summary is available to prefer authoritative feature gates.
  _FEAT_KEY_MAP: {
    pdfExport:         'export_pdf',
    shareLinks:        'share_report',
    rescan:            'new_scan',
    apiKeys:           'api_keys',
    githubAction:      'github_action',
    webhooks:          'webhooks',
    teamFeatures:      'team_members',
    whiteLabelReports: null,
    slackLinear:       null,
    dangerZone:        null,
    tabSdk:            null,
    tabPrivacy:        null,
    tabListing:        null,
    tabSecurity:       null,
    tabExport:         'export_pdf',
  },
  _feat(key) {
    // Prefer backend features from BillingSummaryResponse.features when available.
    const backendFeatures = window.reliqSubscription?.getBillingCache?.()?.features;
    if (backendFeatures) {
      const backendKey = this._FEAT_KEY_MAP[key];
      if (backendKey && backendKey in backendFeatures) {
        return !!backendFeatures[backendKey];
      }
    }
    return PLAN_FEATURES[this._plan()]?.[key] ?? false;
  },
  // Is the user on an active or trialing subscription?
  _hasAccess() {
    if (!this._isAuthed()) return false;
    const state = reliqSubscription.getUserState(true);
    return state === RELIQ_USER_STATE.TRIALING || state === RELIQ_USER_STATE.ACTIVE;
  },

  // ── Public gate checks ────────────────────────────────────────────────────────
  requireAuth()              { return this._isAuthed(); },
  requireSubscription()      { return this._hasAccess(); },
  requirePaymentMethod()     { return reliqSubscription.hasPaymentMethod(); },

  // Feature-level
  canExportReport()          { return this._hasAccess() && this._feat("pdfExport"); },
  canShareReport()           { return this._hasAccess() && this._feat("shareLinks"); },
  canRescan()                { return this._hasAccess() && this._feat("rescan"); },
  canUseApiKeys()            { return this._hasAccess() && this._feat("apiKeys"); },
  canUseGitHubAction()       { return this._hasAccess() && this._feat("githubAction"); },
  canUseWebhooks()           { return this._hasAccess() && this._feat("webhooks"); },
  canUseTeamFeatures()       { return this._hasAccess() && this._feat("teamFeatures"); },
  canUseWhiteLabelReports()  { return this._hasAccess() && this._feat("whiteLabelReports"); },
  canUseSlackLinear()        { return this._hasAccess() && this._feat("slackLinear"); },
  canAccessDangerZone()      { return this._hasAccess() && this._feat("dangerZone"); },

  // Tab-level (used in scan-results, settings, API pages)
  canAccessTab(tabKey) {
    const featKey = {
      "sdk":      "tabSdk",
      "privacy":  "tabPrivacy",
      "listing":  "tabListing",
      "security": "tabSecurity",
      "export":   "tabExport",
    }[tabKey];
    if (!featKey) return true; // ungated tabs are always accessible
    return this._hasAccess() && this._feat(featKey);
  },

  // Which plan is required to unlock a given tab?
  planRequiredForTab(tabKey) {
    // All detail tabs require at least Starter
    return "starter";
  },
};

// ── Form validation helpers ───────────────────────────────────────────────────
// Returns null on pass, or an error string on failure.
const reliqValidate = {
  email(value) {
    if (!value || !value.trim()) return "Email is required.";
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())) return "Enter a valid email address.";
    return null;
  },
  password(value, minLen = 8) {
    if (!value) return "Password is required.";
    if (value.length < minLen) return `Password must be at least ${minLen} characters.`;
    return null;
  },
  required(value, label = "This field") {
    if (!value || !String(value).trim()) return `${label} is required.`;
    return null;
  },
  cardNumber(value) {
    const raw = (value || "").replace(/\s/g, "");
    if (!raw || raw.length < 16) return "Enter a valid 16-digit card number.";
    return null;
  },
  expiry(value) {
    if (!value || value.length < 5) return "Enter a valid expiry date (MM/YY).";
    const [mm, yy] = value.split("/");
    const exp = new Date(2000 + parseInt(yy || "0", 10), parseInt(mm || "0", 10) - 1, 1);
    const now = new Date();
    if (isNaN(exp.getTime()) || exp <= new Date(now.getFullYear(), now.getMonth(), 1))
      return "This card has expired.";
    return null;
  },
  cvc(value) {
    const raw = (value || "").replace(/\D/g, "");
    if (!raw || raw.length < 3) return "Enter a 3 or 4-digit security code.";
    return null;
  },
  fileType(name, allowedExts = [".apk", ".aab", ".ipa"]) {
    if (!name) return "Please select a file.";
    const lower = name.toLowerCase();
    if (!allowedExts.some(ext => lower.endsWith(ext)))
      return `Invalid file type. Please upload an ${allowedExts.join(", ")}.`;
    return null;
  },
  fileSize(bytes, maxMB = 500) {
    if (bytes > maxMB * 1024 * 1024)
      return `File too large. Maximum size is ${maxMB} MB.`;
    return null;
  },
};

// ── ID prefix helpers ─────────────────────────────────────────────────────────
// Scan IDs:         scn_          — full authenticated scans
// Report IDs:       rep_          — full reports linked to a scan
// Preview IDs:      rep_preview_  — free-preview reports (subset of rep_)
// Always validate before use. Never use a scn_ ID where a rep_ ID is expected.
const reliqIds = {
  isScanId(id)          { return typeof id === "string" && id.startsWith("scn_"); },
  // isReportId returns true for both rep_ and rep_preview_ (preview IDs are report IDs).
  isReportId(id)        { return typeof id === "string" && id.startsWith("rep_"); },
  isPreviewReportId(id) { return typeof id === "string" && id.startsWith("rep_preview_"); },

  assertScanId(id) {
    if (!this.isScanId(id)) throw new Error(`Invalid scan ID: "${id}". Expected scn_ prefix.`);
    return id;
  },
  assertReportId(id) {
    if (!this.isReportId(id)) throw new Error(`Invalid report ID: "${id}". Expected rep_ prefix.`);
    return id;
  },

  // Returns the hash-routing path for a report's public share page (no leading slash).
  // Example: "r/rep_2k8x"
  // Throws if reportId does not start with rep_ — NEVER pass a scn_ ID here.
  makeReportUrl(reportId) {
    if (!this.isReportId(reportId))
      throw new Error(`makeReportUrl: expected rep_ ID, got "${reportId}". Never pass a scan ID as a report ID.`);
    return "r/" + reportId;
  },

  // Returns the canonical public share URL for a report.
  // In production: API-generated signed URL. In the prototype: stable base URL.
  // NEVER call this with a scn_ ID — it will throw.
  makeShareReportUrl(reportId) {
    if (!this.isReportId(reportId))
      throw new Error(`makeShareReportUrl: expected rep_ ID, got "${reportId}". Never pass a scan ID as a report ID.`);
    return "https://reliq.dev/r/" + reportId;
  },

  // Maps a scan ID to its linked report ID using the demo data mapping.
  // In production, this is a server-side lookup by scan ID.
  // Returns null if no mapping exists or if the input is not a valid scn_ ID.
  getReportIdFromScan(scanId) {
    if (!this.isScanId(scanId)) return null;
    const DEMO_MAP = {
      "scn_2k8x": "rep_2k8x",
      "scn_2k8r": "rep_2k8r",
      "scn_2k8w": "rep_2k8w",
      "scn_2k8v": "rep_2k8v",
      "scn_2k8u": "rep_2k8u",
      "scn_2k8t": "rep_2k8t",
      "scn_2k8s": "rep_2k8s",
    };
    return DEMO_MAP[scanId] || null;
  },
};

// ── Navigation helper ─────────────────────────────────────────────────────────
// Use reliqNav.go() instead of calling window.location.hash directly.
const reliqNav = {
  go(path, goFn) {
    if (goFn) { goFn(path); return; }
    const clean = path.replace(/^\/+/, "");
    window.location.hash = "/" + clean;
    window.scrollTo({ top: 0, behavior: "instant" });
  },
  // Navigate to a landing page section from any page
  toSection(sectionId, goFn) {
    try { sessionStorage.setItem("reliq_scroll_to", sectionId); } catch (_) {}
    this.go("", goFn);
  },
  // Navigate to a specific tab within a route (e.g. api?tab=webhooks)
  toTab(baseRoute, tab, goFn) {
    this.go(`${baseRoute}?tab=${tab}`, goFn);
  },
};

// ── Layout type registry ──────────────────────────────────────────────────────
// Documents which layout each route should use. Purely informational — enforced
// by the component structure itself, but useful for auditing.
//
// PublicLayout      → landing, docs, github-action, privacy, terms, security, status, free-scan
// AuthMinimalLayout → signin, signup, payment, reset-password, contact-sales
// AuthLayout        → dashboard, scan/*, scans, reports, apps, policy, api, team, billing, settings
// ReportShareLayout → (future) /r/:reportId
const RELIQ_LAYOUT_MAP = {
  "":                "public",
  "docs":            "public",
  "github-action":   "public",
  "privacy":         "public",
  "terms":           "public",
  "security":        "public",
  "status":          "public",
  "free-scan":       "public",
  "signin":          "auth-minimal",
  "signup":          "auth-minimal",
  "payment":         "auth-minimal",
  "contact-sales":   "auth-minimal",
  "dashboard":       "auth",
  "scans":           "auth",
  "reports":         "auth",
  "apps":            "auth",
  "policy":          "auth",
  "api":             "auth",
  "team":            "auth",
  "billing":         "auth",
  "settings":        "auth",
};

// ── GitHub integration URL constants ─────────────────────────────────────────
// Use these wherever a link to the Reliq GitHub repo or Marketplace listing
// is needed.  Both pages may not be live yet; the values are placeholders until
// the real repos are published.  Never hard-code these URLs elsewhere — import
// from window so any future redirect/rename only needs one change here.
const RELIQ_GITHUB_REPO_URL        = "https://github.com/reliq-dev/reliq";
const RELIQ_GITHUB_MARKETPLACE_URL = "https://github.com/marketplace/actions/reliq-scan";

Object.assign(window, {
  // State constants
  RELIQ_KEYS,
  RELIQ_USER_STATE,
  PLAN_FEATURES,
  PLAN_META,
  RELIQ_LAYOUT_MAP,
  // Plan model (Step 8)
  RELIQ_PLANS,
  FEATURE_KEYS,
  // Role and permission model (Step 9)
  RELIQ_ROLES,
  RELIQ_PERMISSIONS,
  // Default data (Step 9)
  DEFAULT_WORKSPACE,
  INITIAL_TEAM_MEMBERS,
  INITIAL_AUDIT_LOG,
  // Default data (Step 10)
  DEFAULT_USER_PROFILE,
  DEFAULT_SECURITY_SETTINGS,
  INITIAL_ACTIVE_SESSIONS,
  INITIAL_SECURITY_EVENTS,
  DEFAULT_NOTIFICATION_PREFS,
  DEFAULT_SCAN_DEFAULTS,
  // Centralised state helpers
  reliqSubscription,  // read/write for all trial + subscription state
  reliqPlans,         // plan model + usage helpers (Step 8)
  reliqGates,         // feature-gate checks (read-only, side-effect free)
  reliqValidate,      // form validation helpers
  reliqIds,           // scan/report ID prefix helpers
  reliqNav,           // programmatic navigation helpers
  reliqWorkspace,     // workspace, team, audit, SSO read/write (Step 9)
  reliqRoles,         // role and permission helpers (Step 9)
  // GitHub integration
  RELIQ_GITHUB_REPO_URL,
  RELIQ_GITHUB_MARKETPLACE_URL,
});
