import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock postgresClient and phishing-audit BEFORE importing the module under test.
const queryMock = vi.fn();
const transactionMock = vi.fn();
vi.mock('./postgres-client', () => ({
  postgresClient: {
    query: (...args: unknown[]) => queryMock(...args),
    transaction: (...args: unknown[]) => transactionMock(...args),
  },
}));

const writeAuditEventMock = vi.fn();
vi.mock('./phishing-audit', () => ({
  writeAuditEvent: (...args: unknown[]) => writeAuditEventMock(...args),
}));

const generateAndPostAcknowledgmentMock = vi.fn();
const generateAndPostAccidentalReportNoteMock = vi.fn();
vi.mock('./triage-note-service', () => ({
  generateAndPostAcknowledgment: (...args: unknown[]) => generateAndPostAcknowledgmentMock(...args),
  generateAndPostAccidentalReportNote: (...args: unknown[]) => generateAndPostAccidentalReportNoteMock(...args),
}));

// eslint-disable-next-line import/first -- imported after vi.mock hoisting
import {
  approveRemediationActions,
  remediateApprovedActions,
  markCampaignFalsePositive,
  markCampaignAccidentalReport,
  autoPostAcknowledgment,
  RemediationValidationError,
  RemediationConflictError,
} from './remediation-service';

/** Records every SQL call made through the fake transaction client. */
let clientCalls: Array<{ sql: string; params: unknown[] }> = [];

interface MockRows {
  classification?: unknown[];
  insertRemediation?: unknown[];
  remediationRows?: unknown[];
  campaign?: unknown[];
  guardRows?: unknown[];
  existingAckRows?: unknown[];
}

function makeClient(rows: MockRows) {
  return {
    query: vi.fn(async (sql: string, params?: unknown[]) => {
      clientCalls.push({ sql, params: params ?? [] });

      if (sql.includes('SELECT id FROM campaigns') && sql.includes('FOR UPDATE')) {
        return { rows: [{ id: 'campaign-1' }], rowCount: 1 };
      }
      if (sql.includes("action_type = 'acknowledge_user'")) {
        return { rows: rows.existingAckRows ?? [], rowCount: rows.existingAckRows?.length ?? 0 };
      }
      if (sql.includes('FROM classifications')) {
        return { rows: rows.classification ?? [], rowCount: rows.classification?.length ?? 0 };
      }
      if (sql.includes('INSERT INTO remediation_actions')) {
        const insertRows = rows.insertRemediation ?? [];
        return { rows: [insertRows.shift() ?? { id: 'unstaged-action-id' }], rowCount: 1 };
      }
      if (sql.includes('SELECT id::text, action_type, status') && sql.includes('FOR UPDATE')) {
        return { rows: rows.remediationRows ?? [], rowCount: rows.remediationRows?.length ?? 0 };
      }
      if (sql.includes('UPDATE remediation_actions SET status')) {
        return { rows: [], rowCount: 1 };
      }
      if (sql.includes('FROM remediation_actions') && sql.includes('status IN')) {
        return { rows: rows.guardRows ?? [], rowCount: rows.guardRows?.length ?? 0 };
      }
      if (sql.includes('SELECT status FROM campaigns')) {
        return { rows: rows.campaign ?? [], rowCount: rows.campaign?.length ?? 0 };
      }
      if (sql.includes('UPDATE campaigns SET status')) {
        return { rows: [], rowCount: 1 };
      }
      throw new Error(`Unstaged query in test mock: ${sql}`);
    }),
  };
}

function callsContaining(needle: string) {
  return clientCalls.filter((c) => c.sql.includes(needle));
}

function stage(rows: MockRows) {
  transactionMock.mockImplementation(async (callback: (client: unknown) => Promise<unknown>) =>
    callback(makeClient(rows))
  );
}

beforeEach(() => {
  queryMock.mockReset();
  transactionMock.mockReset();
  writeAuditEventMock.mockReset();
  writeAuditEventMock.mockResolvedValue('audit-id');
  generateAndPostAcknowledgmentMock.mockReset();
  generateAndPostAcknowledgmentMock.mockResolvedValue({ noteText: 'thanks', tickets: [] });
  generateAndPostAccidentalReportNoteMock.mockReset();
  generateAndPostAccidentalReportNoteMock.mockResolvedValue({ noteText: 'no action needed', tickets: [] });
  clientCalls = [];
});

// =============================================================================
// approveRemediationActions
// =============================================================================

