import 'express-async-errors';
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import multer from 'multer';
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import sharp from 'sharp';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { nanoid } from 'nanoid';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { randomInt, createHmac, randomUUID } from 'node:crypto';
import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 3001);
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
  console.error('FATAL: JWT_SECRET env var is missing or shorter than 32 chars. Set it before starting.');
  process.exit(1);
}
// refuse to run with a leftover placeholder secret — a guessable JWT_SECRET means
// anyone can forge an admin session, which defeats every other auth control.
if (/change-me|replace-this|replace-with|your-secret|example|placeholder/i.test(JWT_SECRET)) {
  console.error('FATAL: JWT_SECRET is still a placeholder. Set a real random value, e.g. `openssl rand -hex 32`.');
  process.exit(1);
}
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`;
// Canonical base for media URLs. The object-storage host blocks browser User-Agents,
// so we never hand browsers a direct storage URL — they go through this app's /media proxy.
const MEDIA_BASE = (process.env.MEDIA_BASE || PUBLIC_ORIGIN).replace(/\/+$/, '');

// On Liara, set UPLOADS_DIR to a mounted Disk path (e.g. /var/lib/uploads) so
// uploaded images survive redeploys. Defaults to a local folder for dev.
const uploadsDir = process.env.UPLOADS_DIR || path.join(__dirname, 'uploads');
// Only needed for local-disk fallback. On Liara uploads go to object storage and
// the app dir may be read-only — never let a failed mkdir crash startup.
try {
  if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
} catch (e) {
  console.warn(`[uploads] could not create ${uploadsDir}: ${e.message} (ok when using object storage)`);
}

// buffer the upload in memory so we can transcode it to WebP before writing to disk
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 10 * 1024 * 1024 },
  fileFilter: (_req, file, cb) => {
    if (/^image\/(jpe?g|png|webp|gif|avif|tiff?)$/.test(file.mimetype)) cb(null, true);
    else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif)'));
  },
});

// raw (non-image) uploads — e.g. videos — streamed straight to object storage.
const uploadRaw = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 200 * 1024 * 1024 },
  fileFilter: (_req, file, cb) => {
    if (/^(video\/(mp4|webm|ogg|quicktime|x-matroska)|audio\/(mpeg|mp3|wav|x-wav|ogg|webm|aac|x-m4a|mp4)|application\/pdf)$/.test(file.mimetype)) cb(null, true);
    else cb(new Error('Only video, audio or PDF uploads are allowed'));
  },
});

// ── Object storage (Liara / any S3-compatible) ──
// Enabled when all LIARA_* env vars are set; otherwise uploads fall back to the local disk.
const S3_ENABLED = !!(
  process.env.LIARA_ENDPOINT &&
  process.env.LIARA_BUCKET &&
  process.env.LIARA_ACCESS_KEY &&
  process.env.LIARA_SECRET_KEY
);
const s3Host = (process.env.LIARA_ENDPOINT || '').replace(/^https?:\/\//, '').replace(/\/+$/, '');
const s3 = S3_ENABLED
  ? new S3Client({
      region: 'default',
      endpoint: `https://${s3Host}`,
      credentials: {
        accessKeyId: process.env.LIARA_ACCESS_KEY,
        secretAccessKey: process.env.LIARA_SECRET_KEY,
      },
      forcePathStyle: true,
    })
  : null;

// upload a buffer to the bucket and return its public URL (bucket must allow public reads)
async function putObject(key, body, contentType) {
  await s3.send(new PutObjectCommand({
    Bucket: process.env.LIARA_BUCKET,
    Key: key,
    Body: body,
    ContentType: contentType,
  }));
  return `${MEDIA_BASE}/media/${key}`;
}

// Validate a file by its magic bytes (content), not the client-supplied mimetype.
// Returns the detected family ('image'|'video'|'audio'|'pdf') or null if not allowed.
function sniffKind(buf) {
  if (!buf || buf.length < 12) return null;
  const b = buf;
  const hex = (n) => b.subarray(0, n).toString('hex');
  if (b.subarray(0, 4).toString('ascii') === '%PDF') return 'pdf';          // PDF
  if (hex(3) === 'ffd8ff') return 'image';                                  // JPEG
  if (hex(8) === '89504e470d0a1a0a') return 'image';                        // PNG
  if (b.subarray(0, 4).toString('ascii') === 'GIF8') return 'image';        // GIF
  if (b.subarray(0, 4).toString('ascii') === 'RIFF' && b.subarray(8, 12).toString('ascii') === 'WEBP') return 'image';
  if (hex(4) === '49492a00' || hex(4) === '4d4d002a') return 'image';       // TIFF
  if (b.subarray(4, 8).toString('ascii') === 'ftyp') return 'video';        // MP4/MOV/m4a
  if (hex(4) === '1a45dfa3') return 'video';                                // WebM/MKV
  if (hex(2) === 'fffb' || hex(2) === 'fff3' || b.subarray(0, 3).toString('ascii') === 'ID3') return 'audio'; // MP3
  if (b.subarray(0, 4).toString('ascii') === 'OggS') return 'audio';        // Ogg
  if (b.subarray(0, 4).toString('ascii') === 'fLaC') return 'audio';        // FLAC
  return null;
}

// only object-storage keys produced by this app (nanoid + extension) are valid —
// blocks path traversal (e.g. ..%2F..) into arbitrary bucket keys
const SAFE_KEY = /^[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/;

// reject non-JSON bodies on state-changing routes. With strict CORS this forces a
// preflight (which a cross-site attacker can't pass), so it doubles as CSRF defence.
function jsonOnly(req, res, next) {
  if (!req.is('application/json')) return res.status(415).json({ error: 'json_required' });
  next();
}

const app = express();
app.set('trust proxy', 1); // honor X-Forwarded-Proto from the reverse proxy
// Send the session cookie with Secure whenever the request actually arrived over HTTPS,
// so a forgotten NODE_ENV can't downgrade the cookie to plaintext behind a TLS proxy.
const cookieSecure = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https';
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173')
  .split(',').map(s => s.trim());
// any localhost/127.0.0.1 origin (any port) is allowed in addition to ALLOWED_ORIGINS —
// the panel serves its own SPA, whose module scripts fetch same-origin with an Origin
// header; rejecting that 500s app.js. Disallowed origins get cb(null,false) (no CORS
// headers) rather than a thrown error, so a bad cross-origin request never 500s static assets.
// localhost (any port) is allowed only in dev — in production it would widen the
// credentialed-CORS allowlist to any local service on a victim's machine.
const isLocalhost = (o) => process.env.NODE_ENV !== 'production' && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(o);
app.use(cors({
  origin: (origin, cb) => {
    cb(null, !origin || ALLOWED_ORIGINS.includes(origin) || isLocalhost(origin));
  },
  credentials: true,
}));
app.use(cookieParser());
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "blob:", ...(s3Host && process.env.LIARA_BUCKET ? [`https://${process.env.LIARA_BUCKET}.${s3Host}`] : [])],
      connectSrc: ["'self'"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      frameAncestors: ["'none'"],
    },
  },
  frameguard: { action: 'deny' },
}));
app.use(express.json({ limit: '1mb' }));
// Public media proxy: streams an object from storage server-side and relays it from
// our own domain. Browsers are blocked from the storage host by a User-Agent filter,
// so this is the only way an <img>/<video> can load uploaded media in a browser.
app.get('/media/:key', async (req, res) => {
  if (!s3) return res.status(404).end();
  if (!SAFE_KEY.test(req.params.key)) return res.status(400).end(); // block path traversal into the bucket
  try {
    const range = req.headers.range;
    const obj = await s3.send(new GetObjectCommand({
      Bucket: process.env.LIARA_BUCKET, Key: req.params.key, Range: range || undefined,
    }));
    if (obj.ContentType) res.setHeader('Content-Type', obj.ContentType);
    res.setHeader('Accept-Ranges', 'bytes');
    res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
    // allow the site (different origin) to embed this media — overrides helmet's same-origin CORP
    res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
    if (obj.ContentRange) { res.status(206); res.setHeader('Content-Range', obj.ContentRange); }
    if (obj.ContentLength != null) res.setHeader('Content-Length', String(obj.ContentLength));
    obj.Body.pipe(res);
  } catch {
    res.status(404).end();
  }
});
app.get('/.well-known/security.txt', (_req, res) => {
  res.type('text/plain').send(`Contact: mailto:${process.env.SECURITY_CONTACT || 'security@steelforesight.ir'}\nPreferred-Languages: fa, en\nExpires: 2027-01-01T00:00:00.000Z\n`);
});
app.use('/uploads', express.static(uploadsDir, { maxAge: '7d' }));

// api.steelforesight.ir is API-only — never serve the admin SPA there (it would
// expose the login page). The admin UI stays on the cms-steelforesight.liara.run host.
// /api, /media, /uploads, /.well-known are registered above, so only the SPA reaches here.
const API_ONLY_HOST = process.env.API_ONLY_HOST || 'api.steelforesight.ir';
// optional: lock the admin SPA to specific IPs (comma-separated). Unset = open to all.
const ADMIN_IPS = (process.env.ADMIN_IP_ALLOWLIST || '').split(',').map(s => s.trim()).filter(Boolean);
const serveAdminSPA = express.static(path.join(__dirname, 'public'));
app.use('/', (req, res, next) => {
  const host = (req.headers.host || '').split(':')[0];
  const isApiPath = req.path.startsWith('/api/');
  if (host === API_ONLY_HOST && !isApiPath) return res.status(404).end();
  // IP allowlist applies only to the admin SPA, never to the public /api the site needs
  if (ADMIN_IPS.length && !isApiPath && !ADMIN_IPS.includes(req.ip)) return res.status(404).end();
  return serveAdminSPA(req, res, next);
});

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  message: { error: 'too_many_attempts' },
  standardHeaders: true,
  legacyHeaders: false,
});

const contactLimiter = rateLimit({
  windowMs: 10 * 60 * 1000,
  max: 3,
  message: { error: 'too_many_requests' },
  standardHeaders: true,
  legacyHeaders: false,
});

// Per-account login lockout — complements the per-IP loginLimiter by bounding a
// distributed / credential-stuffing attack that rotates IPs against ONE account.
// ponytail: in-memory Map (resets on restart, single-process); fine for this panel.
// Move to Postgres only if you scale to multiple instances.
const loginFails = new Map(); // key -> { n, exp }
const LOCK_MAX = 5;
const LOCK_MS = 15 * 60 * 1000;
const loginLocked = (key) => { const e = loginFails.get(key); return !!e && e.n >= LOCK_MAX && e.exp > Date.now(); };
function bumpLoginFail(key) {
  const now = Date.now();
  const e = loginFails.get(key);
  if (!e || e.exp <= now) loginFails.set(key, { n: 1, exp: now + LOCK_MS });
  else { e.n++; e.exp = now + LOCK_MS; }
}
const clearLoginFail = (key) => loginFails.delete(key);

