import { Router, type Request, type Response } from "express";
import { randomUUID, createHash } from "crypto";
import { db, jobs, invoices, type Job } from "@workspace/db";
import { eq, and } from "drizzle-orm";
import { CreateJobBody, GetJobParams } from "@workspace/api-zod";
import { lnbitsService } from "../lib/lnbits.js";
import { agentService } from "../lib/agent.js";
import { pricingService } from "../lib/pricing.js";
import { jobsLimiter } from "../lib/rate-limiter.js";
import { eventBus } from "../lib/event-bus.js";
import { streamRegistry } from "../lib/stream-registry.js";
import { makeLogger } from "../lib/logger.js";
import { latencyHistogram } from "../lib/histogram.js";
import { trustService } from "../lib/trust.js";
import { freeTierService } from "../lib/free-tier.js";
import { zapService } from "../lib/zap.js";

const logger = makeLogger("jobs");

const router = Router();

async function getJobById(id: string): Promise<Job | null> {
  const rows = await db.select().from(jobs).where(eq(jobs.id, id)).limit(1);
  return rows[0] ?? null;
}

async function getInvoiceById(id: string) {
  const rows = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
  return rows[0] ?? null;
}

/**
 * Runs the AI eval in a background task (fire-and-forget) so HTTP polls
 * return immediately with "evaluating" state instead of blocking 5-8 seconds.
 * nostrPubkey is used for free-tier routing and trust scoring.
 */
async function runEvalInBackground(
  jobId: string,
  request: string,
  nostrPubkey: string | null,
): Promise<void> {
  const evalStart = Date.now();
  try {
    const evalResult = await agentService.evaluateRequest(request);
    latencyHistogram.record("eval_phase", Date.now() - evalStart);

    logger.info("eval result", {
      jobId,
      accepted:     evalResult.accepted,
      reason:       evalResult.reason,
      inputTokens:  evalResult.inputTokens,
      outputTokens: evalResult.outputTokens,
    });

    if (evalResult.accepted) {
      const { estimatedInputTokens, estimatedOutputTokens } = pricingService.estimateRequestCost(request, agentService.workModel);
      const breakdown = await pricingService.calculateWorkFeeSats(
        estimatedInputTokens,
        estimatedOutputTokens,
        agentService.workModel,
      );

      // ── Free-tier gate ──────────────────────────────────────────────────
      const ftDecision = await freeTierService.decide(nostrPubkey, breakdown.amountSats);

      if (ftDecision.serve === "free") {
        // Pool was atomically debited for ftDecision.absorbSats by decide().
        // Store ONLY the actual debited amount (not the estimate) so reconciliation
        // in recordGrant() can return over-reservation accurately.
        const reservedAbsorbed = ftDecision.absorbSats; // actual pool debit
        try {
          await db
            .update(jobs)
            .set({
              state: "executing",
              workAmountSats: 0,
              estimatedCostUsd: breakdown.estimatedCostUsd,
              marginPct: breakdown.marginPct,
              btcPriceUsd: breakdown.btcPriceUsd,
              freeTier: true,
              absorbedSats: reservedAbsorbed,
              updatedAt: new Date(),
            })
            .where(eq(jobs.id, jobId));

          eventBus.publish({ type: "job:state", jobId, state: "executing" });

          // Grant is recorded AFTER work completes (in runWorkInBackground) so we use
          // actual cost rather than estimated sats for the audit log.
          streamRegistry.register(jobId);
          setImmediate(() => {
            void runWorkInBackground(
              jobId, request, 0, breakdown.btcPriceUsd, true, nostrPubkey,
              reservedAbsorbed, // actual debited; runWorkInBackground will reconcile with actual cost
            );
          });
        } catch (setupErr) {
          // If DB transition or setup fails after pool was already debited, return sats.
          void freeTierService.releaseReservation(
            reservedAbsorbed,
            `free job setup failed: ${setupErr instanceof Error ? setupErr.message : String(setupErr)}`,
          );
          throw setupErr; // re-throw so outer catch handles job state
        }
        return;
      }

      // Partial subsidy or full gate: invoice amount = chargeSats
      const invoiceSats = ftDecision.serve === "partial"
        ? ftDecision.chargeSats
        : breakdown.amountSats;

      const workInvoiceData = await lnbitsService.createInvoice(
        invoiceSats,
        `Work fee for job ${jobId}`,
      );
      const workInvoiceId = randomUUID();

      await db.transaction(async (tx) => {
        await tx.insert(invoices).values({
          id: workInvoiceId,
          jobId,
          paymentHash: workInvoiceData.paymentHash,
          paymentRequest: workInvoiceData.paymentRequest,
          amountSats: invoiceSats,
          type: "work",
          paid: false,
        });
        await tx
          .update(jobs)
          .set({
            state: "awaiting_work_payment",
            workInvoiceId,
            workAmountSats: invoiceSats,
            estimatedCostUsd: breakdown.estimatedCostUsd,
            marginPct: breakdown.marginPct,
            btcPriceUsd: breakdown.btcPriceUsd,
            ...(ftDecision.serve === "partial" ? {
              freeTier: true,
              absorbedSats: ftDecision.absorbSats,
            } : {}),
            updatedAt: new Date(),
          })
          .where(eq(jobs.id, jobId));
      });

      // Partial grant is recorded AFTER payment + work completes (in runWorkInBackground)
      // to avoid economic DoS where pool is reserved before the user ever pays.

      eventBus.publish({ type: "job:state", jobId, state: "awaiting_work_payment" });
    } else {
      await db
        .update(jobs)
        .set({ state: "rejected", rejectionReason: evalResult.reason, updatedAt: new Date() })
        .where(eq(jobs.id, jobId));
      eventBus.publish({ type: "job:state", jobId, state: "rejected" });

      // Trust scoring — penalise on rejection
      if (nostrPubkey) {
        void trustService.recordFailure(nostrPubkey, evalResult.reason ?? "rejected");
      }
    }
  } catch (err) {
    const message = err instanceof Error ? err.message : "Evaluation error";
    await db
      .update(jobs)
      .set({ state: "failed", errorMessage: message, updatedAt: new Date() })
      .where(eq(jobs.id, jobId));
    eventBus.publish({ type: "job:failed", jobId, reason: message });

    // Trust scoring — penalise on eval failure
    if (nostrPubkey) {
      void trustService.recordFailure(nostrPubkey, message);
    }
  }
}