describe('approveRemediationActions', () => {
  it('rejects an action type that is NOT in the latest classification recommended_actions', async () => {
    stage({
      classification: [{ recommended_actions: ['warn_user'] }],
    });

    await expect(
      approveRemediationActions('campaign-1', [{ actionType: 'block_sender' }], 'operator@example.com')
    ).rejects.toThrow(RemediationValidationError);
  });

  it('throws RemediationValidationError when no classification row exists for the campaign', async () => {
    stage({ classification: [] });

    await expect(
      approveRemediationActions('campaign-1', [{ actionType: 'warn_user' }], 'operator@example.com')
    ).rejects.toThrow(RemediationValidationError);
  });

  it('inserts one remediation_actions row per requested action plus one audit row, inside one transaction', async () => {
    stage({
      classification: [{ recommended_actions: ['block_sender', 'purge_message'] }],
      insertRemediation: [{ id: 'action-1' }, { id: 'action-2' }],
    });

    const result = await approveRemediationActions(
      'campaign-1',
      [
        { actionType: 'block_sender', params: { sender: 'evil@example.com' } },
        { actionType: 'purge_message', params: {} },
      ],
      'operator@example.com'
    );

    expect(transactionMock).toHaveBeenCalledTimes(1);
    const insertCalls = callsContaining('INSERT INTO remediation_actions');
    expect(insertCalls).toHaveLength(2);
    expect(insertCalls[0].sql).toContain("'approved'");
    expect(insertCalls[0].params).toEqual([
      'campaign-1',
      'block_sender',
      JSON.stringify({ sender: 'evil@example.com' }),
      'operator@example.com',
    ]);

    expect(writeAuditEventMock).toHaveBeenCalledTimes(1);
    const [auditArgs, auditClient] = writeAuditEventMock.mock.calls[0];
    expect(auditArgs).toMatchObject({
      campaignId: 'campaign-1',
      actor: 'operator@example.com',
      eventType: 'remediation_approved',
    });
    expect(auditClient).toBeDefined();

    expect(result).toHaveLength(2);
    expect(result[0]).toMatchObject({ id: 'action-1', actionType: 'block_sender', status: 'approved' });
  });

  it('rejects re-approving acknowledge_user when it has already been posted for the campaign (CR-02 fix)', async () => {
    stage({
      classification: [{ recommended_actions: ['acknowledge_user'] }],
      existingAckRows: [{ id: 'auto-posted-action' }],
    });

    await expect(
      approveRemediationActions('campaign-1', [{ actionType: 'acknowledge_user' }], 'operator@example.com')
    ).rejects.toThrow(RemediationValidationError);
    expect(callsContaining('INSERT INTO remediation_actions')).toHaveLength(0);
  });

  it('still allows approving acknowledge_user when no prior post exists for the campaign', async () => {
    stage({
      classification: [{ recommended_actions: ['acknowledge_user'] }],
      existingAckRows: [],
      insertRemediation: [{ id: 'action-1' }],
    });

    const result = await approveRemediationActions(
      'campaign-1',
      [{ actionType: 'acknowledge_user' }],
      'operator@example.com'
    );

    expect(result).toHaveLength(1);
    expect(result[0]).toMatchObject({ id: 'action-1', actionType: 'acknowledge_user', status: 'approved' });
  });
});

// =============================================================================
// remediateApprovedActions
// =============================================================================