async function authRequired(req, res, next) {
  let token = req.cookies?.session;
  if (!token) {
    const auth = req.headers['authorization'];
    if (auth?.startsWith('Bearer ')) token = auth.slice(7);
  }
  if (!token) return res.status(401).json({ error: 'unauthorized' });
  try {
    const payload = jwt.verify(token, JWT_SECRET);
    // must be an admin token — a member JWT (same secret) must NOT reach admin routes
    if (payload.role !== 'admin' && payload.role !== 'owner') return res.status(403).json({ error: 'forbidden' });
    // server-side revocation: logout / password change bumps token_version, killing old tokens
    const acct = await db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub);
    if (!acct || (acct.token_version || 0) !== (payload.tv || 0)) return res.status(401).json({ error: 'session_revoked' });
    req.user = payload;
    next();
  } catch {
    return res.status(401).json({ error: 'invalid_token' });
  }
}

// only an 'owner' admin may manage other admin accounts
function ownerRequired(req, res, next) {
  if (req.user?.role !== 'owner') return res.status(403).json({ error: 'forbidden' });
  next();
}

async function memberRequired(req, res, next) {
  // accept the HttpOnly member cookie first, then a Bearer header — never a URL query param
  let token = req.cookies?.member_session;
  if (!token) {
    const auth = req.headers['authorization'];
    if (auth?.startsWith('Bearer ')) token = auth.slice(7);
  }
  if (!token) return res.status(401).json({ error: 'unauthorized' });
  try {
    const payload = jwt.verify(token, JWT_SECRET);
    if (payload.role !== 'member') return res.status(403).json({ error: 'forbidden' });
    // a 30-day token must not outlive a deactivated account or a logout — verify per request
    const acct = await db.prepare('SELECT is_active, token_version FROM members WHERE id = ?').get(payload.sub);
    if (!acct || !acct.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
    if ((acct.token_version || 0) !== (payload.tv || 0)) return res.status(401).json({ error: 'session_revoked' });
    req.member = payload;
    next();
  } catch {
    return res.status(401).json({ error: 'invalid_token' });
  }
}

// member session cookie options. When COOKIE_DOMAIN is set (e.g. ".steelforesight.ir"),
// the API is a same-site subdomain of the site, so the cookie is first-party and
// 'lax' survives third-party-cookie blocking (fixes "logged out in a new tab").
// Without it, fall back to the cross-site 'none' behaviour.
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || '';
function memberCookieOpts(req) {
  const secure = cookieSecure(req);
  return {
    httpOnly: true,
    secure,
    sameSite: COOKIE_DOMAIN ? 'lax' : (secure ? 'none' : 'lax'),
    ...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}),
    maxAge: 30 * 24 * 60 * 60 * 1000,
    path: '/',
  };
}

app.post('/api/auth/login', loginLimiter, async (req, res) => {
  const { username, password } = req.body || {};
  if (!username || !password) return res.status(400).json({ error: 'username_password_required' });
  const lockKey = `admin:${String(username).toLowerCase()}`;
  if (loginLocked(lockKey)) return res.status(429).json({ error: 'account_locked' });
  const row = await db.prepare('SELECT id, username, password_hash, role, token_version FROM users WHERE username = ?').get(username);
  // always run a bcrypt compare (dummy hash when no row) so timing can't reveal whether the user exists
  const ok = bcrypt.compareSync(password, row?.password_hash || '$2a$10$0000000000000000000000000000000000000000000000000000');
  if (!row || !ok) {
    bumpLoginFail(lockKey);
    return res.status(401).json({ error: 'bad_credentials' });
  }
  clearLoginFail(lockKey);
  const token = jwt.sign({ sub: row.id, username: row.username, role: row.role, tv: row.token_version || 0 }, JWT_SECRET, { expiresIn: '24h' });
  res.cookie('session', token, {
    httpOnly: true,
    secure: cookieSecure(req),
    sameSite: 'strict',
    maxAge: 24 * 60 * 60 * 1000,
    path: '/',
  });
  // token is delivered only via the HttpOnly cookie — never in the body (XSS can't read it)
  res.json({ user: { id: row.id, username: row.username, role: row.role } });
});

app.post('/api/auth/logout', authRequired, async (req, res) => {
  // bump token_version so the just-issued JWT (and any stolen copy) is revoked server-side
  await db.prepare('UPDATE users SET token_version = COALESCE(token_version,0) + 1 WHERE id = ?').run(req.user.sub);
  res.clearCookie('session', { httpOnly: true, secure: cookieSecure(req), sameSite: 'strict', path: '/' });
  res.json({ ok: true });
});

app.get('/api/auth/me', authRequired, (req, res) => {
  res.json({ user: req.user });
});

const PUBLIC_FIELDS = `
  id, title, category, type, author, author_role, author_initial,
  publish_date, pages, price, is_free, summary, body, cover_image,
  blocks, sources, tags, featured, created_at, updated_at
`;

app.get('/api/articles', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 50, 200);
  const { category, type } = req.query;
  let query = `SELECT ${PUBLIC_FIELDS} FROM articles`;
  const params = [];
  const conditions = [];
  if (category) { conditions.push('category = ?'); params.push(category); }
  if (type)     { conditions.push('type = ?');     params.push(type); }
  if (conditions.length) query += ' WHERE ' + conditions.join(' AND ');
  query += ' ORDER BY created_at DESC LIMIT ?';
  params.push(limit);
  const rows = await db.prepare(query).all(...params);
  res.json(rows.map(rowToArticle));
});

app.get('/api/articles/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToArticle(row));
});

app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'no_file' });
  // validate by content (magic bytes), not the client-supplied mimetype
  if (sniffKind(req.file.buffer) !== 'image') return res.status(415).json({ error: 'invalid_image' });
  try {
    const original = req.file.buffer;
    const originalSize = original.length;
    const isAnimated = req.file.mimetype === 'image/gif';

    // cap very large images, then transcode to WebP. Step quality down until the
    // output is ≤ ~25% of the original (or we hit the quality floor).
    const target = Math.round(originalSize * 0.25);
    let out = null;
    for (const quality of [80, 70, 60, 50, 40, 32]) {
      const buf = await sharp(original, { animated: isAnimated })
        .rotate() // respect EXIF orientation
        .resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true })
        .webp({ quality, effort: 4 })
        .toBuffer();
      out = buf;
      if (buf.length <= target) break;
    }
    // never write a file larger than the original
    const finalBuf = out.length < originalSize ? out : await sharp(original, { animated: isAnimated })
      .webp({ quality: 80 }).toBuffer();

    const filename = `${nanoid(12)}.webp`;

    let url, absoluteUrl;
    if (s3) {
      absoluteUrl = await putObject(filename, finalBuf, 'image/webp');
      url = absoluteUrl; // store the full bucket URL so the frontend loads it directly
    } else {
      fs.writeFileSync(path.join(uploadsDir, filename), finalBuf);
      url = `/uploads/${filename}`;
      absoluteUrl = `${PUBLIC_ORIGIN}${url}`;
    }

    res.json({
      url,
      absoluteUrl,
      originalSize,
      size: finalBuf.length,
      reduction: Math.round((1 - finalBuf.length / originalSize) * 100),
    });
  } catch (err) {
    console.error('[upload] webp conversion failed:', err.message);
    res.status(500).json({ error: 'conversion_failed' });
  }
});

// raw file upload (videos / pdf) → object storage. Requires S3 to be configured.
app.post('/api/uploads/raw', authRequired, uploadRaw.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'no_file' });
  if (!s3) return res.status(503).json({ error: 'object_storage_not_configured' });
  // validate by content (magic bytes); only real video/audio/pdf is accepted
  const kind = sniffKind(req.file.buffer);
  if (kind !== 'video' && kind !== 'audio' && kind !== 'pdf') return res.status(415).json({ error: 'invalid_media' });
  try {
    const ext = (req.file.originalname.match(/\.[a-z0-9]+$/i)?.[0] || '').toLowerCase();
    const key = `${nanoid(12)}${ext}`;
    const url = await putObject(key, req.file.buffer, req.file.mimetype);
    res.json({ url, absoluteUrl: url, size: req.file.size, contentType: req.file.mimetype });
  } catch (err) {
    console.error('[upload:raw] failed:', err.message);
    res.status(500).json({ error: 'upload_failed' });
  }
});

function normalizeArticleBody(b) {
  return {
    title: String(b.title || '').trim(),
    category: b.category || null,
    type: b.type || null,
    author: b.author || null,
    author_role: b.authorRole || null,
    author_initial: b.authorInitial || null,
    publish_date: b.publishDate || null,
    pages: Number(b.pages) || 0,
    price: Number(b.price) || 0,
    is_free: b.isFree ? 1 : 0,
    summary: b.summary || null,
    body: b.body || null,
    cover_image: b.coverImage || null,
    blocks: JSON.stringify(Array.isArray(b.blocks) ? b.blocks : []),
    sources: JSON.stringify(Array.isArray(b.sources) ? b.sources : []),
    tags: JSON.stringify(Array.isArray(b.tags) ? b.tags : []),
    featured: b.featured ? 1 : 0,
  };
}

app.post('/api/articles', authRequired, async (req, res) => {
  const a = normalizeArticleBody(req.body || {});
  if (!a.title) return res.status(400).json({ error: 'title_required' });
  const id = nanoid(14);
  await db.prepare(`
    INSERT INTO articles (
      id, title, category, type, author, author_role, author_initial,
      publish_date, pages, price, is_free, summary, body, cover_image,
      blocks, sources, tags, featured
    ) VALUES (
      @id, @title, @category, @type, @author, @author_role, @author_initial,
      @publish_date, @pages, @price, @is_free, @summary, @body, @cover_image,
      @blocks, @sources, @tags, @featured
    )
  `).run({ id, ...a });
  const row = await db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(id);
  res.status(201).json(rowToArticle(row));
});

app.put('/api/articles/:id', authRequired, async (req, res) => {
  const existing = await db.prepare('SELECT id FROM articles WHERE id = ?').get(req.params.id);
  if (!existing) return res.status(404).json({ error: 'not_found' });
  const a = normalizeArticleBody(req.body || {});
  if (!a.title) return res.status(400).json({ error: 'title_required' });
  await db.prepare(`
    UPDATE articles SET
      title = @title, category = @category, type = @type,
      author = @author, author_role = @author_role, author_initial = @author_initial,
      publish_date = @publish_date, pages = @pages, price = @price, is_free = @is_free,
      summary = @summary, body = @body, cover_image = @cover_image,
      blocks = @blocks, sources = @sources,
      tags = @tags, featured = @featured,
      updated_at = now()
    WHERE id = @id
  `).run({ id: req.params.id, ...a });
  const row = await db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id);
  res.json(rowToArticle(row));
});

