import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
import { SessionDirector } from './session-director.service.js';
import { Candidate, VibeCalendarContext } from './generators.service.js';

/**
 * This is deliberately a narrow bridge between the durable Vibe ledger and
 * the current deterministic director. It writes authoritative revisions and
 * lets the playback client replace only its unserved preview after feedback.
 */
export const DEFAULT_VIBE_POLICY_VERSION = 'vibe-v2-initial';

export const VIBE_EVENT_TYPES = [
  'session_started', 'session_resumed', 'session_ended',
  'plan_published', 'track_served', 'playback_started', 'progress', 'completed',
  'skipped', 'disliked', 'kept', 'favourite_added', 'queue_removed',
  'manual_search', 'album_opened', 'artist_opened', 'playlist_added',
  'track_replayed', 'volume_changed', 'playback_error',
] as const;

export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number];

export interface StartVibeSessionInput {
  seedTrackId?: string;
  resumeSessionId?: string;
  context?: VibeCalendarContext;
}

export interface AppendVibeEventInput {
  eventId?: string;
  type: VibeEventType;
  trackId?: string;
  occurredAt?: Date;
  positionMs?: number;
  durationMs?: number;
  payload?: Record<string, unknown>;
}

/**
 * An explicit advancement protocol for a plan item that was durably served
 * but cannot be played locally. `eventId` is the idempotency key for this
 * state transition; it is intentionally separate from a retry of /next.
 */
export interface AdvanceUnplayableVibeItemInput {
  expectedPlanVersion: number;
  planVersionId: string;
  ordinal: number;
  trackId: string;
  eventId: string;
}

/**
 * How much of the plan a client is shown and holds ready. The plan itself is
 * PLAN_SIZE long and stays that way; this is only the served window. Every
 * preview item costs the client one track fetch on every advance, and a replan
 * discards whatever is still unplayed, so a long window buys little beyond a
 * longer Up next list.
 */
const PREVIEW_SIZE = 3;

export class VibeSessionNotFoundError extends Error {}
export class VibeSessionLifecycleError extends Error {}
export class VibePlanNotFoundError extends Error {}

export interface VibeSessionResponse {
  session: VibeSession;
  sessionId: string;
  planVersion: number | null;
  now: VibePlan['items'][number] | null;
  preview: VibePlan['items'];
  state: Record<string, unknown>;
  replanned: boolean;
  replanReason: string | null;
}

export class VibeSessionCoordinator {
  constructor(
    private readonly db: DbService,
    private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState' | 'replan'>,
  ) {}

