// Copyright 2026 CRMy Contributors
// SPDX-License-Identifier: Apache-2.0

import { Router, type Request, type Response } from 'express';
import * as jose from 'jose';
import crypto from 'node:crypto';
import type { DbPool } from '../db/pool.js';
import { z } from 'zod';
import { authRegister, authLogin, apiKeyCreate, CrmyError } from '@crmy/shared';
import { emitEvent } from '../events/emitter.js';
import { authMiddleware } from './middleware.js';
import * as actorRepo from '../db/repos/actors.js';
import * as governorLimits from '../db/repos/governor-limits.js';
import { hashPassword, verifyPassword } from './password.js';
import { hashAuthToken } from '../services/auth-lifecycle.js';
import { assertCanGrantScopes, dedupeScopes, requireScopes } from './scopes.js';

// ── Simple in-memory rate limiter ────────────────────────────────────────────
// No external dependency. Keyed by "route:ip". Entries auto-expire.
// For multi-process / multi-instance deployments, use a shared store (Redis)
// instead — this protects single-instance deployments.

interface RateBucket {
  count: number;
  resetAt: number;
}
const rateBuckets = new Map<string, RateBucket>();

/** Returns true if the request is allowed, false if the limit is exceeded. */
function allowRequest(key: string, maxRequests: number, windowMs: number): boolean {
  const now = Date.now();
  const bucket = rateBuckets.get(key);
  if (!bucket || now >= bucket.resetAt) {
    rateBuckets.set(key, { count: 1, resetAt: now + windowMs });
    return true;
  }
  if (bucket.count >= maxRequests) return false;
  bucket.count++;
  return true;
}

// Purge expired buckets every 10 minutes to prevent unbounded memory growth
setInterval(() => {
  const now = Date.now();
  for (const [key, bucket] of rateBuckets) {
    if (now >= bucket.resetAt) rateBuckets.delete(key);
  }
}, 600_000).unref(); // .unref() so this timer won't keep the process alive

function clientIp(req: Request): string {
  return req.ip || req.socket.remoteAddress || 'unknown';
}

function handleAuthRouteError(res: Response, err: unknown, fallbackDetail: string): void {
  if (err instanceof CrmyError) {
    res.status(err.status).json(err.toJSON());
    return;
  }
  res.status(500).json({
    type: 'https://crmy.ai/errors/internal',
    title: 'Internal Error',
    status: 500,
    detail: fallbackDetail,
  });
}

function requireApiKeyAdmin(req: Request): void {
  const actor = req.actor!;
  if (actor.role !== 'owner' && actor.role !== 'admin') {
    throw new CrmyError('PERMISSION_DENIED', 'Owner or admin access is required to manage API keys', 403);
  }
  requireScopes(actor, 'api_keys:admin');
}