/**
 * Runs the AI work execution in a background task so HTTP polls return fast.
 * Uses streaming so any connected SSE client receives tokens in real time (#3).
 *
 * isFree=true   → free-tier job; no refund logic, no pool credit needed.
 * nostrPubkey   → identity for trust scoring (already known at call site).
 */
async function runWorkInBackground(
  jobId: string,
  request: string,
  workAmountSats: number,
  btcPriceUsd: number | null,
  isFree = false,
  nostrPubkey: string | null = null,
  partialAbsorbSats = 0,
): Promise<void> {
  const workStart = Date.now();
  try {
    eventBus.publish({ type: "job:state", jobId, state: "executing" });

    const workResult = await agentService.executeWorkStreaming(request, (delta) => {
      streamRegistry.write(jobId, delta);
    });

    streamRegistry.end(jobId);
    latencyHistogram.record("work_phase", Date.now() - workStart);

    const actualCostUsd = pricingService.calculateActualCostUsd(
      workResult.inputTokens,
      workResult.outputTokens,
      agentService.workModel,
    );

    const lockedBtcPrice = btcPriceUsd ?? 100_000;
    const actualTotalCostSats = pricingService.calculateActualChargeSats(actualCostUsd, lockedBtcPrice);

    // For free-tier jobs the user paid nothing — no refund is applicable.
    // For partial jobs: Timmy absorbed partialAbsorbSats from pool; user's actual charge is
    //   max(0, actualTotalCostSats - partialAbsorbSats).
    //   Refund = workAmountSats (what user paid) - actualUserChargeSats.
    const isPartial = !isFree && partialAbsorbSats > 0;
    const actualAbsorbedForRefund = isPartial ? partialAbsorbSats : 0;
    const actualAmountSats = isFree
      ? 0
      : Math.max(0, actualTotalCostSats - actualAbsorbedForRefund);
    const refundAmountSats = isFree ? 0 : pricingService.calculateRefundSats(workAmountSats, actualAmountSats);
    const refundState: "not_applicable" | "pending" = isFree
      ? "not_applicable"
      : (refundAmountSats > 0 ? "pending" : "not_applicable");

    await db
      .update(jobs)
      .set({
        state: "complete",
        result: workResult.result,
        actualInputTokens: workResult.inputTokens,
        actualOutputTokens: workResult.outputTokens,
        actualCostUsd,
        actualAmountSats,
        refundAmountSats,
        refundState,
        updatedAt: new Date(),
      })
      .where(eq(jobs.id, jobId));

    logger.info("work completed", {
      jobId,
      isFree,
      inputTokens:      workResult.inputTokens,
      outputTokens:     workResult.outputTokens,
      actualAmountSats,
      refundAmountSats,
      refundState,
    });
    eventBus.publish({ type: "job:completed", jobId, result: workResult.result });

    // Credit the generosity pool from paid interactions
    if (!isFree && workAmountSats > 0) {
      void freeTierService.credit(workAmountSats);
    }

    // Record free-tier grant audit log now that work is confirmed complete.
    // Pool was already debited atomically (at decide() for free jobs, at
    // reservePartialGrant() for partial jobs). Here we reconcile actual vs reserved:
    //
    //   - isFree:   actualAbsorbed = min(actualCostSats, reservedAbsorbed)
    //               over-reservation returned to pool inside recordGrant()
    //   - partial:  actualAbsorbed = min(actualTotalCostSats, reservedAbsorbed)
    //               actual cost may be less than estimated; return excess to pool
    if (partialAbsorbSats > 0 && nostrPubkey) {
      // actualAbsorbed = how many pool sats Timmy actually spent on this request.
      // Capped at reservedAbsorbed (partialAbsorbSats); over-reservation returned inside recordGrant.
      const actualAbsorbed = Math.min(actualTotalCostSats, partialAbsorbSats);
      const reqHash = createHash("sha256").update(request).digest("hex");
      await freeTierService.recordGrant(nostrPubkey, reqHash, actualAbsorbed, partialAbsorbSats);
    }

    // Trust scoring — fire and forget
    const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null;
    if (pubkeyForTrust) {
      void trustService.recordSuccess(pubkeyForTrust, actualAmountSats);
      // Zap back to trusted partners if configured (ZAP_PCT_DEFAULT > 0)
      const tier = await trustService.getTier(pubkeyForTrust);
      void zapService.maybeZapOnJobComplete({ pubkey: pubkeyForTrust, workFeeSats: actualAmountSats, tier });
    }
  } catch (err) {
    const message = err instanceof Error ? err.message : "Execution error";
    streamRegistry.end(jobId);
    await db
      .update(jobs)
      .set({ state: "failed", errorMessage: message, updatedAt: new Date() })
      .where(eq(jobs.id, jobId));
    eventBus.publish({ type: "job:failed", jobId, reason: message });

    // Return any pool reservation if work failed
    if (partialAbsorbSats > 0 && nostrPubkey) {
      void freeTierService.releaseReservation(partialAbsorbSats, `work failed: ${message}`);
    }

    // Trust scoring — penalise on work failure
    const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null;
    if (pubkeyForTrust) {
      void trustService.recordFailure(pubkeyForTrust, message);
    }
  }
}

