import { FastifyInstance } from 'fastify';
import { createClient, RedisClientType } from 'redis';
import { randomUUID } from 'node:crypto';
import { DbService } from '../services/db.service.js';
import { SessionDirector } from '../services/session-director.service.js';
import { Candidate } from '../services/generators.service.js';

interface ActivePlan {
  sessionId: string;
  plan: Candidate[];
  seedTrackId: string | null;
  /** Tracks handed to the player during this Redis-backed session. */
  servedTrackIds?: string[];
  /** Explicit feedback targets (skip, dislike, completion, promotion). */
  excludedTrackIds?: string[];
}

const PLAN_TTL_SEC = 2 * 3600;
// Retain enough history for long listening sessions without allowing an
// unbounded Redis value if a client leaves a session running for days.
const MAX_SESSION_EXCLUSIONS = 1000;

function planKey(userId: string): string {
  return `v2:plan:${userId}`;
}

function appendUniqueId(ids: string[] | undefined, id: string): string[] {
  const next = ids ? [...ids] : [];
  if (!next.includes(id)) next.push(id);
  return next.length > MAX_SESSION_EXCLUSIONS
    ? next.slice(next.length - MAX_SESSION_EXCLUSIONS)
    : next;
}

function sessionExclusions(active: ActivePlan): string[] {
  return [...new Set([
    ...(active.servedTrackIds ?? []),
    ...(active.excludedTrackIds ?? []),
  ])];
}