app.delete('/api/articles/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM articles WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- bulk import (Gemini JSON) ----------
// Gemini grounding leaves [cite: 1921] / [cite: 1921, 1931] markers in the text — strip them
// (and the whitespace artifacts they leave behind) before anything hits the DB.
function stripCites(s) {
  return s
    .replace(/\[cite:[^\]]*\]/g, '')
    .replace(/[ \t]+([.،؛:!؟])/g, '$1')   // space before punctuation
    .replace(/[ \t]{2,}/g, ' ')             // runs of spaces
    .replace(/[ \t]+\n/g, '\n')             // trailing space before newline (keeps \n\n)
    .trim();
}
function deepSanitize(v) {
  if (typeof v === 'string') return stripCites(v);
  if (Array.isArray(v)) return v.map(deepSanitize);
  if (v && typeof v === 'object') {
    const o = {};
    for (const k of Object.keys(v)) o[k] = deepSanitize(v[k]);
    return o;
  }
  return v;
}

app.post('/api/admin/import', authRequired, async (req, res) => {
  const { report, radar_items = [], fa_summary_long, key_data } = deepSanitize(req.body || {});
  if (!report?.title) return res.status(400).json({ error: 'report.title required' });

  const results = { article: null, radar: [] };

  // upsert article
  const articleId = report.id || nanoid(14);
  const body = fa_summary_long
    ? (key_data ? fa_summary_long + '\n\n---KEY_DATA---\n' + JSON.stringify(key_data, null, 2) : fa_summary_long)
    : (key_data ? JSON.stringify(key_data, null, 2) : null);

  await db.prepare(`
    INSERT INTO articles
      (id, title, category, type, author, author_role, author_initial,
       publish_date, pages, price, is_free, summary, body, tags, cover_image, featured, status)
    VALUES
      (@id, @title, @category, @type, @author, @author_role, @author_initial,
       @publish_date, @pages, @price, @is_free, @summary, @body, @tags, @cover_image, @featured, 'published')
    ON CONFLICT (id) DO UPDATE SET
      title = EXCLUDED.title, category = EXCLUDED.category, type = EXCLUDED.type,
      author = EXCLUDED.author, author_role = EXCLUDED.author_role, author_initial = EXCLUDED.author_initial,
      publish_date = EXCLUDED.publish_date, pages = EXCLUDED.pages, price = EXCLUDED.price,
      is_free = EXCLUDED.is_free, summary = EXCLUDED.summary, body = EXCLUDED.body,
      tags = EXCLUDED.tags, cover_image = EXCLUDED.cover_image, featured = EXCLUDED.featured,
      status = EXCLUDED.status, updated_at = now()
  `).run({
    id: articleId,
    title: report.title,
    category: report.category || null,
    type: report.type || 'special',
    author: report.author || null,
    author_role: report.authorRole || report.author_role || null,
    author_initial: report.authorInitial || report.author_initial || null,
    publish_date: report.publishDate || report.publish_date || null,
    pages: Number(report.pages) || 0,
    price: Number(report.price) || 0,
    is_free: (report.isFree ?? report.is_free ?? false) ? 1 : 0,
    summary: report.summary || null,
    body,
    tags: JSON.stringify(Array.isArray(report.tags) ? report.tags : []),
    cover_image: report.coverImage || report.cover_image || null,
    featured: report.featured ? 1 : 0,
  });
  results.article = articleId;

  // upsert radar items
  const insertRadar = db.prepare(`
    INSERT INTO radar_items
      (id, category, img, title_fa, excerpt_fa, date_fa, author_name, sort_order)
    VALUES (@id, @category, @img, @title_fa, @excerpt_fa, @date_fa, @author_name, @sort_order)
    ON CONFLICT (id) DO UPDATE SET
      category = EXCLUDED.category, img = EXCLUDED.img, title_fa = EXCLUDED.title_fa,
      excerpt_fa = EXCLUDED.excerpt_fa, date_fa = EXCLUDED.date_fa, author_name = EXCLUDED.author_name,
      sort_order = EXCLUDED.sort_order, updated_at = now()
  `);
  for (let i = 0; i < radar_items.length; i++) {
    const item = radar_items[i];
    const rid = item.id || nanoid(12);
    await insertRadar.run({
      id: rid,
      category: item.category || 'market',
      img: item.img || item.image || report.coverImage || '',
      title_fa: item.title || '',
      excerpt_fa: item.summary || item.excerpt_fa || '',
      date_fa: item.date || '',
      author_name: report.author || '',
      sort_order: i,
    });
    results.radar.push(rid);
  }

  res.json({ ok: true, ...results });
});

// ---------- events ----------
const EVENT_FIELDS = `id, title_fa, title_en, date_fa, date_en, month_fa, month_en, year, type,
  location_fa, location_en, city_fa, city_en, description_fa, description_en,
  registration_open, sort_order, created_at, updated_at`;

app.get('/api/events', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 100, 500);
  const rows = await db.prepare(`SELECT ${EVENT_FIELDS} FROM events ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
  res.json(rows.map(rowToEvent));
});

app.get('/api/events/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToEvent(row));
});

function normalizeEventBody(b) {
  return {
    title_fa: String(b.titleFa || '').trim(),
    title_en: b.titleEn || null,
    date_fa: b.dateFa || null, date_en: b.dateEn || null,
    month_fa: b.monthFa || null, month_en: b.monthEn || null,
    year: b.year || null,
    type: ['conference','exhibition','seminar','international'].includes(b.type) ? b.type : null,
    location_fa: b.locationFa || null, location_en: b.locationEn || null,
    city_fa: b.cityFa || null, city_en: b.cityEn || null,
    description_fa: b.descriptionFa || null, description_en: b.descriptionEn || null,
    registration_open: b.registrationOpen ? 1 : 0,
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/events', authRequired, async (req, res) => {
  const e = normalizeEventBody(req.body || {});
  if (!e.title_fa) return res.status(400).json({ error: 'title_fa_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO events (id,title_fa,title_en,date_fa,date_en,month_fa,month_en,year,type,
    location_fa,location_en,city_fa,city_en,description_fa,description_en,registration_open,sort_order)
    VALUES (@id,@title_fa,@title_en,@date_fa,@date_en,@month_fa,@month_en,@year,@type,
    @location_fa,@location_en,@city_fa,@city_en,@description_fa,@description_en,@registration_open,@sort_order)
  `).run({ id, ...e });
  res.status(201).json(rowToEvent(await db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(id)));
});

app.put('/api/events/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const e = normalizeEventBody(req.body || {});
  if (!e.title_fa) return res.status(400).json({ error: 'title_fa_required' });
  await db.prepare(`UPDATE events SET title_fa=@title_fa,title_en=@title_en,date_fa=@date_fa,date_en=@date_en,
    month_fa=@month_fa,month_en=@month_en,year=@year,type=@type,location_fa=@location_fa,location_en=@location_en,
    city_fa=@city_fa,city_en=@city_en,description_fa=@description_fa,description_en=@description_en,
    registration_open=@registration_open,sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...e });
  res.json(rowToEvent(await db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/events/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- radar ----------
const RADAR_FIELDS = `id, category, img, author_name, author_avatar,
  title_fa, date_fa, excerpt_fa, body_fa, analysis_fa,
  title_en, date_en, excerpt_en, body_en, analysis_en,
  tags, source, status, sort_order, created_at, updated_at`;

// public: only published items
app.get('/api/radar', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 50, 500);
  const rows = await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE status = 'published' ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
  res.json(rows.map(rowToRadarItem));
});

// admin: all items incl. drafts (for the CMS list + review)
app.get('/api/admin/radar', authRequired, async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 200, 500);
  const rows = await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
  res.json(rows.map(rowToRadarItem));
});

app.get('/api/radar/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToRadarItem(row));
});

function normalizeRadarBody(b) {
  return {
    category: ['market','tech','commodity','geo','energy'].includes(b.category) ? b.category : null,
    img: b.img || null,
    author_name: b.authorName || null,
    author_avatar: b.authorAvatar || null,
    title_fa: String(b.titleFa || '').trim(),
    date_fa: b.dateFa || null,
    excerpt_fa: b.excerptFa || null,
    body_fa: b.bodyFa || null,
    analysis_fa: b.analysisFa || null,
    title_en: b.titleEn || null,
    date_en: b.dateEn || null,
    excerpt_en: b.excerptEn || null,
    body_en: b.bodyEn || null,
    analysis_en: b.analysisEn || null,
    tags: JSON.stringify(Array.isArray(b.tags) ? b.tags : []),
    source: b.source || null,
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/radar', authRequired, async (req, res) => {
  const r = normalizeRadarBody(req.body || {});
  if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
  const id = nanoid(14);
  const status = req.body?.status === 'draft' ? 'draft' : 'published';
  await db.prepare(`INSERT INTO radar_items (id,category,img,author_name,author_avatar,
    title_fa,date_fa,excerpt_fa,body_fa,analysis_fa,
    title_en,date_en,excerpt_en,body_en,analysis_en,tags,source,status,sort_order)
    VALUES (@id,@category,@img,@author_name,@author_avatar,
    @title_fa,@date_fa,@excerpt_fa,@body_fa,@analysis_fa,
    @title_en,@date_en,@excerpt_en,@body_en,@analysis_en,@tags,@source,@status,@sort_order)
  `).run({ id, status, ...r });
  res.status(201).json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(id)));
});

app.put('/api/radar/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM radar_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const r = normalizeRadarBody(req.body || {});
  if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
  await db.prepare(`UPDATE radar_items SET category=@category,img=@img,author_name=@author_name,
    author_avatar=@author_avatar,title_fa=@title_fa,date_fa=@date_fa,excerpt_fa=@excerpt_fa,
    body_fa=@body_fa,analysis_fa=@analysis_fa,
    title_en=@title_en,date_en=@date_en,excerpt_en=@excerpt_en,
    body_en=@body_en,analysis_en=@analysis_en,
    tags=@tags,source=@source,
    sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...r });
  res.json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/radar/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM radar_items WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// publish / unpublish a radar item (draft <-> published)
app.post('/api/radar/:id/publish', authRequired, async (req, res) => {
  const info = await db.prepare("UPDATE radar_items SET status='published', updated_at=now() WHERE id=?").run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id)));
});

app.post('/api/radar/:id/unpublish', authRequired, async (req, res) => {
  const info = await db.prepare("UPDATE radar_items SET status='draft', updated_at=now() WHERE id=?").run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.json(rowToRadarItem(await db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id)));
});

// ---------- radar pages (per-category hero + banner) ----------
const RADAR_PAGE_FIELDS = `slug, source_categories, label_fa, label_en,
  latest_heading_fa, latest_heading_en, featured_title_fa, featured_title_en,
  featured_date_fa, featured_date_en, featured_img,
  banner_kicker_fa, banner_kicker_en, banner_title_fa, banner_title_en,
  banner_desc_fa, banner_desc_en, banner_book_img,
  sort_order, created_at, updated_at`;