/**
 * Checks whether the active invoice for a job has been paid and, if so,
 * advances the state machine. AI work runs in the background so this
 * returns quickly. Returns the refreshed job after any DB transitions.
 */
async function advanceJob(job: Job): Promise<Job | null> {
  if (job.state === "awaiting_eval_payment" && job.evalInvoiceId) {
    const evalInvoice = await getInvoiceById(job.evalInvoiceId);
    if (!evalInvoice || evalInvoice.paid) return getJobById(job.id);

    const isPaid = await lnbitsService.checkInvoicePaid(evalInvoice.paymentHash);
    if (!isPaid) return job;

    const advanced = await db.transaction(async (tx) => {
      await tx
        .update(invoices)
        .set({ paid: true, paidAt: new Date() })
        .where(eq(invoices.id, evalInvoice.id));
      const updated = await tx
        .update(jobs)
        .set({ state: "evaluating", updatedAt: new Date() })
        .where(and(eq(jobs.id, job.id), eq(jobs.state, "awaiting_eval_payment")))
        .returning();
      return updated.length > 0;
    });

    if (!advanced) return getJobById(job.id);

    logger.info("invoice paid", { jobId: job.id, invoiceType: "eval", paymentHash: evalInvoice.paymentHash });
    eventBus.publish({ type: "job:paid", jobId: job.id, invoiceType: "eval" });
    eventBus.publish({ type: "job:state", jobId: job.id, state: "evaluating" });

    // Fire AI eval in background — poll returns immediately with "evaluating"
    setImmediate(() => { void runEvalInBackground(job.id, job.request, job.nostrPubkey ?? null); });

    return getJobById(job.id);
  }

  if (job.state === "awaiting_work_payment" && job.workInvoiceId) {
    const workInvoice = await getInvoiceById(job.workInvoiceId);
    if (!workInvoice || workInvoice.paid) return getJobById(job.id);

    const isPaid = await lnbitsService.checkInvoicePaid(workInvoice.paymentHash);
    if (!isPaid) return job;

    const advanced = await db.transaction(async (tx) => {
      await tx
        .update(invoices)
        .set({ paid: true, paidAt: new Date() })
        .where(eq(invoices.id, workInvoice.id));
      const updated = await tx
        .update(jobs)
        .set({ state: "executing", updatedAt: new Date() })
        .where(and(eq(jobs.id, job.id), eq(jobs.state, "awaiting_work_payment")))
        .returning();
      return updated.length > 0;
    });

    if (!advanced) return getJobById(job.id);

    logger.info("invoice paid", { jobId: job.id, invoiceType: "work", paymentHash: workInvoice.paymentHash });
    eventBus.publish({ type: "job:paid", jobId: job.id, invoiceType: "work" });

    // Register stream slot before firing background work so first tokens aren't lost
    streamRegistry.register(job.id);

    // Fire AI work in background — poll returns immediately with "executing"
    // isFree is true ONLY for fully-free jobs (user paid nothing, workAmountSats=0).
    // Partial jobs (freeTier=true but workAmountSats>0) must use the paid accounting path.
    const isFreeExecution = (job.workAmountSats ?? 0) === 0;

    // For partial free-tier jobs: atomically reserve the pool subsidy NOW (at payment
    // confirmation time), so the pool is only debited once the user has actually paid.
    // decide() was advisory for partial jobs — no pool debit at decision time.
    // This prevents pool drain from users who abandon the payment flow.
    //
    // If reservePartialGrant() returns 0 (pool drained between decide() and payment),
    // treat the job as fully paid (no subsidy). The user already paid the discounted
    // invoice, so we cannot charge them more. Timmy proceeds at a small loss for this
    // edge case, but the amount is bounded by min(partialAbsorbSats, actualCost).
    // This is acceptable because: (a) the pool is non-zero when decide() runs, and
    // (b) the advisory absorbSats is a small fraction of the full cost.
    let partialGrantReserved = 0;
    if (!isFreeExecution && job.freeTier && job.nostrPubkey && (job.absorbedSats ?? 0) > 0) {
      partialGrantReserved = await freeTierService.reservePartialGrant(
        job.absorbedSats ?? 0,
        job.nostrPubkey,
      );
      logger.info("partial grant reserved at payment", {
        jobId: job.id,
        requested: job.absorbedSats,
        reserved: partialGrantReserved,
      });
      if (partialGrantReserved <= 0 && (job.absorbedSats ?? 0) > 0) {
        // Pool drained between advisory decide() and payment confirmation.
        // Revert to normal paid flow: user already paid their invoice (chargeSats),
        // which is less than full price. We proceed with zero subsidy — user will be
        // charged their paid amount and work executes. Any normal refund (actual < paid)
        // still applies through the standard refund path.
        // partialGrantReserved = 0 means recordGrant will record 0 absorbed, so no
        // pool accounting error occurs.
        logger.warn("partial grant pool drained at payment — proceeding with zero subsidy (paid flow)", {
          jobId: job.id,
          pubkey: job.nostrPubkey.slice(0, 8),
          requestedAbsorb: job.absorbedSats,
        });
      }
    }

    setImmediate(() => {
      void runWorkInBackground(
        job.id,
        job.request,
        job.workAmountSats ?? 0,
        job.btcPriceUsd,
        isFreeExecution,
        job.nostrPubkey ?? null,
        // For fully-free jobs: pool was debited at decide() time, pass estimated sats.
        // For partial jobs: pool was debited by reservePartialGrant(), pass actual reserved.
        isFreeExecution ? (job.absorbedSats ?? 0) : partialGrantReserved,
      );
    });

    return getJobById(job.id);
  }

  return job;
}