export default async function v2Routes(fastify: FastifyInstance, options: { dbService: DbService; sessionDirector: SessionDirector }) {
  const { dbService, sessionDirector: director } = options;

  const redisClient: RedisClientType = createClient({
    url: process.env.REDIS_URL || 'redis://localhost:6379',
  });
  await redisClient.connect();
  fastify.addHook('onClose', async () => { await redisClient.quit(); });

  async function getActivePlan(userId: string): Promise<ActivePlan | null> {
    const raw = await redisClient.get(planKey(userId));
    if (!raw) return null;
    return JSON.parse(raw) as ActivePlan;
  }

  async function setActivePlan(userId: string, plan: ActivePlan): Promise<void> {
    await redisClient.setEx(planKey(userId), PLAN_TTL_SEC, JSON.stringify(plan));
  }

  async function delActivePlan(userId: string): Promise<void> {
    await redisClient.del(planKey(userId));
  }

  // ponytail: per-user SET NX PX lock around the getActivePlan->mutate->setActivePlan
  // span, released via a CAS Lua script so a slow holder can't delete a lock a later
  // request already owns. Ceiling: this serializes /next and /feedback per-user only
  // (fine — plans aren't shared across users); if plan mutation logic ever needs to
  // span multiple keys/services atomically, swap this for a WATCH/MULTI transaction
  // or move the plan into a single Lua script instead of an app-level lock.
  const RELEASE_LOCK_LUA = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
  const RENEW_LOCK_LUA = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("pexpire", KEYS[1], ARGV[2]) else return 0 end`;

  async function withPlanLock<T>(userId: string, fn: () => Promise<T>): Promise<T> {
    const lockKey = `v2:planlock:${userId}`;
    const token = randomUUID();
    const deadline = Date.now() + 5000;
    let acquired = false;
    while (Date.now() < deadline) {
      const res = await redisClient.set(lockKey, token, { NX: true, PX: 5000 });
      if (res) { acquired = true; break; }
      await new Promise((r) => setTimeout(r, 20 + Math.random() * 30));
    }
    if (!acquired) {
      throw new Error('Timed out waiting for active-plan lock');
    }
    const renewal = setInterval(() => {
      void redisClient.eval(RENEW_LOCK_LUA, { keys: [lockKey], arguments: [token, '5000'] });
    }, 1500);
    try {
      return await fn();
    } finally {
      clearInterval(renewal);
      await redisClient.eval(RELEASE_LOCK_LUA, { keys: [lockKey], arguments: [token] });
    }
  }

  /**
   * POST /api/v2/vibe/start — start a v2 session
   * Body: { seedTrackId? }
   * Returns: { sessionId, plan: Candidate[] }
   */
  fastify.post('/v2/vibe/start', async (request, reply) => {
    const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
    const { seedTrackId } = request.body as { seedTrackId?: string };

    const sessionId = await dbService.createSessionState(userId, undefined, { energy: 0.5, novelty_hunger: 0.3 });
    const initialExclusions = seedTrackId ? [seedTrackId] : [];
    const plan = await director.buildPlan(userId, sessionId, seedTrackId, {
      excludedTrackIds: initialExclusions,
    });

    await setActivePlan(userId, {
      sessionId,
      plan,
      seedTrackId: seedTrackId ?? null,
      servedTrackIds: [],
      excludedTrackIds: initialExclusions,
    });
    return reply.send({ sessionId, plan: plan.slice(0, 10) });
  });

  /**
   * GET /api/v2/vibe/next — get next track from the plan
   * Returns: { track, planRemaining }
   */
  fastify.get('/v2/vibe/next', async (request, reply) => {
    const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
    const { sessionId } = request.query as { sessionId?: string };

    const result = await withPlanLock(userId, async () => {
      const active = await getActivePlan(userId);

      if (!active) {
        return { kind: 'missing' as const };
      }
      if (!sessionId || active.sessionId !== sessionId) {
        return { kind: 'replaced' as const };
      }
      if (active.plan.length === 0) {
        return { kind: 'exhausted' as const };
      }

      const next = active.plan.shift()!;
      active.servedTrackIds = appendUniqueId(active.servedTrackIds, next.trackId);
      // Enrich with track details
      const track = await dbService.getTrackById(next.trackId);

      // Replan if running low
      if (active.plan.length < 5) {
        const refill = await director.replan(
          userId,
          active.sessionId,
          active.plan,
          [next.trackId],
          active.seedTrackId ?? undefined,
          { excludedTrackIds: sessionExclusions(active) }
        );
        active.plan = refill;
      }

      await setActivePlan(userId, active);

      return { kind: 'track' as const, track, explanation: next.explanation, planRemaining: active.plan.length };
    });

    if (result.kind === 'missing') {
      return reply.code(404).send({ error: 'No active plan. POST /api/v2/vibe/start first.' });
    }
    if (result.kind === 'replaced') {
      return reply.code(409).send({ error: 'This Vibe session was replaced by a newer session.', code: 'VIBE_SESSION_REPLACED' });
    }
    if (result.kind === 'exhausted') {
      return reply.code(409).send({
        error: 'Vibe plan exhausted: no eligible unserved tracks remain for this session.',
        code: 'VIBE_PLAN_EXHAUSTED',
      });
    }

    return reply.send({
      track: result.track,
      explanation: result.explanation,
      planRemaining: result.planRemaining,
    });
  });

  /**
   * POST /api/v2/vibe/feedback — feedback that triggers replan
   * Body: { trackId, action: 'completed' | 'skipped' | 'promoted' | 'disliked' }
   */
  fastify.post('/v2/vibe/feedback', async (request, reply) => {
    const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
    const { trackId, action, sessionId } = request.body as { trackId: string; action: string; sessionId?: string };

    if (!trackId || !action) {
      return reply.code(400).send({ error: 'trackId and action are required' });
    }
    if (!['completed', 'skipped', 'promoted', 'disliked'].includes(action)) {
      return reply.code(400).send({ error: 'Unsupported Vibe feedback action' });
    }

    // Route to existing handlers for evidence wiring
    if (action === 'completed') {
      await dbService.recordPlay(userId, trackId, true);
    } else if (action === 'skipped') {
      await dbService.recordSkip(userId, trackId);
    } else if (action === 'promoted') {
      await dbService.addFavorite(userId, trackId);
      await dbService.recordFeedback(userId, trackId, 'promoted');
    } else if (action === 'disliked') {
      await dbService.dislikeTrack(userId, trackId);
    }

    // Replan the session
    const planRemaining = await withPlanLock(userId, async () => {
      const active = await getActivePlan(userId);
      if (!active) return 0;
      if (!sessionId || active.sessionId !== sessionId) return 0;
      // Feedback may race /next or arrive after a client-side prefetch. In all
      // cases its track becomes ineligible for the rest of this session.
      active.excludedTrackIds = appendUniqueId(active.excludedTrackIds, trackId);
      const sessionTrackIds = sessionExclusions(active);
      const refill = await director.replan(
        userId,
        active.sessionId,
        active.plan,
        [trackId],
        active.seedTrackId ?? undefined,
        { excludedTrackIds: sessionTrackIds }
      );
      active.plan = refill;
      await setActivePlan(userId, active);
      return active.plan.length;
    });

    return reply.send({ status: 'ok', planRemaining });
  });

  /**
   * GET /api/v2/vibe/plan — current plan for debugging
   */
  fastify.get('/v2/vibe/plan', async (request, reply) => {
    const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
    const active = await getActivePlan(userId);
    if (!active) return reply.send({ plan: [] });
    return reply.send({ sessionId: active.sessionId, planRemaining: active.plan.length, plan: active.plan });
  });

  /**
   * GET /api/v2/state — current listener state (debugging)
   */
  fastify.get('/v2/state', async (request, reply) => {
    const userId = (request.headers['x-user-id'] as string) || '00000000-0000-0000-0000-000000000000';
    const state = await director.buildState(userId);
    const fatigue = await director.computeFatigue(userId);
    const budgets = await director.getBudgets(userId);
    return reply.send({ state, fatigue: Object.fromEntries(
      Object.entries(fatigue).map(([k, v]) => [k, v instanceof Map ? Object.fromEntries(v) : v])
    ), budgets });
  });
}
