/**
 * Tracker ↔ fleet bridge (§10) — in-process round-trip on the memory providers.
 * Service tests call the bridge directly; route tests use Fastify inject to exercise
 * the gated auto-echo (FLEET_TRACKER_ECHO, default OFF). Auth + productId are mocked
 * exactly like the other fleet routes tests; NO live HTTP.
 */

import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js';
import * as itemsRepo from '../items/repository.js';
import * as commentsRepo from '../comments/repository.js';
import type { TrackerItemDoc } from '../items/types.js';
import * as repo from './repository.js';
import * as coordinator from './coordinator.js';
import * as bridge from './tracker-bridge.js';
import { SubmitJobSchema } from './types.js';

vi.mock('../../lib/auth.js', () => ({
  extractAuth: vi.fn(async () => ({ sub: 'user_1', role: 'admin' })),
}));
vi.mock('../../lib/request-context.js', () => ({
  getRequestProductId: () => 'lysnrai',
  requireProductAccess: async () => 'lysnrai',
}));

const PID = 'lysnrai';

function makeItem(over: Partial<TrackerItemDoc> = {}): TrackerItemDoc {
  const now = new Date().toISOString();
  return {
    id: 'IT-1',
    productId: PID,
    type: 'task',
    status: 'open',
    priority: 'medium',
    title: 'Fix the widget',
    description: 'Do A then B.',
    labels: [],
    assignee: null,
    reportedBy: 'user_1',
    source: 'internal',
    visibility: 'internal',
    voteCount: 0,
    commentCount: 0,
    priorityOrder: 2,
    targetRelease: null,
    createdAt: now,
    updatedAt: now,
    ...over,
  };
}

async function buildApp(): Promise<FastifyInstance> {
  const { fleetRoutes } = await import('./routes.js');
  const app = Fastify({ logger: false });
  await app.register(fleetRoutes, { prefix: '/api' });
  return app;
}