// ── POST /jobs ────────────────────────────────────────────────────────────────

// ── Resolve Nostr pubkey from token header or body ────────────────────────────

/** Resolves a Nostr token from the request.
 * Returns `{ pubkey, rejected }` where:
 *   rejected=false, pubkey=null  → no token supplied (anonymous)
 *   rejected=true,  pubkey=null  → token supplied but invalid/expired → caller should 401
 *   rejected=false, pubkey=str   → valid token
 */
function resolveNostrPubkey(req: Request): { pubkey: string | null; rejected: boolean } {
  const header = req.headers["x-nostr-token"];
  const bodyToken = req.body?.nostr_token;
  const raw = typeof header === "string" ? header : (typeof bodyToken === "string" ? bodyToken : null);
  if (!raw) return { pubkey: null, rejected: false };
  const parsed = trustService.verifyToken(raw.trim());
  if (!parsed) return { pubkey: null, rejected: true };
  return { pubkey: parsed.pubkey, rejected: false };
}

router.post("/jobs", jobsLimiter, async (req: Request, res: Response) => {
  const parseResult = CreateJobBody.safeParse(req.body);
  if (!parseResult.success) {
    const issue = parseResult.error.issues[0];
    const error = issue?.code === "too_big"
      ? "Invalid request: 'request' must be 500 characters or fewer"
      : "Invalid request: 'request' string is required";
    res.status(400).json({ error });
    return;
  }
  const { request } = parseResult.data;

  // Optionally bind a Nostr identity — ensure row exists before FK insert
  const tokenResult = resolveNostrPubkey(req);
  if (tokenResult.rejected) {
    res.status(401).json({ error: "Invalid or expired nostr_token" });
    return;
  }
  const nostrPubkey = tokenResult.pubkey;
  if (nostrPubkey) await trustService.getOrCreate(nostrPubkey);

  try {
    const evalFee = pricingService.calculateEvalFeeSats();
    const jobId = randomUUID();
    const invoiceId = randomUUID();
    const createdAt = new Date();

    const lnbitsInvoice = await lnbitsService.createInvoice(evalFee, `Eval fee for job ${jobId}`);

    await db.transaction(async (tx) => {
      await tx.insert(jobs).values({
        id: jobId,
        request,
        state: "awaiting_eval_payment",
        evalAmountSats: evalFee,
        createdAt,
        ...(nostrPubkey ? { nostrPubkey } : {}),
      });
      await tx.insert(invoices).values({
        id: invoiceId,
        jobId,
        paymentHash: lnbitsInvoice.paymentHash,
        paymentRequest: lnbitsInvoice.paymentRequest,
        amountSats: evalFee,
        type: "eval",
        paid: false,
      });
      await tx.update(jobs).set({ evalInvoiceId: invoiceId, updatedAt: new Date() }).where(eq(jobs.id, jobId));
    });

    logger.info("job created", {
      jobId,
      evalAmountSats: evalFee,
      stubMode: lnbitsService.stubMode,
      nostrPubkey: nostrPubkey?.slice(0, 8),
    });

    const trust = nostrPubkey
      ? await trustService.getIdentityWithDecay(nostrPubkey)
      : null;

    res.status(201).json({
      jobId,
      createdAt: createdAt.toISOString(),
      ...(nostrPubkey ? { nostrPubkey } : {}),
      trust_tier: trust ? trust.tier : "anonymous",
      evalInvoice: {
        paymentRequest: lnbitsInvoice.paymentRequest,
        amountSats: evalFee,
        paymentHash: lnbitsInvoice.paymentHash,
      },
    });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Failed to create job";
    logger.error("job creation failed", { error: message });
    res.status(500).json({ error: message });
  }
});