describe('remediateApprovedActions', () => {
  it('transitions every status=approved row to completed and writes one audit row per transitioned action', async () => {
    stage({
      remediationRows: [
        { id: 'action-1', action_type: 'block_sender', status: 'approved' },
        { id: 'action-2', action_type: 'purge_message', status: 'approved' },
      ],
    });

    const result = await remediateApprovedActions('campaign-1', 'operator@example.com');

    const updateCalls = callsContaining('UPDATE remediation_actions SET status');
    expect(updateCalls).toHaveLength(2);
    expect(writeAuditEventMock).toHaveBeenCalledTimes(2);
    expect(writeAuditEventMock.mock.calls[0][0]).toMatchObject({
      campaignId: 'campaign-1',
      eventType: 'remediation_completed',
    });
    expect(result.actions.every((a) => a.status === 'completed')).toBe(true);
  });

  it('is idempotent: a second call transitions nothing and writes no second audit row (REMED-04)', async () => {
    // First call: two approved rows.
    stage({
      remediationRows: [
        { id: 'action-1', action_type: 'block_sender', status: 'approved' },
        { id: 'action-2', action_type: 'purge_message', status: 'approved' },
      ],
    });
    await remediateApprovedActions('campaign-1', 'operator@example.com');
    expect(writeAuditEventMock).toHaveBeenCalledTimes(2);

    // Second call: same rows are now already completed — status filter finds nothing to transition.
    clientCalls = [];
    stage({
      remediationRows: [
        { id: 'action-1', action_type: 'block_sender', status: 'completed' },
        { id: 'action-2', action_type: 'purge_message', status: 'completed' },
      ],
    });
    const result2 = await remediateApprovedActions('campaign-1', 'operator@example.com');

    expect(callsContaining('UPDATE remediation_actions SET status')).toHaveLength(0);
    // Still exactly 2 total audit calls across both calls (no additional on the second).
    expect(writeAuditEventMock).toHaveBeenCalledTimes(2);
    expect(result2.actions.every((a) => a.alreadyCompleted)).toBe(true);
  });

  it('raises an explicit failure (RemediationValidationError) when the campaign has zero remediation_actions rows', async () => {
    stage({ remediationRows: [] });

    await expect(remediateApprovedActions('campaign-1', 'operator@example.com')).rejects.toThrow(
      RemediationValidationError
    );
    expect(writeAuditEventMock).not.toHaveBeenCalled();
  });

  it('calls generateAndPostAcknowledgment exactly once with the campaignId when an approved acknowledge_user row is remediated', async () => {
    stage({
      remediationRows: [{ id: 'action-1', action_type: 'acknowledge_user', status: 'approved' }],
    });

    await remediateApprovedActions('campaign-1', 'operator@example.com');

    expect(generateAndPostAcknowledgmentMock).toHaveBeenCalledTimes(1);
    expect(generateAndPostAcknowledgmentMock).toHaveBeenCalledWith('campaign-1');
  });

  it('does NOT call generateAndPostAcknowledgment for a block_sender/warn_user-only remediation', async () => {
    stage({
      remediationRows: [
        { id: 'action-1', action_type: 'block_sender', status: 'approved' },
        { id: 'action-2', action_type: 'warn_user', status: 'approved' },
      ],
    });

    await remediateApprovedActions('campaign-1', 'operator@example.com');

    expect(generateAndPostAcknowledgmentMock).not.toHaveBeenCalled();
  });

  it('does NOT call generateAndPostAcknowledgment when the acknowledge_user row staged is already completed (idempotent re-run)', async () => {
    stage({
      remediationRows: [{ id: 'action-1', action_type: 'acknowledge_user', status: 'completed' }],
    });

    await remediateApprovedActions('campaign-1', 'operator@example.com');

    expect(generateAndPostAcknowledgmentMock).not.toHaveBeenCalled();
  });

  it('does not propagate a generateAndPostAcknowledgment rejection out of remediateApprovedActions (already-committed transition)', async () => {
    stage({
      remediationRows: [{ id: 'action-1', action_type: 'acknowledge_user', status: 'approved' }],
    });
    generateAndPostAcknowledgmentMock.mockRejectedValueOnce(new Error('Autotask unavailable'));

    const result = await remediateApprovedActions('campaign-1', 'operator@example.com');

    expect(result.actions[0]).toMatchObject({ actionType: 'acknowledge_user', status: 'completed' });
  });
});

// =============================================================================
// markCampaignFalsePositive (D-04 guard)
// =============================================================================

describe('markCampaignFalsePositive', () => {
  it('throws RemediationConflictError when any remediation_actions row has status approved/completed', async () => {
    stage({
      guardRows: [{ id: 'action-1' }],
    });

    await expect(
      markCampaignFalsePositive('campaign-1', 'operator@example.com')
    ).rejects.toThrow(RemediationConflictError);
    expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(0);
  });

  it('sets campaigns.status to false_positive and writes one audit row when there are no approved/completed rows', async () => {
    stage({
      guardRows: [],
      campaign: [{ status: 'open' }],
    });

    const result = await markCampaignFalsePositive('campaign-1', 'operator@example.com', 'confirmed benign');

    expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(1);
    expect(writeAuditEventMock).toHaveBeenCalledTimes(1);
    const [auditArgs] = writeAuditEventMock.mock.calls[0];
    expect(auditArgs).toMatchObject({
      campaignId: 'campaign-1',
      eventType: 'campaign_marked_false_positive',
      payload: { previousStatus: 'open', reason: 'confirmed benign' },
    });
    expect(result).toMatchObject({ campaignId: 'campaign-1', status: 'false_positive' });
  });

  it('throws RemediationValidationError when the campaign does not exist', async () => {
    stage({
      guardRows: [],
      campaign: [],
    });

    await expect(
      markCampaignFalsePositive('campaign-missing', 'operator@example.com')
    ).rejects.toThrow(RemediationValidationError);
  });
});

// =============================================================================
// markCampaignAccidentalReport (D-04 guard, quick task 260717-v6c)
// =============================================================================