describe('fleet tracker bridge (§10)', () => {
  beforeEach(() => {
    setProvider(new MemoryDatastoreProvider());
    delete process.env.FLEET_TRACKER_ECHO;
  });
  afterEach(() => {
    _resetDatastoreProvider();
    delete process.env.FLEET_TRACKER_ECHO;
    vi.restoreAllMocks();
  });

  // ── ingest ──
  it('ingest creates exactly one claimable job (trackerItemId, bodyMd, stable key)', async () => {
    await itemsRepo.create(makeItem());
    const { job, outcome } = await bridge.ingestItemAsJob(PID, 'IT-1');
    expect(outcome).toBe('created');
    expect(job.trackerItemId).toBe('IT-1');
    expect(job.idempotencyKey).toBe('tracker-IT-1');
    expect(job.bodyMd).toContain('Do A then B.');
    expect(job.stage).toBe('queued');

    // claimable through the normal scheduler path (claim unchanged)
    const claim = await coordinator.claimNextJob({
      productId: PID,
      factoryId: 'fac_1',
      capabilities: [],
      leaseSeconds: 900,
    });
    expect(claim?.job.id).toBe(job.id);
  });

  it('ingest maps labels → priority + capabilities', async () => {
    await itemsRepo.create(
      makeItem({ labels: ['engine-class:agentic-coder', 'priority:high', 'cap:os:mac'] })
    );
    const { job } = await bridge.ingestItemAsJob(PID, 'IT-1');
    expect(job.priority).toBe('high');
    expect(job.capabilities).toEqual(['os:mac']);
    expect(job.engineClass).toBe('agentic-coder');
  });

  it('ingest is idempotent (same Item twice → one job, deduplicated)', async () => {
    await itemsRepo.create(makeItem());
    const first = await bridge.ingestItemAsJob(PID, 'IT-1');
    const second = await bridge.ingestItemAsJob(PID, 'IT-1');
    expect(second.outcome).toBe('deduplicated');
    expect(second.job.id).toBe(first.job.id);
    const all = await repo.findJobsByIdempotencyKey(PID, 'tracker-IT-1');
    expect(all).toHaveLength(1);
  });

  it('ingest for a foreign productId → not found (isolation)', async () => {
    await itemsRepo.create(makeItem());
    await expect(bridge.ingestItemAsJob('other-product', 'IT-1')).rejects.toThrow(/not found/i);
  });

  // ── echo ──
  it('echo round-trip: queued→building→shipped drives Item in_progress→done with metrics-only notes', async () => {
    await itemsRepo.create(makeItem({ description: 'SECRET-BODY-SENTINEL do the work' }));
    const { job } = await bridge.ingestItemAsJob(PID, 'IT-1');

    const e1 = await bridge.echoJobToItem(PID, job.id);
    expect(e1.echoed).toBe('in_progress');
    expect((await itemsRepo.getById('IT-1'))?.status).toBe('in_progress');

    await repo.updateJob(job.id, PID, { stage: 'shipped' });
    const e2 = await bridge.echoJobToItem(PID, job.id);
    expect(e2.echoed).toBe('done');
    expect((await itemsRepo.getById('IT-1'))?.status).toBe('done');

    // metrics-only comments — never the prompt body
    const comments = await commentsRepo.listByItem('IT-1');
    expect(comments.length).toBeGreaterThanOrEqual(2);
    for (const c of comments) {
      expect(c.body).not.toContain('SECRET-BODY-SENTINEL');
      expect(c.body).toContain('attempts=');
    }
  });

  it('echo failed → Item wont_fix (the Item vocabulary has no "blocked")', async () => {
    await itemsRepo.create(makeItem());
    const { job } = await bridge.ingestItemAsJob(PID, 'IT-1');
    await repo.updateJob(job.id, PID, { stage: 'failed' });
    const res = await bridge.echoJobToItem(PID, job.id);
    expect(res.echoed).toBe('wont_fix');
    expect((await itemsRepo.getById('IT-1'))?.status).toBe('wont_fix');
  });

  it('echo is idempotent (re-echo of an unchanged stage → no duplicate Item write)', async () => {
    await itemsRepo.create(makeItem());
    const { job } = await bridge.ingestItemAsJob(PID, 'IT-1');
    await bridge.echoJobToItem(PID, job.id);
    const after1 = (await itemsRepo.getById('IT-1'))?.commentCount;
    const res = await bridge.echoJobToItem(PID, job.id);
    expect(res.echoed).toBe('in_progress');
    const after2 = (await itemsRepo.getById('IT-1'))?.commentCount;
    expect(after2).toBe(after1); // no extra comment written
    expect(await commentsRepo.listByItem('IT-1')).toHaveLength(after1 ?? 0);
  });

  it('echo is non-fatal: an items-write failure → { echoed: null, error }, job untouched', async () => {
    await itemsRepo.create(makeItem());
    const { job } = await bridge.ingestItemAsJob(PID, 'IT-1');
    vi.spyOn(itemsRepo, 'update').mockRejectedValueOnce(new Error('cosmos down'));
    const res = await bridge.echoJobToItem(PID, job.id);
    expect(res.echoed).toBeNull();
    expect(res.error).toMatch(/cosmos down/);
    // job state untouched: not marked echoed, still queued
    const after = await repo.getJob(job.id, PID);
    expect(after?.trackerEchoedStatus).toBeUndefined();
    expect(after?.stage).toBe('queued');
  });

  it('echo no-op for a job with no trackerItemId', async () => {
    const { job } = await coordinator.submitJob(
      PID,
      SubmitJobSchema.parse({ idempotencyKey: 'plain', bodyMd: '# t' })
    );
    const res = await bridge.echoJobToItem(PID, job.id);
    expect(res.echoed).toBeNull();
  });

  // ── route + flag gating ──
  it('auto-echo OFF (default flag): a PATCH transition performs ZERO items writes', async () => {
    await itemsRepo.create(makeItem());
    const app = await buildApp();
    const ingest = await app.inject({
      method: 'POST',
      url: '/api/fleet/tracker/ingest',
      payload: { itemId: 'IT-1' },
    });
    expect(ingest.statusCode).toBe(201);
    const jobId = JSON.parse(ingest.body).job.id as string;

    const claim = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1', capabilities: [] },
    });
    const leaseEpoch = JSON.parse(claim.body).job.leaseEpoch as number;

    const patch = await app.inject({
      method: 'PATCH',
      url: `/api/fleet/jobs/${jobId}`,
      payload: { stage: 'building', leaseEpoch },
    });
    expect(patch.statusCode).toBe(200);
    // flag OFF ⇒ Item untouched, no comments
    expect((await itemsRepo.getById('IT-1'))?.status).toBe('open');
    expect(await commentsRepo.listByItem('IT-1')).toHaveLength(0);
  });

  it('auto-echo ON (FLEET_TRACKER_ECHO=1): a PATCH transition echoes to the Item', async () => {
    await itemsRepo.create(makeItem());
    const app = await buildApp();
    const ingest = await app.inject({
      method: 'POST',
      url: '/api/fleet/tracker/ingest',
      payload: { itemId: 'IT-1' },
    });
    const jobId = JSON.parse(ingest.body).job.id as string;
    const claim = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1', capabilities: [] },
    });
    const leaseEpoch = JSON.parse(claim.body).job.leaseEpoch as number;

    process.env.FLEET_TRACKER_ECHO = '1';
    const patch = await app.inject({
      method: 'PATCH',
      url: `/api/fleet/jobs/${jobId}`,
      payload: { stage: 'building', leaseEpoch },
    });
    expect(patch.statusCode).toBe(200);
    expect((await itemsRepo.getById('IT-1'))?.status).toBe('in_progress');
  });

  it('manual echo route returns the echoed status', async () => {
    await itemsRepo.create(makeItem());
    const app = await buildApp();
    const ingest = await app.inject({
      method: 'POST',
      url: '/api/fleet/tracker/ingest',
      payload: { itemId: 'IT-1' },
    });
    const jobId = JSON.parse(ingest.body).job.id as string;
    const echo = await app.inject({
      method: 'POST',
      url: '/api/fleet/tracker/echo',
      payload: { jobId },
    });
    expect(echo.statusCode).toBe(200);
    expect(JSON.parse(echo.body).echoed).toBe('in_progress');
  });
});