// ── GET /jobs/:id ─────────────────────────────────────────────────────────────

router.get("/jobs/:id", async (req: Request, res: Response) => {
  const paramResult = GetJobParams.safeParse(req.params);
  if (!paramResult.success) { res.status(400).json({ error: "Invalid job id" }); return; }
  const { id } = paramResult.data;

  try {
    let job = await getJobById(id);
    if (!job) { res.status(404).json({ error: "Job not found" }); return; }

    const advanced = await advanceJob(job);
    if (advanced) job = advanced;

    const trustTier = job.nostrPubkey
      ? await trustService.getTier(job.nostrPubkey)
      : "anonymous";

    const base = {
      jobId: job.id,
      state: job.state,
      createdAt: job.createdAt.toISOString(),
      completedAt: job.state === "complete" ? job.updatedAt.toISOString() : null,
      ...(job.nostrPubkey ? { nostrPubkey: job.nostrPubkey } : {}),
      trust_tier: trustTier,
      ...(job.freeTier ? { free_tier: true, absorbed_sats: job.absorbedSats ?? 0 } : {}),
    };

    switch (job.state) {
      case "awaiting_eval_payment": {
        const inv = job.evalInvoiceId ? await getInvoiceById(job.evalInvoiceId) : null;
        res.json({
          ...base,
          ...(inv ? {
            evalInvoice: {
              paymentRequest: inv.paymentRequest,
              amountSats: inv.amountSats,
              paymentHash: inv.paymentHash,
            },
          } : {}),
        });
        break;
      }

      case "awaiting_work_payment": {
        const inv = job.workInvoiceId ? await getInvoiceById(job.workInvoiceId) : null;
        res.json({
          ...base,
          ...(inv ? {
            workInvoice: {
              paymentRequest: inv.paymentRequest,
              amountSats: inv.amountSats,
              paymentHash: inv.paymentHash,
            },
          } : {}),
          ...(job.estimatedCostUsd != null ? {
            pricingBreakdown: {
              estimatedCostUsd: job.estimatedCostUsd,
              marginPct: job.marginPct,
              btcPriceUsd: job.btcPriceUsd,
            },
          } : {}),
        });
        break;
      }

      case "rejected":
        res.json({ ...base, reason: job.rejectionReason ?? undefined });
        break;

      case "complete":
        res.json({
          ...base,
          result: job.result ?? undefined,
          ...(job.actualCostUsd != null ? {
            costLedger: {
              // Token usage
              actualInputTokens: job.actualInputTokens,
              actualOutputTokens: job.actualOutputTokens,
              totalTokens: (job.actualInputTokens ?? 0) + (job.actualOutputTokens ?? 0),
              // USD costs
              actualCostUsd: job.actualCostUsd,
              actualChargeUsd: job.actualAmountSats != null && job.btcPriceUsd
                ? (job.actualAmountSats / 1e8) * job.btcPriceUsd
                : undefined,
              estimatedCostUsd: job.estimatedCostUsd,
              // Sat amounts
              actualAmountSats: job.actualAmountSats,
              workAmountSats: job.workAmountSats,
              // Refund
              refundAmountSats: job.refundAmountSats,
              refundState: job.refundState,
              // Rate context
              marginPct: job.marginPct,
              btcPriceUsd: job.btcPriceUsd,
            },
          } : {}),
        });
        break;

      case "failed":
        res.json({ ...base, errorMessage: job.errorMessage ?? undefined });
        break;

      default:
        res.json(base);
    }
  } catch (err) {
    const message = err instanceof Error ? err.message : "Failed to fetch job";
    res.status(500).json({ error: message });
  }
});