export function authRouter(
  db: DbPool,
  jwtSecret: string,
  options: { allowPublicRegistration?: boolean } = {},
): Router {
  const router = Router();
  const secret = new TextEncoder().encode(jwtSecret);

  // POST /auth/register
  router.post('/register', async (req: Request, res: Response) => {
    // Rate limit: 5 registrations per 15 minutes per IP
    if (!allowRequest(`register:${clientIp(req)}`, 5, 15 * 60 * 1000)) {
      res.status(429).json({
        type: 'https://crmy.ai/errors/rate_limited',
        title: 'Too Many Requests',
        status: 429,
        detail: 'Too many registration attempts. Please try again later.',
      });
      return;
    }

    let data: z.infer<typeof authRegister>;
    try {
      data = authRegister.parse(req.body);
    } catch (err: unknown) {
      if (err && typeof err === 'object' && 'name' in err && err.name === 'ZodError') {
        res.status(422).json({
          type: 'https://crmy.ai/errors/validation',
          title: 'Validation Error',
          status: 422,
          detail: 'Invalid input',
          errors: (err as unknown as { errors: unknown[] }).errors,
        });
        return;
      }
      throw err;
    }
    const passwordHash = hashPassword(data.password);
    const client = await db.connect();
    try {
      await client.query('BEGIN');
      await client.query('SELECT pg_advisory_xact_lock($1, $2)', [276207, 1]);

      const userCountResult = await client.query('SELECT COUNT(*)::int AS count FROM users');
      const hasUsers = Number(userCountResult.rows[0]?.count ?? 0) > 0;
      if (hasUsers && !options.allowPublicRegistration) {
        await client.query('ROLLBACK');
        res.status(403).json({
          type: 'https://crmy.ai/errors/registration_closed',
          title: 'Registration Closed',
          status: 403,
          detail: 'Registration is closed for this workspace. Ask an admin to invite or create your account.',
        });
        return;
      }

      const tenantResult = await client.query(
        `INSERT INTO tenants (slug, name) VALUES ($1, $2) RETURNING *`,
        [data.tenant_name.toLowerCase().replace(/[^a-z0-9-]/g, '-'), data.tenant_name],
      );
      const tenant = tenantResult.rows[0];

      const userResult = await client.query(
        `INSERT INTO users (tenant_id, email, name, role, password_hash)
         VALUES ($1, $2, $3, 'owner', $4) RETURNING *`,
        [tenant.id, data.email, data.name, passwordHash],
      );
      const user = userResult.rows[0];

      await client.query(
        `INSERT INTO actors (tenant_id, actor_type, display_name, email, user_id, role, registration_source, registration_status)
         VALUES ($1, 'human', $2, $3, $4, $5, 'admin', 'approved')
         ON CONFLICT (tenant_id, user_id) WHERE user_id IS NOT NULL DO NOTHING`,
        [tenant.id, user.name, user.email, user.id, user.role],
      );

      await client.query('COMMIT');

      // Emit event outside transaction (non-critical)
      await emitEvent(db, {
        tenantId: tenant.id,
        eventType: 'user.created',
        actorId: user.id,
        actorType: 'system',
        objectType: 'user',
        objectId: user.id,
        afterData: { id: user.id, email: user.email, name: user.name, role: user.role },
      });

      const token = await new jose.SignJWT({
        sub: user.id,
        tenant_id: tenant.id,
        role: user.role,
      })
        .setProtectedHeader({ alg: 'HS256' })
        .setIssuedAt()
        .setExpirationTime('8h')
        .sign(secret);

      res.status(201).json({
        token,
        user: { id: user.id, email: user.email, name: user.name, role: user.role, manager_id: user.manager_id, tenant_id: tenant.id },
      });
    } catch (err: unknown) {
      await client.query('ROLLBACK').catch(() => {});
      // Detect unique constraint violations without leaking PG internals
      const pgCode = (err as Record<string, unknown>)?.code;
      if (pgCode === '23505') {
        res.status(409).json({
          type: 'https://crmy.ai/errors/conflict',
          title: 'Conflict',
          status: 409,
          detail: 'An account with that email or tenant name already exists.',
        });
        return;
      }
      res.status(500).json({
        type: 'https://crmy.ai/errors/internal',
        title: 'Internal Error',
        status: 500,
        detail: 'Registration failed',
      });
    } finally {
      client.release();
    }
  });

  // POST /auth/login
  router.post('/login', async (req: Request, res: Response) => {
    // Rate limit: 10 attempts per 15 minutes per IP
    const ip = clientIp(req);
    if (!allowRequest(`login:${ip}`, 10, 15 * 60 * 1000)) {
      res.status(429).json({
        type: 'https://crmy.ai/errors/rate_limited',
        title: 'Too Many Requests',
        status: 429,
        detail: 'Too many login attempts. Please try again later.',
      });
      return;
    }

    try {
      const data = authLogin.parse(req.body);

      // Look up by email first — password verification happens in application
      // code using timingSafeEqual, not in SQL, to prevent timing oracles.
      const result = await db.query(
        'SELECT * FROM users WHERE email = $1',
        [data.email],
      );

      const user = result.rows[0];
      const passwordOk = user?.password_hash ? verifyPassword(data.password, user.password_hash) : false;

      if (!passwordOk) {
        res.status(401).json({
          type: 'https://crmy.ai/errors/unauthorized',
          title: 'Unauthorized',
          status: 401,
          detail: 'Invalid email or password',
        });
        return;
      }

      if (user.is_active === false) {
        res.status(403).json({
          type: 'https://crmy.ai/errors/permission_denied',
          title: 'Permission Denied',
          status: 403,
          detail: 'This user account is inactive',
        });
        return;
      }

      db.query('UPDATE users SET last_login_at = now() WHERE id = $1', [user.id]).catch(() => {});

      const token = await new jose.SignJWT({
        sub: user.id,
        tenant_id: user.tenant_id,
        role: user.role,
      })
        .setProtectedHeader({ alg: 'HS256' })
        .setIssuedAt()
        .setExpirationTime('8h')
        .sign(secret);

      res.json({
        token,
        user: { id: user.id, email: user.email, name: user.name, role: user.role, manager_id: user.manager_id, tenant_id: user.tenant_id },
      });
    } catch (err) {
      if (err && typeof err === 'object' && 'name' in err && err.name === 'ZodError') {
        res.status(422).json({
          type: 'https://crmy.ai/errors/validation',
          title: 'Validation Error',
          status: 422,
          detail: 'Invalid input',
        });
        return;
      }
      res.status(500).json({
        type: 'https://crmy.ai/errors/internal',
        title: 'Internal Error',
        status: 500,
        detail: 'Login failed',
      });
    }
  });

  // GET/POST /auth/setup/:token — one-time invite and password-reset completion
  router.get('/setup/:token', async (req: Request, res: Response) => {
    try {
      const token = typeof req.params.token === 'string' ? req.params.token : '';
      const result = await db.query(
        `SELECT t.id, t.token_type, t.expires_at, t.used_at,
                u.id as user_id, u.email, u.name, u.role, tenant.name as tenant_name
         FROM user_auth_tokens t
         JOIN users u ON u.id = t.user_id AND u.tenant_id = t.tenant_id
         JOIN tenants tenant ON tenant.id = t.tenant_id
         WHERE t.token_hash = $1`,
        [hashAuthToken(token)],
      );
      const row = result.rows[0];
      if (!row || row.used_at || new Date(row.expires_at) <= new Date()) {
        res.status(404).json({
          type: 'https://crmy.ai/errors/not_found',
          title: 'Not Found',
          status: 404,
          detail: 'Setup link is invalid or expired',
        });
        return;
      }
      res.json({
        token_type: row.token_type,
        expires_at: row.expires_at,
        user: { id: row.user_id, email: row.email, name: row.name, role: row.role },
        tenant_name: row.tenant_name,
      });
    } catch {
      res.status(500).json({ detail: 'Failed to validate setup link' });
    }
  });

  router.post('/setup/:token', async (req: Request, res: Response) => {
    const client = await db.connect();
    try {
      const token = typeof req.params.token === 'string' ? req.params.token : '';
      const { password } = req.body as { password?: string };
      if (!password || password.length < 12) {
        res.status(400).json({
          type: 'https://crmy.ai/errors/validation',
          title: 'Validation Error',
          status: 400,
          detail: 'Password must be at least 12 characters',
        });
        return;
      }

      await client.query('BEGIN');
      const tokenResult = await client.query(
        `SELECT t.*, u.email, u.name, u.role
         FROM user_auth_tokens t
         JOIN users u ON u.id = t.user_id AND u.tenant_id = t.tenant_id
         WHERE t.token_hash = $1
         FOR UPDATE`,
        [hashAuthToken(token)],
      );
      const row = tokenResult.rows[0];
      if (!row || row.used_at || new Date(row.expires_at) <= new Date()) {
        await client.query('ROLLBACK');
        res.status(404).json({
          type: 'https://crmy.ai/errors/not_found',
          title: 'Not Found',
          status: 404,
          detail: 'Setup link is invalid or expired',
        });
        return;
      }

      await client.query(
        `UPDATE users
         SET password_hash = $1, is_active = true, password_set_at = now(), updated_at = now()
         WHERE id = $2 AND tenant_id = $3`,
        [hashPassword(password), row.user_id, row.tenant_id],
      );
      await client.query(
        `UPDATE user_auth_tokens SET used_at = now(), updated_at = now() WHERE id = $1`,
        [row.id],
      );
      await client.query('COMMIT');

      await emitEvent(db, {
        tenantId: row.tenant_id,
        eventType: row.token_type === 'invite' ? 'user.invite_accepted' : 'user.password_reset_completed',
        actorId: row.user_id,
        actorType: 'user',
        objectType: 'user',
        objectId: row.user_id,
      });

      res.json({ success: true });
    } catch (err) {
      await client.query('ROLLBACK').catch(() => {});
      res.status(500).json({
        type: 'https://crmy.ai/errors/internal',
        title: 'Internal Error',
        status: 500,
        detail: 'Failed to complete setup',
      });
    } finally {
      client.release();
    }
  });

  // POST /auth/register-agent — Self-registration for agents with minimal scopes.
  // Requires an existing API key with 'write' scope for the tenant.
  const agentRegisterSchema = z.object({
    display_name: z.string().min(1).max(200),
    agent_identifier: z.string().min(1).max(200),
    agent_model: z.string().max(200).optional(),
    requested_scopes: z.array(z.string()).optional(),
  });

  const agentRegistration = Router();
  agentRegistration.use(authMiddleware(db, jwtSecret));

  agentRegistration.post('/register-agent', async (req: Request, res: Response) => {
    const client = await db.connect();
    try {
      const data = agentRegisterSchema.parse(req.body);
      const actor = req.actor!;
      requireScopes(actor, 'write');

      const minimalScopes = ['read']; // agents start read-only; admin grants more
      const rawKey = 'crmy_' + crypto.randomBytes(32).toString('hex');
      const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');

      await client.query('BEGIN');
      await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [`crmy:agent-register:${actor.tenant_id}`]);

      const actorCount = await client.query(
        `SELECT count(*)::int AS count
         FROM actors
         WHERE tenant_id = $1
           AND (is_active = TRUE OR registration_status = 'pending_review')`,
        [actor.tenant_id],
      );
      await governorLimits.enforceLimit(client, actor.tenant_id, 'actors_max', Number(actorCount.rows[0]?.count ?? 0));

      const existingActor = await client.query(
        `SELECT *
         FROM actors
         WHERE tenant_id = $1 AND agent_identifier = $2
         FOR UPDATE`,
        [actor.tenant_id, data.agent_identifier],
      );
      let agentActor = existingActor.rows[0];
      if (agentActor?.actor_type && agentActor.actor_type !== 'agent') {
        throw new CrmyError('CONFLICT', 'Agent identifier is already used by another actor', 409);
      }
      if (agentActor?.registration_status === 'rejected') {
        throw new CrmyError('PERMISSION_DENIED', 'This agent registration was rejected. Ask an admin to review it.', 403);
      }
      if (!agentActor) {
        const createdActor = await client.query(
          `INSERT INTO actors (tenant_id, actor_type, display_name, agent_identifier, agent_model, scopes,
                               metadata, is_active, registration_source, registration_status)
           VALUES ($1, 'agent', $2, $3, $4, $5, '{}'::jsonb, false, 'self_registered', 'pending_review')
           RETURNING *`,
          [actor.tenant_id, data.display_name, data.agent_identifier, data.agent_model ?? null, minimalScopes],
        );
        agentActor = createdActor.rows[0];
      }

      const keyResult = await client.query(
        `INSERT INTO api_keys (tenant_id, actor_id, key_hash, label, scopes, expires_at)
         VALUES ($1, $2, $3, $4, $5, $6)
         RETURNING id, label, scopes, actor_id, created_at`,
        [actor.tenant_id, agentActor.id, keyHash, `${data.display_name} auto`, minimalScopes, null],
      );

      await client.query('COMMIT');

      // Emit event outside transaction (non-critical)
      await emitEvent(db, {
        tenantId: actor.tenant_id,
        eventType: 'actor.self_registered',
        actorId: agentActor.id,
        actorType: 'agent',
        objectType: 'actor',
        objectId: agentActor.id,
        afterData: { display_name: data.display_name, agent_identifier: data.agent_identifier },
      });

      res.status(201).json({
        actor: agentActor,
        api_key: {
          ...keyResult.rows[0],
          key: rawKey, // Only returned once
        },
      });
    } catch (err: unknown) {
      await client.query('ROLLBACK').catch(() => {});
      if (err && typeof err === 'object' && 'name' in err && err.name === 'ZodError') {
        res.status(422).json({
          type: 'https://crmy.ai/errors/validation',
          title: 'Validation Error',
          status: 422,
          detail: 'Invalid input',
          errors: (err as unknown as { errors: unknown[] }).errors,
        });
        return;
      }
      if (err instanceof CrmyError) {
        res.status(err.status).json(err.toJSON());
        return;
      }
      res.status(500).json({
        type: 'https://crmy.ai/errors/internal',
        title: 'Internal Error',
        status: 500,
        detail: 'Agent registration failed',
      });
    } finally {
      client.release();
    }
  });

  router.use('/', agentRegistration);

  // Authenticated API key routes
  const authenticated = Router();
  authenticated.use(authMiddleware(db, jwtSecret));

  // POST /auth/api-keys
  authenticated.post('/api-keys', async (req: Request, res: Response) => {
    try {
      requireApiKeyAdmin(req);
      const data = apiKeyCreate.parse(req.body);
      const actor = req.actor!;
      const scopes = dedupeScopes(data.scopes);
      assertCanGrantScopes(actor, scopes);

      // Generate key
      const rawKey = 'crmy_' + crypto.randomBytes(32).toString('hex');
      const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');

      // If actor_id provided, verify it belongs to this tenant
      const actorId = data.actor_id ?? null;
      if (actorId) {
        const actorCheck = await db.query(
          'SELECT id FROM actors WHERE id = $1 AND tenant_id = $2',
          [actorId, actor.tenant_id],
        );
        if (actorCheck.rows.length === 0) {
          res.status(404).json({
            type: 'https://crmy.ai/errors/not_found',
            title: 'Not Found',
            status: 404,
            detail: 'Actor not found',
          });
          return;
        }
      }

      const result = await db.query(
        `INSERT INTO api_keys (tenant_id, user_id, actor_id, key_hash, label, scopes, expires_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7)
         RETURNING id, label, scopes, actor_id, created_at, expires_at`,
        [actor.tenant_id, actorId ? null : actor.actor_id, actorId, keyHash, data.label, scopes, data.expires_at ?? null],
      );

      await emitEvent(db, {
        tenantId: actor.tenant_id,
        eventType: 'api_key.created',
        actorId: actor.actor_id,
        actorType: actor.actor_type,
        objectType: 'api_key',
        objectId: result.rows[0].id,
        afterData: { label: data.label, scopes },
      });

      res.status(201).json({
        ...result.rows[0],
        key: rawKey, // Only returned once
      });
    } catch (err) {
      handleAuthRouteError(res, err, 'Failed to create API key');
    }
  });

  // GET /auth/api-keys
  authenticated.get('/api-keys', async (req: Request, res: Response) => {
    try {
      requireApiKeyAdmin(req);
      const actor = req.actor!;
      // Optionally filter by actor_id
      const actorFilter = req.query.actor_id as string | undefined;
      const params: unknown[] = [actor.tenant_id];
      let where = 'ak.tenant_id = $1';
      if (actorFilter) {
        where += ' AND ak.actor_id = $2';
        params.push(actorFilter);
      }
      const result = await db.query(
        `SELECT ak.id, ak.label, ak.scopes, ak.actor_id, ak.user_id,
                ak.last_used_at, ak.expires_at, ak.created_at,
                a.display_name as actor_name, a.actor_type as actor_type
         FROM api_keys ak
         LEFT JOIN actors a ON ak.actor_id = a.id
         WHERE ${where}
         ORDER BY ak.created_at DESC`,
        params,
      );
      res.json({ data: result.rows });
    } catch (err) {
      handleAuthRouteError(res, err, 'Failed to list API keys');
    }
  });

  // PATCH /auth/api-keys/:id
  authenticated.patch('/api-keys/:id', async (req: Request, res: Response) => {
    try {
      requireApiKeyAdmin(req);
      const actor = req.actor!;
      const keyId = typeof req.params.id === 'string' ? req.params.id : '';
      const { label, scopes, actor_id, expires_at } = req.body as {
        label?: string;
        scopes?: string[];
        actor_id?: string | null;
        expires_at?: string | null;
      };

      // Verify key belongs to this tenant
      const existing = await db.query(
        'SELECT id FROM api_keys WHERE id = $1 AND tenant_id = $2',
        [keyId, actor.tenant_id],
      );
      if (existing.rows.length === 0) {
        res.status(404).json({ detail: 'API key not found' });
        return;
      }

      // If actor_id provided, verify it belongs to this tenant
      if (actor_id) {
        const actorCheck = await db.query(
          'SELECT id FROM actors WHERE id = $1 AND tenant_id = $2',
          [actor_id, actor.tenant_id],
        );
        if (actorCheck.rows.length === 0) {
          res.status(404).json({ detail: 'Actor not found' });
          return;
        }
      }

      const sets: string[] = [];
      const params: unknown[] = [];
      let p = 1;
      if (label !== undefined) { sets.push(`label = $${p++}`); params.push(label); }
      if (scopes !== undefined) {
        const normalizedScopes = dedupeScopes(scopes);
        assertCanGrantScopes(actor, normalizedScopes);
        sets.push(`scopes = $${p++}`);
        params.push(normalizedScopes);
      }
      if ('actor_id' in req.body) { sets.push(`actor_id = $${p++}`); params.push(actor_id ?? null); }
      if ('expires_at' in req.body) { sets.push(`expires_at = $${p++}`); params.push(expires_at ? new Date(expires_at) : null); }

      if (sets.length === 0) {
        res.status(400).json({ detail: 'No fields to update' });
        return;
      }

      params.push(keyId, actor.tenant_id);
      const result = await db.query(
        `UPDATE api_keys SET ${sets.join(', ')} WHERE id = $${p} AND tenant_id = $${p + 1}
         RETURNING id, label, scopes, actor_id, expires_at`,
        params,
      );

      res.json(result.rows[0]);
    } catch (err) {
      handleAuthRouteError(res, err, 'Failed to update API key');
    }
  });

  // DELETE /auth/api-keys/:id
  authenticated.delete('/api-keys/:id', async (req: Request, res: Response) => {
    try {
      requireApiKeyAdmin(req);
      const actor = req.actor!;
      const keyId = typeof req.params.id === 'string' ? req.params.id : '';
      const result = await db.query(
        'DELETE FROM api_keys WHERE id = $1 AND tenant_id = $2 RETURNING id',
        [keyId, actor.tenant_id],
      );
      if (result.rows.length === 0) {
        res.status(404).json({
          type: 'https://crmy.ai/errors/not_found',
          title: 'Not Found',
          status: 404,
          detail: 'API key not found',
        });
        return;
      }

      await emitEvent(db, {
        tenantId: actor.tenant_id,
        eventType: 'api_key.revoked',
        actorId: actor.actor_id,
        actorType: actor.actor_type,
        objectType: 'api_key',
        objectId: keyId,
      });

      res.json({ deleted: true });
    } catch (err) {
      handleAuthRouteError(res, err, 'Failed to revoke API key');
    }
  });

  // PATCH /auth/profile — self-service profile update (any authenticated user)
  authenticated.patch('/profile', async (req: Request, res: Response) => {
    try {
      const actor = req.actor!;
      const { name, email, current_password, new_password } = req.body as {
        name?: string;
        email?: string;
        current_password?: string;
        new_password?: string;
      };

      // Fetch current user record
      const existing = await db.query(
        `SELECT u.*
         FROM users u
         LEFT JOIN actors a ON a.tenant_id = u.tenant_id AND a.user_id = u.id
         WHERE u.tenant_id = $1
           AND (u.id::text = $2 OR a.id::text = $2)
         LIMIT 1`,
        [actor.tenant_id, actor.actor_id],
      );
      if (existing.rows.length === 0) {
        res.status(404).json({ detail: 'User not found' });
        return;
      }
      const user = existing.rows[0];

      // If changing password, verify current password first
      if (new_password) {
        if (!current_password) {
          res.status(400).json({ detail: 'Current password is required to set a new password' });
          return;
        }
        if (!verifyPassword(current_password, user.password_hash)) {
          res.status(400).json({ detail: 'Current password is incorrect' });
          return;
        }
        if (new_password.length < 12) {
          res.status(400).json({ detail: 'New password must be at least 12 characters' });
          return;
        }
      }

      if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
        res.status(400).json({ detail: 'Valid email address is required' });
        return;
      }

      const cols: string[] = ['updated_at = now()'];
      const vals: unknown[] = [];
      if (name?.trim()) { cols.push(`name = $${vals.length + 1}`); vals.push(name.trim()); }
      if (email?.trim()) { cols.push(`email = $${vals.length + 1}`); vals.push(email.trim()); }
      if (new_password) {
        cols.push(`password_hash = $${vals.length + 1}`);
        vals.push(hashPassword(new_password));
      }

      if (cols.length === 1) {
        res.status(400).json({ detail: 'No fields to update' });
        return;
      }

      vals.push(user.id, actor.tenant_id);
      const result = await db.query(
        `UPDATE users SET ${cols.join(', ')}
         WHERE id = $${vals.length - 1} AND tenant_id = $${vals.length}
         RETURNING id, email, name, role, created_at, updated_at`,
        vals,
      );

      // Also sync display_name on the linked actor
      if (name?.trim()) {
        await db.query(
          'UPDATE actors SET display_name = $1, updated_at = now() WHERE user_id = $2 AND tenant_id = $3',
          [name.trim(), user.id, actor.tenant_id],
        );
      }

      res.json(result.rows[0]);
    } catch {
      res.status(500).json({ detail: 'Failed to update profile' });
    }
  });

  router.use('/', authenticated);

  return router;
}
