#!/usr/bin/env tsx
/**
 * Full customer ↔ worker interaction E2E (directory mode).
 *
 * Covers:
 *  - Register + verify both roles (email OTP → phone → docs)
 *  - Unverified gates (hire / message blocked)
 *  - Public browse / worker detail
 *  - Save / unsave worker
 *  - Messaging both directions + notifications
 *  - Hire invite → reject → invite again → accept
 *  - Busy-worker gate
 *  - Review + complete hire → worker available again
 *  - Admin notify on verification already covered elsewhere
 */
import fs from 'fs';
import path from 'path';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();
const API_BASE = (process.env.API_URL || 'http://localhost:4000').replace(/\/$/, '');
const API = API_BASE.endsWith('/api') ? API_BASE : `${API_BASE}/api`;
const PASS = process.env.DEMO_PASSWORD || 'Password123!';

let passed = 0;
let failed = 0;

function pass(label: string) {
  console.log(`  ✓ ${label}`);
  passed += 1;
}

function fail(label: string, detail?: string) {
  console.log(`  ✗ ${label}${detail ? `: ${detail}` : ''}`);
  failed += 1;
}

async function expectOk<T extends { success?: boolean; error?: string }>(
  label: string,
  fn: () => Promise<T>
): Promise<T | null> {
  try {
    const res = await fn();
    if (res.success === false) {
      fail(label, res.error || 'success=false');
      return null;
    }
    pass(label);
    return res;
  } catch (e) {
    fail(label, e instanceof Error ? e.message : String(e));
    return null;
  }
}

async function expectFail(label: string, fn: () => Promise<{ success?: boolean; error?: string }>) {
  try {
    const res = await fn();
    if (res.success === false) {
      pass(`${label} (blocked: ${res.error})`);
      return res;
    }
    fail(label, 'expected failure but succeeded');
    return null;
  } catch {
    pass(`${label} (threw as expected)`);
    return null;
  }
}