// ── POST /jobs/:id/refund ─────────────────────────────────────────────────────

router.post("/jobs/:id/refund", async (req: Request, res: Response) => {
  const paramResult = GetJobParams.safeParse(req.params);
  if (!paramResult.success) { res.status(400).json({ error: "Invalid job id" }); return; }
  const { id } = paramResult.data;

  const { invoice } = req.body as { invoice?: string };
  if (!invoice || typeof invoice !== "string" || invoice.trim().length === 0) {
    res.status(400).json({ error: "Body must include 'invoice' (BOLT11 string)" });
    return;
  }
  const bolt11 = invoice.trim();

  try {
    const job = await getJobById(id);
    if (!job) { res.status(404).json({ error: "Job not found" }); return; }
    if (job.state !== "complete") {
      res.status(409).json({ error: `Job is in state '${job.state}', not 'complete'` });
      return;
    }
    if (job.refundState !== "pending") {
      if (job.refundState === "not_applicable") {
        res.status(409).json({ error: "No refund is owed for this job (actual cost matched or exceeded the invoice amount)" });
      } else if (job.refundState === "paid") {
        res.status(409).json({ error: "Refund has already been sent", refundPaymentHash: job.refundPaymentHash });
      } else {
        res.status(409).json({ error: "Refund is not available for this job" });
      }
      return;
    }

    const refundSats = job.refundAmountSats ?? 0;
    if (refundSats <= 0) {
      res.status(409).json({ error: "Refund amount is zero — nothing to return" });
      return;
    }

    // ── Validate invoice amount ───────────────────────────────────────────
    const decoded = await lnbitsService.decodeInvoice(bolt11);
    if (decoded !== null && decoded.amountSats !== refundSats) {
      res.status(400).json({
        error: `Invoice amount (${decoded.amountSats} sats) does not match refund owed (${refundSats} sats)`,
        refundAmountSats: refundSats,
      });
      return;
    }

    // ── Send refund ───────────────────────────────────────────────────────
    const paymentHash = await lnbitsService.payInvoice(bolt11);

    await db
      .update(jobs)
      .set({ refundState: "paid", refundPaymentHash: paymentHash, updatedAt: new Date() })
      .where(and(eq(jobs.id, id), eq(jobs.refundState, "pending")));

    res.json({
      ok: true,
      refundAmountSats: refundSats,
      paymentHash,
      message: `Refund of ${refundSats} sats sent successfully`,
    });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Failed to process refund";
    res.status(500).json({ error: message });
  }
});