app.get('/api/radar-pages', async (req, res) => {
  const rows = await db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages ORDER BY sort_order ASC`).all();
  res.json(rows.map(rowToRadarPage));
});

app.get('/api/radar-pages/:slug', async (req, res) => {
  const row = await db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(req.params.slug);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToRadarPage(row));
});

function normalizeRadarPageBody(b) {
  return {
    source_categories: JSON.stringify(Array.isArray(b.sourceCategories) ? b.sourceCategories : []),
    label_fa: String(b.labelFa || '').trim(),
    label_en: b.labelEn || null,
    latest_heading_fa: b.latestHeadingFa || null,
    latest_heading_en: b.latestHeadingEn || null,
    featured_title_fa: b.featuredTitleFa || null,
    featured_title_en: b.featuredTitleEn || null,
    featured_date_fa: b.featuredDateFa || null,
    featured_date_en: b.featuredDateEn || null,
    featured_img: b.featuredImg || null,
    banner_kicker_fa: b.bannerKickerFa || null,
    banner_kicker_en: b.bannerKickerEn || null,
    banner_title_fa: b.bannerTitleFa || null,
    banner_title_en: b.bannerTitleEn || null,
    banner_desc_fa: b.bannerDescFa || null,
    banner_desc_en: b.bannerDescEn || null,
    banner_book_img: b.bannerBookImg || null,
    sort_order: Number(b.sortOrder) || 0,
  };
}

// upsert by slug (slug is the stable key for the 5 fixed pages)
app.put('/api/radar-pages/:slug', authRequired, async (req, res) => {
  const slug = req.params.slug;
  const r = normalizeRadarPageBody(req.body || {});
  if (!r.label_fa) return res.status(400).json({ error: 'label_fa_required' });
  const exists = await db.prepare('SELECT slug FROM radar_pages WHERE slug = ?').get(slug);
  if (exists) {
    await db.prepare(`UPDATE radar_pages SET source_categories=@source_categories,label_fa=@label_fa,label_en=@label_en,
      latest_heading_fa=@latest_heading_fa,latest_heading_en=@latest_heading_en,
      featured_title_fa=@featured_title_fa,featured_title_en=@featured_title_en,
      featured_date_fa=@featured_date_fa,featured_date_en=@featured_date_en,featured_img=@featured_img,
      banner_kicker_fa=@banner_kicker_fa,banner_kicker_en=@banner_kicker_en,
      banner_title_fa=@banner_title_fa,banner_title_en=@banner_title_en,
      banner_desc_fa=@banner_desc_fa,banner_desc_en=@banner_desc_en,banner_book_img=@banner_book_img,
      sort_order=@sort_order,updated_at=now() WHERE slug=@slug
    `).run({ slug, ...r });
  } else {
    await db.prepare(`INSERT INTO radar_pages (slug,source_categories,label_fa,label_en,
      latest_heading_fa,latest_heading_en,featured_title_fa,featured_title_en,
      featured_date_fa,featured_date_en,featured_img,banner_kicker_fa,banner_kicker_en,
      banner_title_fa,banner_title_en,banner_desc_fa,banner_desc_en,banner_book_img,sort_order)
      VALUES (@slug,@source_categories,@label_fa,@label_en,@latest_heading_fa,@latest_heading_en,
      @featured_title_fa,@featured_title_en,@featured_date_fa,@featured_date_en,@featured_img,
      @banner_kicker_fa,@banner_kicker_en,@banner_title_fa,@banner_title_en,
      @banner_desc_fa,@banner_desc_en,@banner_book_img,@sort_order)
    `).run({ slug, ...r });
  }
  res.json(rowToRadarPage(await db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(slug)));
});

// ---------- factory reports ----------
const FACTORY_REPORT_FIELDS = `id, img, tag_fa, tag_en, title_fa, title_en, date_fa, date_en,
  excerpt_fa, excerpt_en, file_url, scroll_dir, sort_order, created_at, updated_at`;

app.get('/api/factory-reports', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 50, 200);
  const rows = await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
  res.json(rows.map(rowToFactoryReport));
});

app.get('/api/factory-reports/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToFactoryReport(row));
});

function normalizeFactoryReportBody(b) {
  return {
    img: b.img || null,
    tag_fa: b.tagFa || null, tag_en: b.tagEn || null,
    title_fa: String(b.titleFa || '').trim(),
    title_en: b.titleEn || null,
    date_fa: b.dateFa || null, date_en: b.dateEn || null,
    excerpt_fa: b.excerptFa || null, excerpt_en: b.excerptEn || null,
    file_url: b.fileUrl || null,
    scroll_dir: ['left','right','up'].includes(b.scrollDir) ? b.scrollDir : 'up',
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/factory-reports', authRequired, async (req, res) => {
  const r = normalizeFactoryReportBody(req.body || {});
  if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO factory_reports (id,img,tag_fa,tag_en,title_fa,title_en,date_fa,date_en,
    excerpt_fa,excerpt_en,file_url,scroll_dir,sort_order)
    VALUES (@id,@img,@tag_fa,@tag_en,@title_fa,@title_en,@date_fa,@date_en,
    @excerpt_fa,@excerpt_en,@file_url,@scroll_dir,@sort_order)
  `).run({ id, ...r });
  res.status(201).json(rowToFactoryReport(await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(id)));
});

app.put('/api/factory-reports/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM factory_reports WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const r = normalizeFactoryReportBody(req.body || {});
  if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
  await db.prepare(`UPDATE factory_reports SET img=@img,tag_fa=@tag_fa,tag_en=@tag_en,title_fa=@title_fa,title_en=@title_en,
    date_fa=@date_fa,date_en=@date_en,excerpt_fa=@excerpt_fa,excerpt_en=@excerpt_en,
    file_url=@file_url,scroll_dir=@scroll_dir,sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...r });
  res.json(rowToFactoryReport(await db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/factory-reports/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM factory_reports WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- integrations ----------
const INTEGRATION_FIELDS = `id, name, icon, logo, accent, grid_col, grid_row, sort_order, created_at, updated_at`;
const INTEGRATION_ICONS = ['exchange','globe','chart','activity','database','newspaper','building','trending','coins'];

app.get('/api/integrations', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 50, 200);
  const rows = await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
  res.json(rows.map(rowToIntegration));
});

app.get('/api/integrations/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToIntegration(row));
});

function normalizeIntegrationBody(b) {
  return {
    name: String(b.name || '').trim(),
    icon: INTEGRATION_ICONS.includes(b.icon) ? b.icon : 'globe',
    logo: b.logo || null,
    accent: b.accent || null,
    grid_col: Number(b.gridCol) || 1,
    grid_row: Number(b.gridRow) || 1,
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/integrations', authRequired, async (req, res) => {
  const it = normalizeIntegrationBody(req.body || {});
  if (!it.name) return res.status(400).json({ error: 'name_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO integrations (id,name,icon,logo,accent,grid_col,grid_row,sort_order)
    VALUES (@id,@name,@icon,@logo,@accent,@grid_col,@grid_row,@sort_order)
  `).run({ id, ...it });
  res.status(201).json(rowToIntegration(await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(id)));
});

app.put('/api/integrations/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM integrations WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const it = normalizeIntegrationBody(req.body || {});
  if (!it.name) return res.status(400).json({ error: 'name_required' });
  await db.prepare(`UPDATE integrations SET name=@name,icon=@icon,logo=@logo,accent=@accent,
    grid_col=@grid_col,grid_row=@grid_row,sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...it });
  res.json(rowToIntegration(await db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/integrations/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM integrations WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- institute stats ----------
const INSTITUTE_STATS_FIELDS = `id,label_fa,label_en,value,suffix_fa,sort_order,created_at,updated_at`;

app.get('/api/institute-stats', async (_req, res) => {
  res.json((await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats ORDER BY sort_order ASC`).all()).map(rowToInstituteStat));
});

app.get('/api/institute-stats/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToInstituteStat(row));
});