  async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
    if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId);
    // Calendar context is a weak boot prior, never a substitute for listening
    // feedback or long-term preference. It is intentionally coarse and is
    // persisted with the session so a resume remains coherent.
    const calendar = input.context;
    const contextualEnergy = calendar && calendar.localHour < 6 ? 0.32
      : calendar && calendar.localHour >= 18 && calendar.localHour < 23 ? 0.58
        : 0.5;
    const initialState = {
      energy: contextualEnergy,
      noveltyHunger: 0.3,
      explorationCoefficient: 0.3,
      discoveryRadius: 0.38,
      sessionGoal: { type: 'discovery' as const, target: 1, progress: 0 },
    };
    const session = await this.db.createVibeSession({
      userId,
      policyVersion: DEFAULT_VIBE_POLICY_VERSION,
      seedTrackId: input.seedTrackId ?? null,
      context: calendar ? { ...calendar } : undefined,
      profile: {
        goals: initialState.sessionGoal,
        explorationCoefficient: initialState.explorationCoefficient,
        discoveryRadius: initialState.discoveryRadius,
      },
    });

    // session_state is a derived cache used by the current director. Give it
    // the durable ID so director state cannot accidentally bleed into another
    // session while the durable tables remain the source of truth.
    await this.db.createSessionState(
      userId,
      calendar ? JSON.stringify(calendar) : undefined,
      {
        energy: initialState.energy,
        noveltyHunger: initialState.noveltyHunger,
        explorationCoefficient: initialState.explorationCoefficient,
        discoveryRadius: initialState.discoveryRadius,
        sessionGoal: initialState.sessionGoal,
      },
      session.id,
    );
    await this.db.recordVibeEvent({
      sessionId: session.id,
      userId,
      type: 'session_started',
      payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION, ...(calendar ? { calendar } : {}) },
    });

    const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId);
    const state = await this.director.buildState(userId, session.id);
    let plan: VibePlan;
    try {
      plan = await this.db.publishVibePlan({
        sessionId: session.id,
        userId,
        version: 1,
        reason: 'session_started',
        stateSnapshot: state,
        objectiveSnapshot: {
          policyVersion: DEFAULT_VIBE_POLICY_VERSION,
          horizonTracks: candidates.length,
          ...(candidates[0]?.plan?.objective ?? {}),
        },
        items: candidates.map((candidate, ordinal) => ({
          ordinal,
          track_id: candidate.trackId,
          slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
          candidate_source: candidate.generatorId,
          score: candidate.plan?.score ?? candidate.relevance,
          score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
          explanation: candidate.plan
            ? { paths: candidate.explanation, planner: candidate.plan.explanation }
            : candidate.explanation,
          committed: false,
        })),
      });
    } catch (error) {
      throw this.mapLifecycleError(error);
    }

    return this.toResponse(session, plan, state);
  }

  async getPlan(userId: string, sessionId: string, version?: number): Promise<VibeSessionResponse> {
    const session = await this.requireSession(userId, sessionId);
    const plan = await this.db.getVibePlan(sessionId, userId, version);
    if (!plan) throw new VibePlanNotFoundError(
      version === undefined ? 'Vibe session does not have a published plan' : 'Vibe plan revision was not found',
    );
    return this.toResponse(session, plan, plan?.state_snapshot ?? {});
  }

  async serveNext(userId: string, sessionId: string, expectedPlanVersion?: number): Promise<VibeSessionResponse> {
    try {
      const served = expectedPlanVersion === undefined
        ? await this.db.serveNextVibePlanItem(sessionId, userId)
        : await this.db.serveNextVibePlanItem(sessionId, userId, expectedPlanVersion);
      const response = await this.getPlan(userId, sessionId);
      // A plan may be replaced between the client's preview and this request.
      // In that case the database does not commit anything and this is the
      // current, revisable preview the client must reconcile to.
      if (served.stale) return response;
      return { ...response, now: served.item, preview: response.preview };
    } catch (error) {
      throw this.mapLifecycleError(error);
    }
  }

  async advancePastUnplayable(
    userId: string,
    sessionId: string,
    input: AdvanceUnplayableVibeItemInput,
  ): Promise<VibeSessionResponse> {
    try {
      const served = await this.db.advancePastUnplayableVibePlanItem(sessionId, userId, input);
      const response = await this.getPlan(userId, sessionId);
      if (served.stale) return response;
      return { ...response, now: served.item, preview: response.preview };
    } catch (error) {
      throw this.mapLifecycleError(error);
    }
  }

  async appendEvent(
    userId: string,
    sessionId: string,
    input: AppendVibeEventInput,
  ): Promise<VibeSessionResponse & { event: VibeEvent; idempotent: boolean }> {
    try {
      // Do this before the ledger write, rather than after it, because Vibe
      // events are immutable. The DB repeats this boundary for non-HTTP
      // callers; keeping it here also makes coordinator callers see exactly
      // what will be persisted.
      const result = await this.db.recordVibeEvent({
        sessionId,
        userId,
        clientEventId: input.eventId,
        type: input.type,
        trackId: input.trackId,
        occurredAt: input.occurredAt,
        positionMs: input.positionMs,
        durationMs: input.durationMs,
        payload: input.payload,
      });
      // The ledger write is authoritative; this idempotent projection updates
      // exploration only after the exact event exists. Keep the compatibility
      // guard for old coordinator test doubles during the migration.
      const projectSessionFeedback = (this.db as Partial<DbService>).projectVibeSessionFeedback;
      if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event);
      // A dislike in a Vibe is the same verdict as a dislike anywhere else. The
      // ledger alone only excludes the track from this one session, which is
      // why a disliked track kept coming back the next evening. Run it once per
      // distinct event so a retried delivery cannot log a second feedback row.
      if (input.type === 'disliked' && input.trackId && result.inserted) {
        const dislikeTrack = (this.db as Partial<DbService>).dislikeTrack;
        if (dislikeTrack) await dislikeTrack.call(this.db, userId, input.trackId);
      }
      if (!isMaterialFeedback(input.type)) {
        const response = await this.getPlan(userId, sessionId);
        return { ...response, event: result.event, idempotent: !result.inserted };
      }

      // A material event is durable before its computed replacement can be
      // written. If planning/persistence failed after that event committed, a
      // retry must finish the missing replacement instead of permanently
      // returning an obsolete preview. Once a replacement exists, a duplicate
      // retry returns that canonical revision without doing work again.
      if (!result.inserted) {
        const existingReplacement = await this.db.getVibePlanForFeedbackEvent(sessionId, userId, result.event.id);
        if (existingReplacement) {
          const session = await this.requireSession(userId, sessionId);
          return {
            ...this.toResponse(session, existingReplacement, existingReplacement.state_snapshot),
            event: result.event,
            idempotent: true,
          };
        }
      }

      const session = await this.requireSession(userId, sessionId);
      const state = await this.director.buildState(userId, sessionId);
      // A feedback target is useful as the local replan anchor, but it must
      // never displace the durable seed from the exclusion boundary. Unlike
      // feedback tracks, the seed is not necessarily present in the event
      // ledger, so carry it explicitly into every replacement request.
      const seedTrackId = session.seed_track_id ?? undefined;
      // Revisions retain the durable, unserved queue tail rather than building
      // an unrelated plan after every signal. Besides reducing churn, this
      // preserves a valid callback/recovery pair that has already been shown
      // to the client while allowing the director to refill under the same
      // hard caps and current feedback state.
      const current = await this.db.getVibePlan(sessionId, userId);
      const retained = current ? this.unservedCandidates(current) : [];
      const excludedTrackIds = new Set<string>([
        ...(seedTrackId ? [seedTrackId] : []),
      ]);
      const candidates = await this.director.replan(
        userId,
        sessionId,
        retained,
        input.trackId ? [input.trackId] : [],
        input.trackId ?? seedTrackId,
        { excludedTrackIds },
      );
      const reason = `feedback:${input.type}`;
      const plan = await this.db.publishVibePlan({
        sessionId,
        userId,
        reason,
        stateSnapshot: state,
        objectiveSnapshot: {
          policyVersion: session.policy_version,
          feedbackEventId: result.event.id,
          feedbackType: input.type,
          horizonTracks: candidates.length,
          ...(candidates[0]?.plan?.objective ?? {}),
        },
        items: candidates.map((candidate, ordinal) => ({
          ordinal,
          track_id: candidate.trackId,
          slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
          candidate_source: candidate.generatorId,
          score: candidate.plan?.score ?? candidate.relevance,
          score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
          explanation: candidate.plan
            ? { paths: candidate.explanation, planner: candidate.plan.explanation }
            : candidate.explanation,
          committed: false,
        })),
      });
      return {
        ...this.toResponse(session, plan, state),
        event: result.event,
        idempotent: !result.inserted,
        replanned: true,
        replanReason: reason,
      };
    } catch (error) {
      throw this.mapLifecycleError(error);
    }
  }

  async end(userId: string, sessionId: string): Promise<VibeSessionResponse> {
    let ended: VibeSession;
    try {
      ended = (await this.db.endVibeSessionWithEvent(sessionId, userId)).session;
    } catch (error) {
      throw this.mapLifecycleError(error);
    }
    const plan = await this.db.getVibePlan(sessionId, userId);
    return this.toResponse(ended, plan, plan?.state_snapshot ?? {});
  }

  private async resume(userId: string, sessionId: string): Promise<VibeSessionResponse> {
    try {
      const resumed = await this.db.resumeVibeSession(sessionId, userId);
      const plan = await this.db.getVibePlan(sessionId, userId);
      return this.toResponse(resumed.session, plan, plan?.state_snapshot ?? {});
    } catch (error) {
      throw this.mapLifecycleError(error);
    }
  }

  private async requireSession(userId: string, sessionId: string): Promise<VibeSession> {
    const session = await this.db.getVibeSession(sessionId, userId);
    if (!session) throw new VibeSessionNotFoundError('Vibe session was not found');
    return session;
  }

  private toResponse(
    session: VibeSession,
    plan: VibePlan | null,
    state: Record<string, unknown>,
  ): VibeSessionResponse {
    // A revision is immutable, but clients need a live future: already served
    // rows stay in the ledger and are excluded from the replacement preview.
    const preview = plan?.items.filter((item) => !item.committed).slice(0, PREVIEW_SIZE) ?? [];
    return {
      session,
      sessionId: session.id,
      planVersion: plan?.version ?? null,
      now: preview[0] ?? null,
      preview,
      state,
      replanned: false,
      replanReason: null,
    };
  }

  /** Reconstruct the planner envelope from the durable revision. Older
   * revisions stored only paths, so they remain valid retention inputs. */
  private unservedCandidates(plan: VibePlan): Candidate[] {
    return plan.items
      .filter(item => !item.committed)
      .map(item => {
        const stored = item.explanation;
        const hasPlanner = !!stored && !Array.isArray(stored) && typeof stored === 'object'
          && 'planner' in stored;
        const object = hasPlanner ? stored as { paths?: Candidate['explanation']; planner?: Record<string, unknown> } : undefined;
        const planner = object?.planner;
        return {
          trackId: item.track_id,
          generatorId: item.candidate_source,
          relevance: item.score,
          explanation: object?.paths ?? (Array.isArray(stored) ? stored : []),
          plan: planner ? {
            slotRole: item.slot_role ?? 'retained',
            score: item.score,
            scoreBreakdown: item.score_breakdown,
            explanation: planner,
            objective: {
              policy: planner.policy,
              constraints: planner.constraints,
              relaxations: planner.relaxations,
            },
          } : undefined,
        };
      });
  }

  private mapLifecycleError(error: unknown): Error {
    if (error instanceof Error && (error.message.includes('Cannot record a new event for') || error.message.includes('Cannot resume ') || error.message.includes('Cannot publish a plan for'))) {
      return new VibeSessionLifecycleError(error.message);
    }
    if (error instanceof Error && error.message.includes('not found or is not owned')) {
      return new VibeSessionNotFoundError('Vibe session was not found');
    }
    return error instanceof Error ? error : new Error(String(error));
  }
}

const MATERIAL_FEEDBACK_EVENTS = new Set<VibeEventType>(['skipped', 'disliked', 'completed', 'kept']);

function isMaterialFeedback(type: VibeEventType): boolean {
  return MATERIAL_FEEDBACK_EVENTS.has(type);
}