// ── GET /jobs/:id/stream ──────────────────────────────────────────────────────
// Server-Sent Events (#3): streams Claude token deltas in real time while the
// job is executing. If the job is already complete, sends the full result then
// closes. If the job isn't executing yet, waits up to 60 s for it to start.

router.get("/jobs/:id/stream", async (req: Request, res: Response) => {
  const paramResult = GetJobParams.safeParse(req.params);
  if (!paramResult.success) {
    res.status(400).json({ error: "Invalid job id" });
    return;
  }
  const { id } = paramResult.data;

  const job = await getJobById(id);
  if (!job) {
    res.status(404).json({ error: "Job not found" });
    return;
  }

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no");
  res.flushHeaders();

  const sendEvent = (event: string, data: unknown) => {
    res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
  };

  // Job already complete — replay full result immediately
  if (job.state === "complete" && job.result) {
    sendEvent("token", { text: job.result });
    sendEvent("done", { jobId: id, state: "complete" });
    res.end();
    return;
  }

  if (job.state === "failed") {
    sendEvent("error", { jobId: id, message: job.errorMessage ?? "Job failed" });
    res.end();
    return;
  }

  // Job is executing or about to execute — pipe the live stream
  const sendHeartbeat = setInterval(() => {
    res.write(": heartbeat\n\n");
  }, 15_000);

  const cleanup = () => {
    clearInterval(sendHeartbeat);
  };

  req.on("close", cleanup);

  // ── Wait for stream slot (fixes #16 race condition) ──────────────────────
  // After the bus wait we re-check BOTH the stream registry AND the DB so we
  // handle: (a) job completed while we waited (stream already gone), (b) job
  // still executing but stream was registered after we first checked.
  let stream = streamRegistry.get(id);
  let currentJob = job;

  if (!stream) {
    await new Promise<void>((resolve) => {
      // 90 s timeout — generous enough for slow payment confirmations on mainnet
      const deadline = setTimeout(resolve, 90_000);
      const busListener = (data: Parameters<typeof eventBus.publish>[0]) => {
        if ("jobId" in data && data.jobId === id) {
          clearTimeout(deadline);
          eventBus.off("bus", busListener);
          resolve();
        }
      };
      eventBus.on("bus", busListener);
    });

    // Refresh both stream slot and job state after waiting
    stream = streamRegistry.get(id);
    currentJob = (await getJobById(id)) ?? currentJob;
  }

  // ── Resolve: stream available ─────────────────────────────────────────────
  if (stream) {
    const attachToStream = (s: typeof stream) => {
      s!.on("data", (chunk: Buffer) => {
        sendEvent("token", { text: chunk.toString("utf8") });
      });
      s!.on("end", () => {
        sendEvent("done", { jobId: id, state: "complete" });
        res.end();
        cleanup();
      });
      s!.on("error", (err: Error) => {
        sendEvent("error", { jobId: id, message: err.message });
        res.end();
        cleanup();
      });
    };
    attachToStream(stream);
    return;
  }

  // ── Resolve: job completed while we waited (stream already gone) ──────────
  if (currentJob.state === "complete" && currentJob.result) {
    sendEvent("token", { text: currentJob.result });
    sendEvent("done", { jobId: id, state: "complete" });
    res.end();
    cleanup();
    return;
  }

  if (currentJob.state === "failed") {
    sendEvent("error", { jobId: id, message: currentJob.errorMessage ?? "Job failed" });
    res.end();
    cleanup();
    return;
  }

  // ── Resolve: timeout with no activity — tell client to fall back to polling
  sendEvent("error", {
    jobId: id,
    message: "Stream timed out. Poll GET /api/jobs/:id for current state.",
  });
  res.end();
  cleanup();
});

export default router;