function normalizeInstituteStatBody(b) {
  return {
    label_fa: String(b.labelFa || '').trim(),
    label_en: b.labelEn || null,
    value: Number(b.value) || 0,
    suffix_fa: b.suffixFa || null,
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/institute-stats', authRequired, async (req, res) => {
  const s = normalizeInstituteStatBody(req.body || {});
  if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO institute_stats (id,label_fa,label_en,value,suffix_fa,sort_order)
    VALUES (@id,@label_fa,@label_en,@value,@suffix_fa,@sort_order)
  `).run({ id, ...s });
  res.status(201).json(rowToInstituteStat(await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(id)));
});

app.put('/api/institute-stats/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM institute_stats WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const s = normalizeInstituteStatBody(req.body || {});
  if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' });
  await db.prepare(`UPDATE institute_stats SET label_fa=@label_fa,label_en=@label_en,value=@value,
    suffix_fa=@suffix_fa,sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...s });
  res.json(rowToInstituteStat(await db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/institute-stats/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM institute_stats WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- market prices ----------
const MARKET_PRICE_FIELDS = `id,name,value,unit,change,change_percent,trend,sort_order,created_at,updated_at`;

app.get('/api/market-prices', async (_req, res) => {
  res.json((await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices ORDER BY sort_order ASC`).all()).map(rowToMarketPrice));
});

app.get('/api/market-prices/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToMarketPrice(row));
});

function normalizeMarketPriceBody(b) {
  return {
    name: String(b.name || '').trim(),
    value: Number(b.value) || 0,
    unit: b.unit || null,
    change: Number(b.change) || 0,
    change_percent: Number(b.changePercent) || 0,
    trend: ['up','down','flat'].includes(b.trend) ? b.trend : 'flat',
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/market-prices', authRequired, async (req, res) => {
  const p = normalizeMarketPriceBody(req.body || {});
  if (!p.name) return res.status(400).json({ error: 'name_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO market_prices (id,name,value,unit,change,change_percent,trend,sort_order)
    VALUES (@id,@name,@value,@unit,@change,@change_percent,@trend,@sort_order)
  `).run({ id, ...p });
  res.status(201).json(rowToMarketPrice(await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(id)));
});

app.put('/api/market-prices/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM market_prices WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const p = normalizeMarketPriceBody(req.body || {});
  if (!p.name) return res.status(400).json({ error: 'name_required' });
  await db.prepare(`UPDATE market_prices SET name=@name,value=@value,unit=@unit,change=@change,
    change_percent=@change_percent,trend=@trend,sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...p });
  res.json(rowToMarketPrice(await db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/market-prices/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM market_prices WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- market chart points (history | export | comparison) ----------
const CHART_POINT_FIELDS = `id,series,label,value,meta,sort_order,created_at,updated_at`;
const ALLOWED_SERIES = new Set(['history','export','comparison']);

app.get('/api/market-chart-points', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 200, 1000);
  const series = ALLOWED_SERIES.has(req.query.series) ? req.query.series : null;
  let q = `SELECT ${CHART_POINT_FIELDS} FROM market_chart_points`;
  const params = [];
  if (series) { q += ' WHERE series = ?'; params.push(series); }
  q += ' ORDER BY series ASC, sort_order ASC LIMIT ?';
  params.push(limit);
  res.json((await db.prepare(q).all(...params)).map(rowToMarketChartPoint));
});

app.get('/api/market-chart-points/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToMarketChartPoint(row));
});

function normalizeChartPointBody(b) {
  let meta = null;
  if (b.meta != null && b.meta !== '') {
    meta = typeof b.meta === 'string' ? b.meta : JSON.stringify(b.meta);
  }
  return {
    series: ALLOWED_SERIES.has(b.series) ? b.series : null,
    label: String(b.label || '').trim(),
    value: Number(b.value) || 0,
    meta,
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/market-chart-points', authRequired, async (req, res) => {
  const c = normalizeChartPointBody(req.body || {});
  if (!c.series) return res.status(400).json({ error: 'series_required' });
  if (!c.label) return res.status(400).json({ error: 'label_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO market_chart_points (id,series,label,value,meta,sort_order)
    VALUES (@id,@series,@label,@value,@meta,@sort_order)
  `).run({ id, ...c });
  res.status(201).json(rowToMarketChartPoint(await db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(id)));
});

app.put('/api/market-chart-points/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM market_chart_points WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const c = normalizeChartPointBody(req.body || {});
  if (!c.series) return res.status(400).json({ error: 'series_required' });
  if (!c.label) return res.status(400).json({ error: 'label_required' });
  await db.prepare(`UPDATE market_chart_points SET series=@series,label=@label,value=@value,meta=@meta,
    sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...c });
  res.json(rowToMarketChartPoint(await db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/market-chart-points/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM market_chart_points WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- vision items (About page) ----------
const VISION_FIELDS = `id, icon, title_fa, title_en, desc_fa, desc_en, sort_order, created_at, updated_at`;

app.get('/api/vision-items', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 100, 500);
  const rows = await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
  res.json(rows.map(rowToVisionItem));
});

app.get('/api/vision-items/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToVisionItem(row));
});

function normalizeVisionBody(b) {
  return {
    icon: b.icon || null,
    title_fa: String(b.titleFa || '').trim(),
    title_en: b.titleEn || null,
    desc_fa: b.descFa || null,
    desc_en: b.descEn || null,
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/vision-items', authRequired, async (req, res) => {
  const v = normalizeVisionBody(req.body || {});
  if (!v.title_fa) return res.status(400).json({ error: 'title_fa_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO vision_items (id,icon,title_fa,title_en,desc_fa,desc_en,sort_order)
    VALUES (@id,@icon,@title_fa,@title_en,@desc_fa,@desc_en,@sort_order)
  `).run({ id, ...v });
  res.status(201).json(rowToVisionItem(await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(id)));
});

app.put('/api/vision-items/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM vision_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const v = normalizeVisionBody(req.body || {});
  if (!v.title_fa) return res.status(400).json({ error: 'title_fa_required' });
  await db.prepare(`UPDATE vision_items SET icon=@icon,title_fa=@title_fa,title_en=@title_en,
    desc_fa=@desc_fa,desc_en=@desc_en,sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...v });
  res.json(rowToVisionItem(await db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/vision-items/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM vision_items WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- advisory board (About page) ----------
const ADVISORY_FIELDS = `id, name_fa, name_en, role_fa, role_en, sort_order, created_at, updated_at`;

app.get('/api/advisory-board', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 100, 500);
  const rows = await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
  res.json(rows.map(rowToAdvisoryMember));
});

app.get('/api/advisory-board/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToAdvisoryMember(row));
});

function normalizeAdvisoryBody(b) {
  return {
    name_fa: String(b.nameFa || '').trim(),
    name_en: b.nameEn || null,
    role_fa: b.roleFa || null,
    role_en: b.roleEn || null,
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/advisory-board', authRequired, async (req, res) => {
  const m = normalizeAdvisoryBody(req.body || {});
  if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO advisory_board (id,name_fa,name_en,role_fa,role_en,sort_order)
    VALUES (@id,@name_fa,@name_en,@role_fa,@role_en,@sort_order)
  `).run({ id, ...m });
  res.status(201).json(rowToAdvisoryMember(await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(id)));
});

app.put('/api/advisory-board/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM advisory_board WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const m = normalizeAdvisoryBody(req.body || {});
  if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
  await db.prepare(`UPDATE advisory_board SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa,
    role_en=@role_en,sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...m });
  res.json(rowToAdvisoryMember(await db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/advisory-board/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM advisory_board WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- team members & experts ----------
const TEAM_FIELDS = `id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo,
  report_count,is_expert,expert_room_fa,expert_room_en,telegram,linkedin,twitter,website,sort_order,created_at,updated_at`;

app.get('/api/team', async (req, res) => {
  const isExpert = req.query.expert === '1' ? 1 : req.query.expert === '0' ? 0 : null;
  let query = `SELECT ${TEAM_FIELDS} FROM team_members`;
  const params = [];
  if (isExpert !== null) { query += ' WHERE is_expert = ?'; params.push(isExpert); }
  query += ' ORDER BY sort_order ASC, created_at DESC';
  res.json((await db.prepare(query).all(...params)).map(rowToTeamMember));
});

app.get('/api/team/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToTeamMember(row));
});

function normalizeTeamBody(b) {
  return {
    name_fa: String(b.nameFa || '').trim(),
    name_en: b.nameEn || null,
    role_fa: b.roleFa || null, role_en: b.roleEn || null,
    bio_fa: b.bioFa || null, bio_en: b.bioEn || null,
    expertise: JSON.stringify(Array.isArray(b.expertise) ? b.expertise : []),
    email: b.email || null, initial: b.initial || null, photo: b.photo || null,
    report_count: Number(b.reportCount) || 0,
    is_expert: b.isExpert ? 1 : 0,
    expert_room_fa: b.expertRoomFa || null, expert_room_en: b.expertRoomEn || null,
    telegram: b.telegram || null, linkedin: b.linkedin || null,
    twitter: b.twitter || null, website: b.website || null,
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/team', authRequired, async (req, res) => {
  const m = normalizeTeamBody(req.body || {});
  if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO team_members (id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo,
    report_count,is_expert,expert_room_fa,expert_room_en,telegram,linkedin,twitter,website,sort_order)
    VALUES (@id,@name_fa,@name_en,@role_fa,@role_en,@bio_fa,@bio_en,@expertise,@email,@initial,@photo,
    @report_count,@is_expert,@expert_room_fa,@expert_room_en,@telegram,@linkedin,@twitter,@website,@sort_order)
  `).run({ id, ...m });
  res.status(201).json(rowToTeamMember(await db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(id)));
});

app.put('/api/team/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM team_members WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const m = normalizeTeamBody(req.body || {});
  if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
  await db.prepare(`UPDATE team_members SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa,role_en=@role_en,
    bio_fa=@bio_fa,bio_en=@bio_en,expertise=@expertise,email=@email,initial=@initial,photo=@photo,
    report_count=@report_count,is_expert=@is_expert,expert_room_fa=@expert_room_fa,expert_room_en=@expert_room_en,
    telegram=@telegram,linkedin=@linkedin,twitter=@twitter,website=@website,
    sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...m });
  res.json(rowToTeamMember(await db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/team/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM team_members WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- plans ----------
const PLAN_FIELDS = `id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order,created_at,updated_at`;

app.get('/api/plans', async (req, res) => {
  res.json((await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans ORDER BY sort_order ASC`).all()).map(rowToPlan));
});

app.get('/api/plans/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToPlan(row));
});