describe('markCampaignAccidentalReport', () => {
  it('throws RemediationConflictError when any remediation_actions row has status approved/completed', async () => {
    stage({
      guardRows: [{ id: 'action-1' }],
    });

    await expect(
      markCampaignAccidentalReport('campaign-1', 'operator@example.com')
    ).rejects.toThrow(RemediationConflictError);
    expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(0);
    expect(generateAndPostAccidentalReportNoteMock).not.toHaveBeenCalled();
  });

  it('sets campaigns.status to accidental_report and writes one audit row when there are no approved/completed rows', async () => {
    stage({
      guardRows: [],
      campaign: [{ status: 'open' }],
    });

    const result = await markCampaignAccidentalReport('campaign-1', 'operator@example.com', 'reported by mistake');

    expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(1);
    expect(writeAuditEventMock).toHaveBeenCalledTimes(1);
    const [auditArgs] = writeAuditEventMock.mock.calls[0];
    expect(auditArgs).toMatchObject({
      campaignId: 'campaign-1',
      eventType: 'campaign_marked_accidental_report',
      payload: { previousStatus: 'open', reason: 'reported by mistake' },
    });
    expect(result).toMatchObject({
      campaignId: 'campaign-1',
      status: 'accidental_report',
      notePosted: true,
    });
    expect(generateAndPostAccidentalReportNoteMock).toHaveBeenCalledTimes(1);
    expect(generateAndPostAccidentalReportNoteMock).toHaveBeenCalledWith('campaign-1');
  });

  it('throws RemediationValidationError when the campaign does not exist', async () => {
    stage({
      guardRows: [],
      campaign: [],
    });

    await expect(
      markCampaignAccidentalReport('campaign-missing', 'operator@example.com')
    ).rejects.toThrow(RemediationValidationError);
    expect(generateAndPostAccidentalReportNoteMock).not.toHaveBeenCalled();
  });

  it('still returns the committed status change with notePosted:false and noteError set when the note post fails', async () => {
    stage({
      guardRows: [],
      campaign: [{ status: 'open' }],
    });
    generateAndPostAccidentalReportNoteMock.mockRejectedValueOnce(new Error('Autotask unavailable'));

    const result = await markCampaignAccidentalReport('campaign-1', 'operator@example.com');

    expect(callsContaining('UPDATE campaigns SET status')).toHaveLength(1);
    expect(result).toMatchObject({
      campaignId: 'campaign-1',
      status: 'accidental_report',
      notePosted: false,
      noteError: 'Autotask unavailable',
    });
  });
});

// =============================================================================
// autoPostAcknowledgment (AUTOGATE-03 gap closure — CR-01 / WR-01)
// =============================================================================

describe('autoPostAcknowledgment', () => {
  it('first pass: inserts one acknowledge_user row, writes one audit row (with actionId), posts the note once, returns posted:true', async () => {
    stage({ existingAckRows: [], insertRemediation: [{ id: 'auto-action-1' }] });

    const result = await autoPostAcknowledgment('campaign-1', 'system:auto_report');

    const insertCalls = callsContaining('INSERT INTO remediation_actions');
    expect(insertCalls).toHaveLength(1);
    expect(insertCalls[0].sql).toContain("'acknowledge_user'");
    expect(insertCalls[0].sql).toContain("'completed'");
    expect(insertCalls[0].sql).toContain('RETURNING id');
    expect(insertCalls[0].params).toEqual(['campaign-1', 'system:auto_report']);

    expect(writeAuditEventMock).toHaveBeenCalledTimes(1);
    expect(writeAuditEventMock.mock.calls[0][0]).toMatchObject({
      campaignId: 'campaign-1',
      actor: 'system:auto_report',
      eventType: 'remediation_completed',
      payload: { actionId: 'auto-action-1', actionType: 'acknowledge_user', auto: true },
    });

    expect(generateAndPostAcknowledgmentMock).toHaveBeenCalledTimes(1);
    expect(generateAndPostAcknowledgmentMock).toHaveBeenCalledWith('campaign-1');

    expect(result).toEqual({ posted: true });
  });

  it('idempotency (THE CR-01 FIX): an existing acknowledge_user row skips insert, audit, and note post, returns posted:false', async () => {
    stage({ existingAckRows: [{ id: 'existing-action-1' }] });

    const result = await autoPostAcknowledgment('campaign-1', 'system:auto_report');

    expect(callsContaining('INSERT INTO remediation_actions')).toHaveLength(0);
    expect(writeAuditEventMock).not.toHaveBeenCalled();
    expect(generateAndPostAcknowledgmentMock).not.toHaveBeenCalled();
    expect(result).toEqual({ posted: false });
  });

  it('does not propagate a generateAndPostAcknowledgment rejection (already-committed transition)', async () => {
    stage({ existingAckRows: [] });
    generateAndPostAcknowledgmentMock.mockRejectedValueOnce(new Error('Autotask unavailable'));

    const result = await autoPostAcknowledgment('campaign-1', 'system:auto_report');

    expect(result).toEqual({ posted: true });
  });
});