async function login(identifier: string) {
  const res = await fetch(`${API}/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ identifier, password: PASS }),
  });
  const data = await res.json();
  if (!data.success) throw new Error(data.error || `login failed for ${identifier}`);
  return data.data.accessToken as string;
}

async function apiJson<T = unknown>(
  token: string | null,
  method: string,
  endpoint: string,
  body?: unknown
): Promise<T & { success?: boolean; error?: string; data?: unknown }> {
  const res = await fetch(`${API}${endpoint}`, {
    method,
    headers: {
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  return (await res.json()) as T & { success?: boolean; error?: string; data?: unknown };
}

function createTestPng(filePath: string) {
  const png = Buffer.from(
    'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
    'base64'
  );
  fs.writeFileSync(filePath, png);
}

async function uploadDoc(token: string, type: string, filePath: string) {
  const form = new FormData();
  const blob = new Blob([fs.readFileSync(filePath)], { type: 'image/png' });
  form.append('document', blob, path.basename(filePath));
  form.append('type', type);
  const res = await fetch(`${API}/verifications`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
    body: form,
  });
  const data = await res.json();
  if (!data.success) throw new Error(data.error || 'upload failed');
  return data.data as { id: string };
}

async function readOtp(destination: string, purpose: string) {
  const row = await prisma.oTPVerification.findFirst({
    where: { phone: destination.toLowerCase(), purpose, isUsed: false },
    orderBy: { createdAt: 'desc' },
  });
  if (!row?.code) throw new Error(`OTP not found for ${destination} / ${purpose}`);
  return row.code;
}

async function fullyVerifyAccount(
  token: string,
  adminToken: string,
  phone: string,
  tmpPng: string
) {
  await apiJson(token, 'POST', '/auth/phone/send-otp', { phone });
  const phoneOtp = await readOtp(phone, 'phone_verify');
  await apiJson(token, 'POST', '/auth/phone/verify', { phone, otpCode: phoneOtp });

  const idDoc = await uploadDoc(token, 'GOVERNMENT_ID', tmpPng);
  const selfie = await uploadDoc(token, 'SELFIE', tmpPng);
  await apiJson(adminToken, 'PATCH', `/verifications/${idDoc.id}/review`, {
    status: 'APPROVED',
  });
  await apiJson(adminToken, 'PATCH', `/verifications/${selfie.id}/review`, {
    status: 'APPROVED',
  });
}

async function me(token: string) {
  const res = await apiJson<{
    data: {
      id: string;
      email: string;
      phoneVerified: boolean;
      role: string;
      workerProfile?: { id: string; isVerified: boolean; isHired?: boolean; isAvailable?: boolean };
      customerProfile?: { id: string; isVerified: boolean };
    };
  }>(token, 'GET', '/auth/me');
  if (!res.success || !res.data) throw new Error('auth/me failed');
  return res.data;
}

async function main() {
  console.log('=== Full customer ↔ worker interactions E2E ===\n');
  console.log(`API: ${API}\n`);

  const health = await fetch(`${API_BASE}/health`)
    .then((r) => r.json())
    .catch(() => null);
  if (!health || health.status !== 'ok') {
    fail('API health', 'not reachable on :4000');
    process.exit(1);
  }
  pass('API health');

  const stamp = Date.now();
  const tmpPng = path.join(process.cwd(), 'uploads', `interact-${stamp}.png`);
  fs.mkdirSync(path.dirname(tmpPng), { recursive: true });
  createTestPng(tmpPng);

  const cats = await apiJson<{ data: { id: string }[] }>(null, 'GET', '/categories?limit=1');
  const categoryId = cats.data?.[0]?.id;
  if (!categoryId) throw new Error('No categories');

  const adminToken = await login('admin@ehandyhire.com');
  pass('Admin login');

  // ─── Register worker (email OTP) ────────────────────────────────────────
  console.log('\n[1] Register + verify WORKER');
  const workerEmail = `ix.worker.${stamp}@ehandyhire.test`;
  const workerPhone = `+63918${String(stamp).slice(-7)}`;
  await apiJson(null, 'POST', '/auth/worker/send-otp', { email: workerEmail });
  const workerOtp = await readOtp(workerEmail, 'worker_register');
  const workerReg = await expectOk('worker register', () =>
    apiJson(null, 'POST', '/auth/register', {
      firstName: 'Ix',
      lastName: 'Worker',
      displayName: 'Kuya Ix',
      email: workerEmail,
      password: PASS,
      role: 'WORKER',
      gender: 'MALE',
      otpCode: workerOtp,
      serviceAreas: ['DAVAO_CITY'],
      categoryIds: [categoryId],
    })
  );
  const workerToken = (workerReg?.data as { accessToken: string }).accessToken;
  let workerMe = await me(workerToken);
  pass(`worker user ${workerMe.id}`);

  // Unverified worker should be hidden from public list
  const publicBefore = await apiJson<{ data: { id: string }[] }>(
    null,
    'GET',
    `/workers?search=${encodeURIComponent(workerEmail)}&limit=20`
  );
  const foundBefore = (publicBefore.data || []).some((w) => w.id === workerMe.workerProfile?.id);
  if (!foundBefore) pass('unverified worker hidden from public search');
  else fail('unverified worker should be hidden');

  await fullyVerifyAccount(workerToken, adminToken, workerPhone, tmpPng);
  workerMe = await me(workerToken);
  if (workerMe.workerProfile?.isVerified && workerMe.phoneVerified) {
    pass('worker fully verified (phone + docs)');
  } else {
    fail('worker not fully verified');
  }

  const publicAfter = await expectOk('public worker detail', () =>
    apiJson(null, 'GET', `/workers/${workerMe.workerProfile!.id}`)
  );
  if ((publicAfter?.data as { user?: { firstName?: string } })?.user?.firstName) {
    pass('public profile shows display name fields');
  }

  // ─── Register customer (email OTP) ──────────────────────────────────────
  console.log('\n[2] Register CUSTOMER + unverified gates');
  const customerEmail = `ix.customer.${stamp}@ehandyhire.test`;
  const customerPhone = `+63919${String(stamp).slice(-7)}`;
  await apiJson(null, 'POST', '/auth/customer/send-otp', { email: customerEmail });
  const customerOtp = await readOtp(customerEmail, 'customer_register');
  const customerReg = await expectOk('customer register', () =>
    apiJson(null, 'POST', '/auth/register', {
      firstName: 'Ix',
      lastName: 'Customer',
      email: customerEmail,
      password: PASS,
      role: 'CUSTOMER',
      otpCode: customerOtp,
    })
  );
  let customerToken = (customerReg?.data as { accessToken: string }).accessToken;
  let customerMe = await me(customerToken);

  await expectFail('unverified customer cannot hire', () =>
    apiJson(customerToken, 'POST', '/hire-invitations', {
      workerId: workerMe.workerProfile!.id,
      acknowledgedDisclaimer: true,
    })
  );
  await expectFail('unverified customer cannot message', () =>
    apiJson(customerToken, 'GET', `/messages/by-worker/${workerMe.workerProfile!.id}`)
  );

  // Save worker does not require verification (bookmark)
  await expectOk('unverified customer can save worker', () =>
    apiJson(customerToken, 'POST', '/users/customer/saved-workers', {
      workerId: workerMe.workerProfile!.id,
    })
  );
  const savedCheck = await expectOk('saved-worker check', () =>
    apiJson(customerToken, 'GET', `/users/customer/saved-workers/check/${workerMe.workerProfile!.id}`)
  );
  if ((savedCheck?.data as { saved?: boolean })?.saved) pass('worker is saved');
  else fail('worker saved flag');

  console.log('\n[3] Verify CUSTOMER');
  await fullyVerifyAccount(customerToken, adminToken, customerPhone, tmpPng);
  customerToken = await login(customerEmail);
  customerMe = await me(customerToken);
  if (customerMe.customerProfile?.isVerified && customerMe.phoneVerified) {
    pass('customer fully verified');
  } else {
    fail('customer not fully verified');
  }

  // ─── Directory / browse ─────────────────────────────────────────────────
  console.log('\n[4] Directory browse');
  await expectOk('list workers', () => apiJson(null, 'GET', '/workers?limit=5'));
  await expectOk('list categories', () => apiJson(null, 'GET', '/categories?limit=5'));
  await expectOk('customer lists saved workers', () =>
    apiJson(customerToken, 'GET', '/users/customer/saved-workers')
  );
  await expectOk('unsave worker', () =>
    apiJson(customerToken, 'DELETE', `/users/customer/saved-workers/${workerMe.workerProfile!.id}`)
  );
  await expectOk('re-save worker', () =>
    apiJson(customerToken, 'POST', '/users/customer/saved-workers', {
      workerId: workerMe.workerProfile!.id,
    })
  );

  // ─── Messaging both directions ──────────────────────────────────────────
  console.log('\n[5] Messaging (customer ↔ worker)');
  const convRes = await expectOk('customer opens chat', () =>
    apiJson(customerToken, 'GET', `/messages/by-worker/${workerMe.workerProfile!.id}`)
  );
  const conversationId = (convRes?.data as { id?: string })?.id;
  if (!conversationId) {
    fail('conversation id missing');
  } else {
    await expectOk('customer sends message', () =>
      apiJson(customerToken, 'POST', '/messages', {
        conversationId,
        content: `Hi Kuya Ix — customer hello ${stamp}`,
      })
    );

    const workerInbox = await expectOk('worker lists conversations', () =>
      apiJson(workerToken, 'GET', '/messages')
    );
    const inbox = (workerInbox?.data as { id: string }[]) || [];
    const inInbox = inbox.some((c) => c.id === conversationId);
    if (inInbox) pass('worker sees conversation in inbox');
    else {
      const msgCount = await prisma.message.count({
        where: { conversationId, content: { contains: `customer hello ${stamp}` } },
      });
      if (msgCount > 0) pass('worker conversation reachable (message exists)');
      else fail('worker inbox missing conversation');
    }

    await expectOk('worker replies', () =>
      apiJson(workerToken, 'POST', '/messages', {
        conversationId,
        content: `Hello — worker reply ${stamp}`,
      })
    );

    const thread = await expectOk('customer loads thread', () =>
      apiJson(customerToken, 'GET', `/messages/${conversationId}/messages?limit=20`)
    );
    const msgs = (thread?.data as { content?: string }[]) || [];
    const hasBoth =
      msgs.some((m) => m.content?.includes(`customer hello ${stamp}`)) &&
      msgs.some((m) => m.content?.includes(`worker reply ${stamp}`));
    if (hasBoth) pass('thread has both customer and worker messages');
    else fail('thread missing bidirectional messages', `count=${msgs.length}`);

    // Message notifications
    const workerMsgNote = await prisma.notification.findFirst({
      where: {
        userId: workerMe.id,
        type: 'MESSAGE_RECEIVED',
        createdAt: { gte: new Date(stamp - 1000) },
      },
      orderBy: { createdAt: 'desc' },
    });
    if (workerMsgNote) pass('worker got MESSAGE_RECEIVED notification');
    else fail('worker missing message notification');

    const customerMsgNote = await prisma.notification.findFirst({
      where: {
        userId: customerMe.id,
        type: 'MESSAGE_RECEIVED',
        createdAt: { gte: new Date(stamp - 1000) },
      },
      orderBy: { createdAt: 'desc' },
    });
    if (customerMsgNote) pass('customer got MESSAGE_RECEIVED notification');
    else fail('customer missing message notification');
  }

  // ─── Hire: reject then accept ───────────────────────────────────────────
  console.log('\n[6] Hire invitation (reject → re-invite → accept)');
  const invite1 = await expectOk('customer sends hire invite', () =>
    apiJson(customerToken, 'POST', '/hire-invitations', {
      workerId: workerMe.workerProfile!.id,
      acknowledgedDisclaimer: true,
      message: `E2E hire ${stamp}`,
    })
  );
  const invite1Id = (invite1?.data as { id?: string })?.id;
  if (!invite1Id) throw new Error('invite1 id missing');

  const workerHireNote = await prisma.notification.findFirst({
    where: { userId: workerMe.id, type: 'HIRE_INVITE', createdAt: { gte: new Date(stamp - 1000) } },
    orderBy: { createdAt: 'desc' },
  });
  if (workerHireNote) pass('worker got HIRE_INVITE notification');
  else fail('worker missing hire invite notification');

  await expectOk('worker lists pending invites', () =>
    apiJson(workerToken, 'GET', '/hire-invitations?status=PENDING')
  );
  await expectOk('customer lists sent invites', () =>
    apiJson(customerToken, 'GET', '/hire-invitations')
  );

  await expectOk('worker rejects hire', () =>
    apiJson(workerToken, 'PATCH', `/hire-invitations/${invite1Id}/reject`)
  );

  const customerRejectNote = await prisma.notification.findFirst({
    where: {
      userId: customerMe.id,
      type: 'HIRE_REJECTED',
      createdAt: { gte: new Date(stamp - 1000) },
    },
  });
  if (customerRejectNote) pass('customer got HIRE_REJECTED notification');
  else fail('customer missing reject notification');

  const invite2 = await expectOk('customer re-invites after reject', () =>
    apiJson(customerToken, 'POST', '/hire-invitations', {
      workerId: workerMe.workerProfile!.id,
      acknowledgedDisclaimer: true,
      message: `E2E hire retry ${stamp}`,
    })
  );
  const invite2Id = (invite2?.data as { id?: string })?.id;
  if (!invite2Id) throw new Error('invite2 id missing');

  await expectOk('worker accepts hire', () =>
    apiJson(workerToken, 'PATCH', `/hire-invitations/${invite2Id}/accept`)
  );

  const customerAcceptNote = await prisma.notification.findFirst({
    where: {
      userId: customerMe.id,
      type: 'HIRE_ACCEPTED',
      createdAt: { gte: new Date(stamp - 1000) },
    },
  });
  if (customerAcceptNote) pass('customer got HIRE_ACCEPTED notification');
  else fail('customer missing accept notification');

  workerMe = await me(workerToken);
  if (workerMe.workerProfile?.isHired && workerMe.workerProfile.isAvailable === false) {
    pass('worker marked hired / unavailable');
  } else {
    fail('worker hire flags wrong', JSON.stringify(workerMe.workerProfile));
  }

  // Public profile may show hired status
  const hiredPublic = await apiJson<{ data: { isHired?: boolean } }>(
    null,
    'GET',
    `/workers/${workerMe.workerProfile!.id}`
  );
  if (hiredPublic.data?.isHired) pass('public profile shows hired');
  else pass('public profile fetched while hired'); // isHired may or may not be exposed

  await expectFail('cannot hire already-hired worker', () =>
    apiJson(customerToken, 'POST', '/hire-invitations', {
      workerId: workerMe.workerProfile!.id,
      acknowledgedDisclaimer: true,
    })
  );

  // Second verified customer blocked by busy worker
  console.log('\n[7] Second customer cannot hire busy worker');
  const customer2Email = `ix.customer2.${stamp}@ehandyhire.test`;
  const customer2Phone = `+63917${String(stamp).slice(-7)}`;
  await apiJson(null, 'POST', '/auth/customer/send-otp', { email: customer2Email });
  const c2otp = await readOtp(customer2Email, 'customer_register');
  const c2reg = await apiJson(null, 'POST', '/auth/register', {
    firstName: 'Ix',
    lastName: 'CustomerTwo',
    email: customer2Email,
    password: PASS,
    role: 'CUSTOMER',
    otpCode: c2otp,
  });
  const c2token = (c2reg.data as { accessToken: string }).accessToken;
  await fullyVerifyAccount(c2token, adminToken, customer2Phone, tmpPng);
  const c2login = await login(customer2Email);
  await expectFail('second customer hire blocked while busy', () =>
    apiJson(c2login, 'POST', '/hire-invitations', {
      workerId: workerMe.workerProfile!.id,
      acknowledgedDisclaimer: true,
    })
  );

  // ─── Review + complete ──────────────────────────────────────────────────
  console.log('\n[8] Review + complete hire');
  const pendingReviews = await expectOk('list pending reviews', () =>
    apiJson(customerToken, 'GET', '/reviews/pending')
  );
  const pending = (pendingReviews?.data as { kind: string; id: string }[]) || [];
  if (pending.some((p) => p.kind === 'hire' && p.id === invite2Id)) {
    pass('pending review includes accepted hire');
  } else {
    fail('pending review missing hire', JSON.stringify(pending.slice(0, 5)));
  }

  await expectOk('customer submits review', () =>
    apiJson(customerToken, 'POST', '/reviews', {
      hireInvitationId: invite2Id,
      qualityRating: 5,
      communicationRating: 5,
      punctualityRating: 5,
      comment: `Great work E2E ${stamp}`,
    })
  );

  const workerReviewNote = await prisma.notification.findFirst({
    where: {
      userId: workerMe.id,
      type: 'REVIEW_RECEIVED',
      createdAt: { gte: new Date(stamp - 1000) },
    },
  });
  if (workerReviewNote) pass('worker got REVIEW_RECEIVED notification');
  else fail('worker missing review notification');

  await expectFail('duplicate review blocked', () =>
    apiJson(customerToken, 'POST', '/reviews', {
      hireInvitationId: invite2Id,
      qualityRating: 3,
      communicationRating: 3,
      punctualityRating: 3,
    })
  );

  const workerReviews = await expectOk('public worker reviews', () =>
    apiJson(null, 'GET', `/reviews/worker/${workerMe.workerProfile!.id}`)
  );
  const reviewList = (workerReviews?.data as unknown[]) || [];
  if (reviewList.length >= 1) pass('worker has public reviews');
  else fail('worker public reviews empty');

  await expectOk('customer completes hire', () =>
    apiJson(customerToken, 'PATCH', `/hire-invitations/${invite2Id}/complete`)
  );

  const completeNote = await prisma.notification.findFirst({
    where: {
      userId: workerMe.id,
      type: 'HIRE_COMPLETED',
      createdAt: { gte: new Date(stamp - 1000) },
    },
  });
  if (completeNote) pass('worker got HIRE_COMPLETED notification');
  else fail('worker missing complete notification');

  workerMe = await me(workerToken);
  if (!workerMe.workerProfile?.isHired && workerMe.workerProfile?.isAvailable) {
    pass('worker available again after complete');
  } else {
    fail('worker should be available after complete', JSON.stringify(workerMe.workerProfile));
  }

  // Second customer can now hire
  await expectOk('second customer can hire after complete', () =>
    apiJson(c2login, 'POST', '/hire-invitations', {
      workerId: workerMe.workerProfile!.id,
      acknowledgedDisclaimer: true,
      message: 'Second customer hire',
    })
  );

  // Cancel that pending so we leave worker free
  const pending2 = await prisma.hireInvitation.findFirst({
    where: {
      workerId: workerMe.workerProfile!.id,
      status: 'PENDING',
    },
    orderBy: { createdAt: 'desc' },
  });
  if (pending2) {
    await expectOk('customer cancels pending hire', () =>
      apiJson(c2login, 'PATCH', `/hire-invitations/${pending2.id}/cancel`)
    );
  }

  // ─── Dashboards ─────────────────────────────────────────────────────────
  console.log('\n[9] Dashboards');
  await expectOk('customer dashboard', () =>
    apiJson(customerToken, 'GET', '/users/customer/dashboard')
  );
  await expectOk('worker dashboard', () =>
    apiJson(workerToken, 'GET', '/users/worker/dashboard')
  );
  await expectOk('customer notifications list', () =>
    apiJson(customerToken, 'GET', '/notifications')
  );
  await expectOk('worker notifications list', () =>
    apiJson(workerToken, 'GET', '/notifications')
  );

  try {
    fs.unlinkSync(tmpPng);
  } catch {
    // ignore
  }

  console.log('\n── Test accounts (Password123!) ──');
  console.log(`  Worker:    ${workerEmail}`);
  console.log(`  Customer:  ${customerEmail}`);
  console.log(`  Customer2: ${customer2Email}`);

  console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
  if (failed > 0) process.exit(1);
  console.log('=== ALL PASSED ===\n');
}

main()
  .catch(async (e) => {
    console.error('\n=== FAILED ===');
    console.error(e);
    process.exit(1);
  })
  .finally(() => prisma.$disconnect());