function normalizePlanBody(b) {
  return {
    name_fa: String(b.nameFa || '').trim(),
    name_en: b.nameEn || null,
    price: Number(b.price) || 0,
    period_fa: b.periodFa || null, period_en: b.periodEn || null,
    features: JSON.stringify(Array.isArray(b.features) ? b.features : []),
    badge_fa: b.badgeFa || null, badge_en: b.badgeEn || null,
    is_featured: b.isFeatured ? 1 : 0,
    cta_fa: b.ctaFa || null, cta_en: b.ctaEn || null,
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/plans', authRequired, async (req, res) => {
  const p = normalizePlanBody(req.body || {});
  if (!p.name_fa) return res.status(400).json({ error: 'name_fa_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO plans (id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order)
    VALUES (@id,@name_fa,@name_en,@price,@period_fa,@period_en,@features,@badge_fa,@badge_en,@is_featured,@cta_fa,@cta_en,@sort_order)
  `).run({ id, ...p });
  res.status(201).json(rowToPlan(await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(id)));
});

app.put('/api/plans/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM plans WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const p = normalizePlanBody(req.body || {});
  if (!p.name_fa) return res.status(400).json({ error: 'name_fa_required' });
  await db.prepare(`UPDATE plans SET name_fa=@name_fa,name_en=@name_en,price=@price,period_fa=@period_fa,period_en=@period_en,
    features=@features,badge_fa=@badge_fa,badge_en=@badge_en,is_featured=@is_featured,cta_fa=@cta_fa,cta_en=@cta_en,
    sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...p });
  res.json(rowToPlan(await db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/plans/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM plans WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- risk signals ----------
const RISK_FIELDS = `id, quote, name, date, level, sort_order, created_at, updated_at`;

app.get('/api/risks', async (req, res) => {
  const limit = Math.min(Number(req.query.limit) || 100, 500);
  const rows = await db
    .prepare(`SELECT ${RISK_FIELDS} FROM risk_signals ORDER BY sort_order ASC, created_at DESC LIMIT ?`)
    .all(limit);
  res.json(rows.map(rowToRiskSignal));
});

app.get('/api/risks/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToRiskSignal(row));
});

const ALLOWED_LEVELS = new Set(['critical', 'high', 'medium', 'low', 'opportunity']);
function normalizeRiskBody(b) {
  const level = ALLOWED_LEVELS.has(b.level) ? b.level : null;
  return {
    quote: String(b.quote || '').trim(),
    name: String(b.name || '').trim(),
    date: b.date || null,
    level,
    sort_order: Number(b.sortOrder) || 0,
  };
}

app.post('/api/risks', authRequired, async (req, res) => {
  const r = normalizeRiskBody(req.body || {});
  if (!r.quote || !r.name || !r.level) return res.status(400).json({ error: 'quote_name_level_required' });
  const id = nanoid(14);
  await db.prepare(`
    INSERT INTO risk_signals (id, quote, name, date, level, sort_order)
    VALUES (@id, @quote, @name, @date, @level, @sort_order)
  `).run({ id, ...r });
  const row = await db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(id);
  res.status(201).json(rowToRiskSignal(row));
});

app.put('/api/risks/:id', authRequired, async (req, res) => {
  const existing = await db.prepare('SELECT id FROM risk_signals WHERE id = ?').get(req.params.id);
  if (!existing) return res.status(404).json({ error: 'not_found' });
  const r = normalizeRiskBody(req.body || {});
  if (!r.quote || !r.name || !r.level) return res.status(400).json({ error: 'quote_name_level_required' });
  await db.prepare(`
    UPDATE risk_signals SET
      quote = @quote, name = @name, date = @date, level = @level,
      sort_order = @sort_order, updated_at = now()
    WHERE id = @id
  `).run({ id: req.params.id, ...r });
  const row = await db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id);
  res.json(rowToRiskSignal(row));
});

app.delete('/api/risks/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM risk_signals WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- users (admin management) ----------
// password_hash is NEVER returned to the client.
app.get('/api/users', authRequired, ownerRequired, async (_req, res) => {
  const rows = await db.prepare('SELECT id, username, role, created_at FROM users ORDER BY id LIMIT 1000').all();
  res.json(rows.map((r) => ({ id: r.id, username: r.username, role: r.role, createdAt: r.created_at })));
});

app.post('/api/users', authRequired, ownerRequired, async (req, res) => {
  const username = String(req.body?.username || '').trim();
  const password = String(req.body?.password || '');
  if (username.length < 3) return res.status(400).json({ error: 'username_too_short' });
  if (password.length < 8) return res.status(400).json({ error: 'password_too_short' });
  const exists = await db.prepare('SELECT 1 FROM users WHERE username = ?').get(username);
  if (exists) return res.status(409).json({ error: 'username_taken' });
  const hash = bcrypt.hashSync(password, 12);
  const role = req.body?.role === 'owner' ? 'owner' : 'admin';
  const info = await db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) RETURNING id').run(username, hash, role);
  res.status(201).json({ id: info.lastInsertRowid, username, role });
});

app.put('/api/users/:id', authRequired, async (req, res) => {
  const id = Number(req.params.id);
  // only an owner may edit other admins; a plain admin may edit only their own account
  if (req.user.role !== 'owner' && req.user.sub !== id) return res.status(403).json({ error: 'forbidden' });
  const row = await db.prepare('SELECT id, username FROM users WHERE id = ?').get(id);
  if (!row) return res.status(404).json({ error: 'not_found' });

  const username = req.body?.username != null ? String(req.body.username).trim() : row.username;
  const password = req.body?.password != null ? String(req.body.password) : '';
  if (username.length < 3) return res.status(400).json({ error: 'username_too_short' });
  if (username !== row.username) {
    const taken = await db.prepare('SELECT 1 FROM users WHERE username = ? AND id != ?').get(username, id);
    if (taken) return res.status(409).json({ error: 'username_taken' });
  }
  if (password) {
    if (password.length < 8) return res.status(400).json({ error: 'password_too_short' });
    const hash = bcrypt.hashSync(password, 12);
    await db.prepare('UPDATE users SET username = ?, password_hash = ?, token_version = COALESCE(token_version,0) + 1 WHERE id = ?').run(username, hash, id);
  } else {
    await db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username, id);
  }
  res.json({ id, username });
});

app.delete('/api/users/:id', authRequired, ownerRequired, async (req, res) => {
  const id = Number(req.params.id);
  if (req.user?.sub === id) return res.status(400).json({ error: 'cannot_delete_self' });
  const count = (await db.prepare('SELECT COUNT(*) AS n FROM users').get()).n;
  if (count <= 1) return res.status(400).json({ error: 'cannot_delete_last_user' });
  const info = await db.prepare('DELETE FROM users WHERE id = ?').run(id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- site members (phone/OTP users) — admin management ----------
app.get('/api/members', authRequired, async (_req, res) => {
  const rows = await db.prepare(`
    SELECT m.id, m.name, m.email, m.phone, m.is_active, m.created_at, m.last_login,
           (SELECT COUNT(*) FROM purchases p WHERE p.member_id = m.id) AS purchases
    FROM members m ORDER BY m.last_login DESC NULLS LAST, m.created_at DESC LIMIT 2000
  `).all();
  res.json(rows.map((r) => ({
    id: r.id, name: r.name, email: r.email, phone: r.phone,
    isActive: !!r.is_active, createdAt: r.created_at, lastLogin: r.last_login,
    purchases: Number(r.purchases) || 0,
  })));
});

// NOTE: :id is constrained to digits so "me" (and other word routes) fall through
// to the member-auth /api/members/me handler below instead of this admin route.
app.get('/api/members/:id(\\d+)', authRequired, async (req, res) => {
  const id = Number(req.params.id);
  const r = await db.prepare('SELECT id, name, email, phone, avatar, is_active, created_at, updated_at, last_login FROM members WHERE id = ?').get(id);
  if (!r) return res.status(404).json({ error: 'not_found' });
  const purchases = await db.prepare(`
    SELECT p.article_id, p.amount, p.purchased_at, a.title
    FROM purchases p LEFT JOIN articles a ON a.id = p.article_id
    WHERE p.member_id = ? ORDER BY p.purchased_at DESC
  `).all(id);
  res.json({
    id: r.id, name: r.name, email: r.email, phone: r.phone, avatar: r.avatar,
    isActive: !!r.is_active, createdAt: r.created_at, updatedAt: r.updated_at, lastLogin: r.last_login,
    purchases: purchases.map((p) => ({ articleId: p.article_id, title: p.title, amount: p.amount, purchasedAt: p.purchased_at })),
  });
});

// block / unblock — body { isActive: boolean }
app.patch('/api/members/:id(\\d+)', authRequired, ownerRequired, jsonOnly, async (req, res) => {
  const id = Number(req.params.id);
  const isActive = req.body?.isActive ? 1 : 0;
  const info = await db.prepare('UPDATE members SET is_active = ?, updated_at = now() WHERE id = ?').run(isActive, id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.json({ id, isActive: !!isActive });
});

app.delete('/api/members/:id(\\d+)', authRequired, ownerRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM members WHERE id = ?').run(Number(req.params.id));
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// admin: recent activity (page views + clicks) for one member
app.get('/api/members/:id(\\d+)/activity', authRequired, async (req, res) => {
  const rows = await db.prepare(
    'SELECT kind, path, label, created_at FROM member_activity WHERE member_id = ? ORDER BY created_at DESC LIMIT 200'
  ).all(Number(req.params.id));
  res.json(rows.map((r) => ({ kind: r.kind, path: r.path, label: r.label, at: r.created_at })));
});

// ---------- newsletter subscribers ----------
// public: footer signup. dedupes silently on repeat email.
app.post('/api/newsletter', contactLimiter, async (req, res) => {
  const email = String(req.body?.email || '').trim().toLowerCase();
  if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ error: 'ایمیل نامعتبر است' });
  await db.prepare('INSERT INTO subscribers (email) VALUES (?) ON CONFLICT (email) DO NOTHING').run(email);
  res.status(201).json({ ok: true });
});

// admin: list subscribers
app.get('/api/newsletter', authRequired, async (_req, res) => {
  const rows = await db.prepare('SELECT id, email, created_at FROM subscribers ORDER BY created_at DESC LIMIT 5000').all();
  res.json(rows.map((r) => ({ id: r.id, email: r.email, createdAt: r.created_at })));
});

app.delete('/api/newsletter/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM subscribers WHERE id = ?').run(Number(req.params.id));
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

// ---------- banners ----------
const BANNER_FIELDS = `id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order,created_at,updated_at`;

app.get('/api/banners', async (req, res) => {
  const onlyActive = req.query.active === '1';
  let q = `SELECT ${BANNER_FIELDS} FROM banners`;
  if (onlyActive) q += ' WHERE active = 1';
  q += ' ORDER BY sort_order ASC, created_at DESC';
  res.json((await db.prepare(q).all()).map(rowToBanner));
});

app.get('/api/banners/:id', async (req, res) => {
  const row = await db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json(rowToBanner(row));
});

// accept only relative paths or absolute http(s) URLs; null out javascript:/data:/etc.
function safeHref(u) {
  const s = String(u || '').trim();
  if (!s) return null;
  if (s.startsWith('/')) return s;
  return /^https?:\/\//i.test(s) ? s : null;
}
const BANNER_POSITIONS = ['featured', 'glance', 'media-split', 'radar-sustainability', 'radar-geopolitics', 'radar-technology', 'radar-market'];

function normalizeBannerBody(b) {
  return {
    overline_fa:  b.overlineFa  || null,
    overline_en:  b.overlineEn  || null,
    title_fa:     String(b.titleFa || '').trim(),
    title_en:     b.titleEn     || null,
    subtitle_fa:  b.subtitleFa  || null,
    subtitle_en:  b.subtitleEn  || null,
    cta_label_fa: b.ctaLabelFa  || null,
    cta_label_en: b.ctaLabelEn  || null,
    cta_url:      safeHref(b.ctaUrl),
    image:        b.image       || null,
    position:     BANNER_POSITIONS.includes(b.position) ? b.position : 'hero',
    active:       b.active !== false ? 1 : 0,
    sort_order:   Number(b.sortOrder) || 0,
  };
}

app.post('/api/banners', authRequired, async (req, res) => {
  const bn = normalizeBannerBody(req.body || {});
  if (!bn.title_fa) return res.status(400).json({ error: 'title_fa_required' });
  const id = nanoid(14);
  await db.prepare(`INSERT INTO banners (id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order)
    VALUES (@id,@overline_fa,@overline_en,@title_fa,@title_en,@subtitle_fa,@subtitle_en,@cta_label_fa,@cta_label_en,@cta_url,@image,@position,@active,@sort_order)
  `).run({ id, ...bn });
  res.status(201).json(rowToBanner(await db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(id)));
});

app.put('/api/banners/:id', authRequired, async (req, res) => {
  if (!await db.prepare('SELECT id FROM banners WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
  const bn = normalizeBannerBody(req.body || {});
  if (!bn.title_fa) return res.status(400).json({ error: 'title_fa_required' });
  await db.prepare(`UPDATE banners SET overline_fa=@overline_fa,overline_en=@overline_en,title_fa=@title_fa,title_en=@title_en,subtitle_fa=@subtitle_fa,subtitle_en=@subtitle_en,
    cta_label_fa=@cta_label_fa,cta_label_en=@cta_label_en,cta_url=@cta_url,image=@image,
    position=@position,active=@active,sort_order=@sort_order,updated_at=now() WHERE id=@id
  `).run({ id: req.params.id, ...bn });
  res.json(rowToBanner(await db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id)));
});

app.delete('/api/banners/:id', authRequired, async (req, res) => {
  const info = await db.prepare('DELETE FROM banners WHERE id = ?').run(req.params.id);
  if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
  res.status(204).end();
});

app.get('/api/health', (_req, res) => res.json({ ok: true }));

/* ── Contact form → email ─────────────────────────────── */
import nodemailer from 'nodemailer';

function escHtml(s) {
  return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

app.post('/api/contact', contactLimiter, async (req, res) => {
  const name = String(req.body?.name || '').trim().slice(0, 200);
  const phone = String(req.body?.phone || '').trim().slice(0, 40);
  const email = String(req.body?.email || '').trim().slice(0, 254);
  if (!name || !phone) return res.status(400).json({ error: 'name and phone required' });

  const mailTo = process.env.MAIL_TO;
  if (!mailTo) {
    console.error('Mail error: MAIL_TO is not configured');
    return res.status(500).json({ error: 'mail failed' });
  }
  const transporter = nodemailer.createTransport({
    service: 'gmail',
    auth: { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS },
  });

  try {
    await transporter.sendMail({
      from: process.env.MAIL_USER,
      to: mailTo,
      subject: `درخواست دسترسی نبض صنعت — ${escHtml(name)}`,
      html: `<h2>درخواست دسترسی — نبض صنعت</h2>
             <p><strong>نام:</strong> ${escHtml(name)}</p>
             <p><strong>شماره همراه:</strong> ${escHtml(phone)}</p>
             <p><strong>ایمیل:</strong> ${escHtml(email) || '—'}</p>`,
    });
    res.json({ ok: true });
  } catch (err) {
    console.error('Mail error:', err.message);
    res.status(500).json({ error: 'mail failed' });
  }
});

// ---------- PDF analyser ----------

const SECTION_KEYWORDS = {
  market: {
    words: ['بازار','قیمت','صادرات','واردات','معامله','تجارت','فولاد','آهن','سنگ آهن','بلوم','تختال','کویل','عرضه','تقاضا','تولید','اقتصاد','market','price','trade','export','import','steel','iron ore','billet','slab','coil','supply','demand','production'],
    label: 'بازار و زنجیره فولاد',
  },
  technology: {
    words: ['فناوری','هوش مصنوعی','دیجیتال','اتوماسیون','نرم‌افزار','ربات','هوشمند','نوآوری','تکنولوژی','داده','technology','ai','digital','automation','software','robot','smart','innovation','iot','machine learning'],
    label: 'فناوری صنعتی',
  },
  geopolitics: {
    words: ['ژئوپلیتیک','تحریم','سیاست','دیپلماسی','جنگ','تنش','بین‌الملل','ژئواکونومی','قدرت','geopolitics','sanction','policy','diplomacy','war','tension','international','brics','conflict'],
    label: 'ژئواکونومی',
  },
  sustainability: {
    words: ['محیط زیست','کربن','پایداری','سبز','آلودگی','اقلیم','انرژی پاک','انتشار','تجدیدپذیر','esg','carbon','sustainability','environment','green','climate','emission','renewable','clean energy'],
    label: 'پایداری محیطی',
  },
  foresight: {
    words: ['آینده','سناریو','پیش‌بینی','روند','چشم‌انداز','آینده‌نگاری','استراتژی','رصد','تحلیل','future','scenario','forecast','trend','outlook','foresight','strategy','horizon','intelligence'],
    label: 'آینده‌نگاری',
  },
};

function classifyPdfText(text) {
  const lower = text.toLowerCase();
  const scores = {};
  for (const [cat, data] of Object.entries(SECTION_KEYWORDS)) {
    let score = 0; const matched = [];
    for (const kw of data.words) {
      const hits = (lower.match(new RegExp(kw.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
      if (hits > 0) { score += hits; matched.push(kw); }
    }
    scores[cat] = { score, matched, label: data.label };
  }
  const sorted = Object.entries(scores).sort((a, b) => b[1].score - a[1].score);
  const [topCat, topData] = sorted[0];
  const secondScore = sorted[1]?.[1].score || 0;
  const confidence = topData.score > 0 ? Math.min(100, Math.round(((topData.score - secondScore) / (topData.score + 1)) * 100 + 40)) : 0;
  return {
    category: topCat, label: topData.label, score: topData.score, confidence,
    keywords: [...new Set(topData.matched)].slice(0, 8),
    allScores: Object.fromEntries(sorted.map(([c, d]) => [c, { score: d.score, label: d.label }])),
  };
}

// PDF-upload → AI content generation was removed (feature dropped). This also
// eliminated the OpenAI/Anthropic SDKs, pdf-parse, and the prompt-injection surface.

// ──────────────────────────────────────────
// Member auth & profile  (public users, not admins)
// ──────────────────────────────────────────

app.post('/api/members/register', loginLimiter, async (req, res) => {
  const { name, email, password, phone } = req.body || {};
  if (!name || !email || !password) return res.status(400).json({ error: 'همه فیلدها الزامی است' });
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ error: 'ایمیل نامعتبر است' });
  if (password.length < 8) return res.status(400).json({ error: 'رمز عبور باید حداقل ۸ کاراکتر باشد' });
  const exists = await db.prepare('SELECT 1 FROM members WHERE email = ?').get(email);
  if (exists) return res.status(409).json({ error: 'این ایمیل قبلاً ثبت شده است' });
  const hash = bcrypt.hashSync(password, 12);
  const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id').run(name.trim(), email.toLowerCase().trim(), hash, phone ? String(phone).trim() : null);
  const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member', tv: 0 }, JWT_SECRET, { expiresIn: '30d' });
  res.cookie('member_session', token, memberCookieOpts(req));
  await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(info.lastInsertRowid);
  res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } });
});

app.post('/api/members/login', loginLimiter, async (req, res) => {
  const { email, phone, password } = req.body || {};
  const identifier = String(email || phone || '').trim();
  if (!identifier || !password) return res.status(400).json({ error: 'شماره موبایل یا ایمیل و رمز عبور الزامی است' });
  const lockKey = `member:${identifier.toLowerCase()}`;
  if (loginLocked(lockKey)) return res.status(429).json({ error: 'تعداد تلاش‌های ناموفق زیاد است؛ چند دقیقه دیگر دوباره تلاش کنید' });
  // accept either an email or a phone number as the login identifier
  const row = await db.prepare('SELECT id, name, email, password_hash, is_active, token_version FROM members WHERE email = ? OR phone = ?').get(identifier.toLowerCase(), identifier);
  // constant-time-ish: always run a compare so timing can't reveal whether the identifier exists
  const ok = bcrypt.compareSync(password, row?.password_hash || '$2a$10$0000000000000000000000000000000000000000000000000000');
  if (!row || !ok) { bumpLoginFail(lockKey); return res.status(401).json({ error: 'شماره موبایل/ایمیل یا رمز عبور اشتباه است' }); }
  if (!row.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
  clearLoginFail(lockKey);
  const token = jwt.sign({ sub: row.id, email: row.email, name: row.name, role: 'member', tv: row.token_version || 0 }, JWT_SECRET, { expiresIn: '30d' });
  res.cookie('member_session', token, memberCookieOpts(req));
  await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(row.id);
  res.json({ user: { id: row.id, name: row.name, email: row.email } });
});

app.post('/api/members/logout', memberRequired, async (req, res) => {
  // revoke the member JWT server-side (bump token_version) so a stolen 30-day token dies
  await db.prepare('UPDATE members SET token_version = COALESCE(token_version,0) + 1 WHERE id = ?').run(req.member.sub);
  res.clearCookie('member_session', { ...memberCookieOpts(req), maxAge: undefined });
  res.json({ ok: true });
});

// ── SSO hand-off to statista.steelforesight.ir ──
// The "ورود به سامانه جامع آمار و اطلاعات" button links here. Logged-in member →
// mint a 2-min HMAC token (shared SSO_SECRET) and bounce to statista's /api/sso,
// which logs them straight into the dashboard. Anyone else → statista landing.
const SSO_SECRET = process.env.SSO_SECRET;
const STATISTA_URL = (process.env.STATISTA_URL || 'https://statista.steelforesight.ir').replace(/\/+$/, '');
app.get('/api/sso/statista', async (req, res) => {
  const fail = () => res.redirect(`${STATISTA_URL}/`); // not a member → public landing
  if (!SSO_SECRET) return fail();
  const token = req.cookies?.member_session;
  if (!token) return fail();
  let p;
  try { p = jwt.verify(token, JWT_SECRET); } catch { return fail(); }
  if (p.role !== 'member') return fail();
  // honor deactivation / logout-revocation just like memberRequired does
  const acct = await db.prepare('SELECT is_active, token_version FROM members WHERE id = ?').get(p.sub);
  if (!acct || !acct.is_active || (acct.token_version || 0) !== (p.tv || 0)) return fail();
  // aud pins the token to statista; jti makes it single-use (statista records it);
  // 60s exp keeps the replay window tiny. All three are enforced on the consumer.
  const body = Buffer.from(JSON.stringify({
    email: p.email, name: p.name, aud: 'statista', jti: randomUUID(),
    exp: Math.floor(Date.now() / 1000) + 60,
  })).toString('base64url');
  const sig = createHmac('sha256', SSO_SECRET).update(body).digest('base64url');
  res.redirect(`${STATISTA_URL}/api/sso?t=${body}.${sig}`);
});

/* ── OTP login/signup via Kavenegar (verify/lookup) ──
   SFsignin → existing numbers, SFsignup → new numbers (auto-created on verify). */
const otpStore = new Map(); // phone -> { code, expires, attempts }
const otpSends = new Map();  // phone -> [timestamps] — per-phone send throttle (anti SMS-bombing)
const OTP_TTL = 5 * 60 * 1000;
const OTP_MAX_ATTEMPTS = 5;
// returns true if a new OTP may be sent to this phone (≤3 per 30 min), recording the send
function canSendOtp(phone) {
  const now = Date.now();
  const recent = (otpSends.get(phone) || []).filter((t) => now - t < 30 * 60 * 1000);
  if (recent.length >= 3) { otpSends.set(phone, recent); return false; }
  recent.push(now); otpSends.set(phone, recent);
  return true;
}
function newOtp() { return String(randomInt(100000, 1000000)); } // CSPRNG, not Math.random
// validate a submitted code with an attempt cap; deletes the record on success or exhaustion
function checkOtp(phone, code) {
  const rec = otpStore.get(phone);
  if (!rec || rec.expires < Date.now()) { otpStore.delete(phone); return false; }
  if (rec.attempts >= OTP_MAX_ATTEMPTS) { otpStore.delete(phone); return false; }
  rec.attempts += 1;
  if (rec.code !== String(code || '').trim()) return false;
  otpStore.delete(phone);
  return true;
}
async function sendOtpSms(phone, code, template) {
  const key = process.env.KAVENEGAR_API_KEY;
  if (!key) {
    // dev fallback — no SMS provider configured (local only). NEVER log the code or full phone.
    console.log(`[otp:dev] no KAVENEGAR_API_KEY — code generated for ...${phone.slice(-4)} (suppressed)`);
    return;
  }
  const url = `https://api.kavenegar.com/v1/${key}/verify/lookup.json?receptor=${encodeURIComponent(phone)}&token=${encodeURIComponent(code)}&template=${encodeURIComponent(template)}`;
  const r = await fetch(url);
  const j = await r.json().catch(() => null);
  if (!j || j.return?.status !== 200) throw new Error('sms_failed');
}

app.post('/api/members/otp/send', loginLimiter, async (req, res) => {
  const phone = String(req.body?.phone || '').trim();
  if (!/^09\d{9}$/.test(phone)) return res.status(400).json({ error: 'شماره موبایل معتبر وارد کنید' });
  if (!canSendOtp(phone)) return res.status(429).json({ error: 'تعداد درخواست پیامک بیش از حد مجاز است؛ کمی بعد تلاش کنید' });
  const existing = await db.prepare('SELECT id FROM members WHERE phone = ?').get(phone);
  const code = newOtp();
  otpStore.set(phone, { code, expires: Date.now() + OTP_TTL, attempts: 0 });
  try {
    await sendOtpSms(phone, code, existing ? 'SFsignin' : 'SFsignup');
    res.json({ ok: true, isNew: !existing });
  } catch (err) {
    console.error('[otp] send failed:', err.message);
    res.status(502).json({ error: 'ارسال پیامک ناموفق بود' });
  }
});

app.post('/api/members/otp/verify', loginLimiter, async (req, res) => {
  const phone = String(req.body?.phone || '').trim();
  const code = String(req.body?.code || '').trim();
  if (!checkOtp(phone, code)) return res.status(401).json({ error: 'کد نامعتبر یا منقضی است' });
  let member = await db.prepare('SELECT id, name, email, is_active, token_version FROM members WHERE phone = ?').get(phone);
  if (!member) {
    // auto-signup: create a member keyed by phone (random password, placeholder email)
    const hash = bcrypt.hashSync(nanoid(16), 12);
    const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id')
      .run('کاربر جدید', `${phone}@otp.fstt.ir`, hash, phone);
    member = { id: info.lastInsertRowid, name: 'کاربر جدید', email: `${phone}@otp.fstt.ir`, is_active: true, token_version: 0 };
  }
  if (!member.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
  const token = jwt.sign({ sub: member.id, email: member.email, name: member.name, role: 'member', tv: member.token_version || 0 }, JWT_SECRET, { expiresIn: '30d' });
  res.cookie('member_session', token, memberCookieOpts(req));
  await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(member.id);
  res.json({ user: { id: member.id, name: member.name, email: member.email } });
});

/* verify the SFsignup OTP code, then create the account with the real form data */
app.post('/api/members/register-verify', loginLimiter, async (req, res) => {
  const { name, email, password, code } = req.body || {};
  const phone = String(req.body?.phone || '').trim();
  if (!name || !email || !password || !phone) return res.status(400).json({ error: 'همه فیلدها الزامی است' });
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ error: 'ایمیل نامعتبر است' });
  if (password.length < 8) return res.status(400).json({ error: 'رمز عبور باید حداقل ۸ کاراکتر باشد' });
  if (!checkOtp(phone, code)) return res.status(401).json({ error: 'کد تأیید نامعتبر یا منقضی است' });
  if (await db.prepare('SELECT 1 FROM members WHERE email = ?').get(email.toLowerCase().trim())) return res.status(409).json({ error: 'این ایمیل قبلاً ثبت شده است' });
  if (await db.prepare('SELECT 1 FROM members WHERE phone = ?').get(phone)) return res.status(409).json({ error: 'این شماره موبایل قبلاً ثبت شده است' });
  const hash = bcrypt.hashSync(password, 12);
  let info;
  try {
    info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id')
      .run(name.trim(), email.toLowerCase().trim(), hash, phone);
  } catch (err) {
    // concurrent request won the UNIQUE(email)/UNIQUE(phone) race — report cleanly, not 500
    if (err.code === '23505') return res.status(409).json({ error: 'این ایمیل یا شماره موبایل قبلاً ثبت شده است' });
    throw err;
  }
  const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member', tv: 0 }, JWT_SECRET, { expiresIn: '30d' });
  res.cookie('member_session', token, memberCookieOpts(req));
  await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(info.lastInsertRowid);
  res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } });
});

/* activity beacon — logged-in members only. fire-and-forget from the site. */
app.post('/api/members/activity', memberRequired, jsonOnly, async (req, res) => {
  const kind = req.body?.kind === 'click' ? 'click' : 'view';
  const path = String(req.body?.path || '').slice(0, 300);
  const label = req.body?.label != null ? String(req.body.label).slice(0, 200) : null;
  await db.prepare('INSERT INTO member_activity (member_id, kind, path, label) VALUES (?, ?, ?, ?)')
    .run(req.member.sub, kind, path, label);
  res.status(204).end();
});

/* set password via SMS code — for members who don't know their current password (e.g. OTP signups) */
app.post('/api/members/me/password-otp/send', memberRequired, async (req, res) => {
  const row = await db.prepare('SELECT phone FROM members WHERE id = ?').get(req.member.sub);
  if (!row || !row.phone) return res.status(400).json({ error: 'برای این حساب شماره موبایلی ثبت نشده است' });
  if (!canSendOtp(row.phone)) return res.status(429).json({ error: 'تعداد درخواست پیامک بیش از حد مجاز است؛ کمی بعد تلاش کنید' });
  const code = newOtp();
  otpStore.set(row.phone, { code, expires: Date.now() + OTP_TTL, attempts: 0 });
  try { await sendOtpSms(row.phone, code, 'SFsignin'); res.json({ ok: true }); }
  catch (err) { console.error('[otp:pw] send failed:', err.message); res.status(502).json({ error: 'ارسال پیامک ناموفق بود' }); }
});

app.post('/api/members/me/password-otp/set', memberRequired, jsonOnly, async (req, res) => {
  const { code, newPassword } = req.body || {};
  if (!newPassword || String(newPassword).length < 8) return res.status(400).json({ error: 'رمز جدید باید حداقل ۸ کاراکتر باشد' });
  const row = await db.prepare('SELECT phone, email, name FROM members WHERE id = ?').get(req.member.sub);
  if (!row?.phone || !checkOtp(row.phone, code)) return res.status(401).json({ error: 'کد نامعتبر یا منقضی است' });
  const hash = bcrypt.hashSync(String(newPassword), 12);
  // bump token_version (revoke other sessions) and re-issue this session's cookie
  const upd = await db.prepare("UPDATE members SET password_hash = ?, token_version = COALESCE(token_version,0)+1, updated_at = now() WHERE id = ? RETURNING token_version").get(hash, req.member.sub);
  const token = jwt.sign({ sub: req.member.sub, email: row.email, name: row.name, role: 'member', tv: upd.token_version }, JWT_SECRET, { expiresIn: '30d' });
  res.cookie('member_session', token, memberCookieOpts(req));
  res.json({ ok: true });
});

app.get('/api/members/me', memberRequired, async (req, res) => {
  const row = await db.prepare('SELECT id, name, email, phone, avatar, created_at FROM members WHERE id = ?').get(req.member.sub);
  if (!row) return res.status(404).json({ error: 'not_found' });
  res.json({ id: row.id, name: row.name, email: row.email, phone: row.phone, avatar: row.avatar, createdAt: row.created_at });
});

app.put('/api/members/me', memberRequired, jsonOnly, async (req, res) => {
  const { name } = req.body || {};
  const row = await db.prepare('SELECT id FROM members WHERE id = ?').get(req.member.sub);
  if (!row) return res.status(404).json({ error: 'not_found' });
  const newName = String(name || '').trim().slice(0, 120) || req.member.name;
  // phone is an auth factor (OTP login) — it cannot be changed here, which blocks account/OTP hijacking
  await db.prepare("UPDATE members SET name = ?, updated_at = now() WHERE id = ?").run(newName, req.member.sub);
  res.json({ ok: true });
});

app.put('/api/members/me/password', memberRequired, jsonOnly, async (req, res) => {
  const { currentPassword, newPassword } = req.body || {};
  if (!currentPassword || !newPassword) return res.status(400).json({ error: 'فیلدها الزامی است' });
  if (newPassword.length < 8) return res.status(400).json({ error: 'رمز جدید باید حداقل ۸ کاراکتر باشد' });
  const row = await db.prepare('SELECT password_hash, email, name FROM members WHERE id = ?').get(req.member.sub);
  if (!row || !bcrypt.compareSync(currentPassword, row.password_hash)) return res.status(401).json({ error: 'رمز فعلی اشتباه است' });
  const hash = bcrypt.hashSync(newPassword, 12);
  const upd = await db.prepare("UPDATE members SET password_hash = ?, token_version = COALESCE(token_version,0)+1, updated_at = now() WHERE id = ? RETURNING token_version").get(hash, req.member.sub);
  const token = jwt.sign({ sub: req.member.sub, email: row.email, name: row.name, role: 'member', tv: upd.token_version }, JWT_SECRET, { expiresIn: '30d' });
  res.cookie('member_session', token, memberCookieOpts(req));
  res.json({ ok: true });
});

app.get('/api/members/me/purchases', memberRequired, async (req, res) => {
  const rows = await db.prepare(`
    SELECT p.id, p.amount, p.purchased_at,
           a.id AS article_id, a.title, a.category, a.type, a.cover_image, a.pages, a.summary, a.author, a.publish_date
    FROM purchases p JOIN articles a ON p.article_id = a.id
    WHERE p.member_id = ?
    ORDER BY p.purchased_at DESC
  `).all(req.member.sub);
  res.json(rows.map(r => ({
    id: r.id, amount: r.amount, purchasedAt: r.purchased_at,
    article: { id: r.article_id, title: r.title, category: r.category, type: r.type, coverImage: r.cover_image, pages: r.pages, summary: r.summary, author: r.author, publishDate: r.publish_date },
  })));
});

// admin: grant purchase to a member
app.post('/api/members/:memberId(\\d+)/purchases', authRequired, async (req, res) => {
  const { articleId, amount = 0 } = req.body || {};
  if (!articleId) return res.status(400).json({ error: 'articleId الزامی است' });
  const member = await db.prepare('SELECT id FROM members WHERE id = ?').get(req.params.memberId);
  if (!member) return res.status(404).json({ error: 'عضو پیدا نشد' });
  const article = await db.prepare('SELECT id, cover_image FROM articles WHERE id = ?').get(articleId);
  if (!article) return res.status(404).json({ error: 'مقاله پیدا نشد' });
  const info = await db.prepare('INSERT INTO purchases (member_id, article_id, amount) VALUES (?, ?, ?) ON CONFLICT (member_id, article_id) DO NOTHING').run(req.params.memberId, articleId, amount);
  if (info.changes === 0) return res.status(409).json({ error: 'قبلاً خریداری شده' });
  res.status(201).json({ ok: true });
});

// download purchased article file (cover image for now; swap for PDF path later)
// auth rides on the HttpOnly member_session cookie sent with the navigation — no token in the URL
app.get('/api/members/me/purchases/:purchaseId/download', memberRequired, async (req, res) => {
  const row = await db.prepare(`
    SELECT a.cover_image, a.title FROM purchases p JOIN articles a ON p.article_id = a.id
    WHERE p.id = ? AND p.member_id = ?
  `).get(req.params.purchaseId, req.member.sub);
  if (!row) return res.status(404).json({ error: 'خرید پیدا نشد' });
  if (!row.cover_image) return res.status(404).json({ error: 'فایل موجود نیست' });
  // object-storage assets are full URLs → redirect, but ONLY to our own media host (no open redirect)
  if (/^https?:\/\//i.test(row.cover_image)) {
    if (!row.cover_image.startsWith(MEDIA_BASE + '/')) return res.status(400).json({ error: 'invalid_file_location' });
    return res.redirect(row.cover_image);
  }
  const filePath = path.join(uploadsDir, path.basename(row.cover_image));
  if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'فایل موجود نیست' });
  res.download(filePath, row.title + path.extname(filePath));
});

// terminal error handler — never leak stack traces / SQL to the client
app.use((err, _req, res, _next) => {
  console.error(err);
  res.status(err.status || 500).json({ error: 'internal_error' });
});

app.listen(PORT, () => {
  console.log(`Panel running at ${PUBLIC_ORIGIN}`);
});
