/**
 * Remediation Service (Phase 20)
 *
 * Approve / remediate / mark-false-positive orchestrators for phishing
 * campaigns. Every state change here is proposed-only until an operator
 * explicitly approves it (REMED-01) — no function in this file auto-creates
 * an approved/completed remediation_actions row. Approval only materializes
 * action types that appear in the campaign's latest classification's
 * recommended_actions (REMED-02). Every state change writes exactly one
 * audit_events row, atomically, via writeAuditEvent(client) inside the same
 * postgresClient.transaction as the state write (REMED-06).
 *
 * D-01: this file never has an unimplemented/no-op code path for the
 * remediate step. Its "external effect" is a simulated internal transition
 * (status='approved' -> 'completed') — no real provider call for any of the
 * 7 original action types. The only explicit-failure branch is the
 * zero-approved-actions case (REMED-03) — remediating with nothing approved
 * always throws, never silently no-ops as a success.
 *
 * Phase 23 carve-out (D-04): the ONE exception is the post-commit
 * `acknowledge_user` customer-note post below — a non-destructive thank-you
 * message, not a security action. It runs AFTER the transaction commits
 * (never inside it — the Autotask write is network I/O and must not hold a
 * DB transaction open or risk a post-then-rollback), and its failure is
 * caught/logged, never propagated (the DB transition already succeeded).
 * Every other action type remains a simulated status-only transition.
 */

import { postgresClient } from './postgres-client';
import { writeAuditEvent } from './phishing-audit';
import { generateAndPostAcknowledgment, generateAndPostAccidentalReportNote } from './triage-note-service';

export class RemediationValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'RemediationValidationError';
  }
}

export class RemediationConflictError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'RemediationConflictError';
  }
}

// =============================================================================
// approveRemediationActions (REMED-01, REMED-02, REMED-06)
// =============================================================================

export interface ApproveActionInput {
  actionType: string;
  params?: Record<string, unknown>;
}

export interface ApprovedRemediationAction {
  id: string;
  campaignId: string;
  actionType: string;
  status: 'approved';
  approvedBy: string | null;
}

interface ClassificationRow {
  recommended_actions: string[] | string | null;
}

interface InsertRemediationRow {
  id: string;
}

/** Normalizes the JSONB recommended_actions column into a string[] regardless of driver JSON parsing. */
function parseRecommendedActions(value: string[] | string | null): string[] {
  if (Array.isArray(value)) return value;
  if (typeof value === 'string') {
    try {
      const parsed = JSON.parse(value);
      return Array.isArray(parsed) ? parsed : [];
    } catch {
      return [];
    }
  }
  return [];
}

/**
 * Validates each requested action against the campaign's latest
 * classification's recommended_actions, then inserts one approved
 * remediation_actions row per action plus one atomic audit row — all inside
 * a single transaction.
 */
export async function approveRemediationActions(
  campaignId: string,
  actions: ApproveActionInput[],
  actor: string | null
): Promise<ApprovedRemediationAction[]> {
  return postgresClient.transaction(async (client) => {
    const classificationRes = await client.query<ClassificationRow>(
      `SELECT recommended_actions
         FROM classifications
        WHERE campaign_id = $1
        ORDER BY created_at DESC
        LIMIT 1`,
      [campaignId]
    );
    const classification = classificationRes.rows[0];
    if (!classification) {
      throw new RemediationValidationError('Campaign has no classification to approve against');
    }
    const recommendedActions = new Set(parseRecommendedActions(classification.recommended_actions));

    const requestsAcknowledgeUser = actions.some((action) => action.actionType === 'acknowledge_user');
    let acknowledgeUserAlreadyPosted = false;
    if (requestsAcknowledgeUser) {
      const existingAckRes = await client.query<{ id: string }>(
        `SELECT id FROM remediation_actions WHERE campaign_id = $1 AND action_type = 'acknowledge_user' LIMIT 1`,
        [campaignId]
      );
      acknowledgeUserAlreadyPosted = existingAckRes.rows.length > 0;
    }

    for (const action of actions) {
      if (!recommendedActions.has(action.actionType)) {
        throw new RemediationValidationError(
          `Action ${action.actionType} is not a recommended action for this campaign`
        );
      }
      if (action.actionType === 'acknowledge_user' && acknowledgeUserAlreadyPosted) {
        throw new RemediationValidationError(
          'acknowledge_user has already been posted for this campaign'
        );
      }
    }

    const approved: ApprovedRemediationAction[] = [];
    const actionIds: string[] = [];
    for (const action of actions) {
      const insertRes = await client.query<InsertRemediationRow>(
        `INSERT INTO remediation_actions (campaign_id, action_type, status, params, approved_by, approved_at)
         VALUES ($1, $2, 'approved', $3::jsonb, $4, NOW())
         RETURNING id::text AS id`,
        [campaignId, action.actionType, JSON.stringify(action.params ?? {}), actor]
      );
      const id = insertRes.rows[0].id;
      actionIds.push(id);
      approved.push({
        id,
        campaignId,
        actionType: action.actionType,
        status: 'approved',
        approvedBy: actor,
      });
    }

    await writeAuditEvent(
      { campaignId, actor, eventType: 'remediation_approved', payload: { actions, actionIds } },
      client
    );

    return approved;
  });
}

// =============================================================================
// remediateApprovedActions (REMED-03, REMED-04, REMED-06)
// =============================================================================

interface RemediationActionRow {
  id: string;
  action_type: string;
  status: string;
}

export interface RemediateResultAction {
  id: string;
  actionType: string;
  status: 'completed';
  alreadyCompleted: boolean;
}

export interface RemediateResult {
  campaignId: string;
  actions: RemediateResultAction[];
}

/**
 * Transitions every status='approved' row for the campaign to 'completed'
 * (the D-01 simulated internal effect — no real external provider call for
 * any action type) and writes one 'remediation_completed' audit row per
 * transitioned action. Rows already 'completed' are left untouched and
 * generate NO audit row — the status='approved' filter + FOR UPDATE is the
 * idempotency mechanism: a re-run finds no approved rows and transitions/
 * audits nothing (REMED-04). A campaign with zero remediation_actions rows
 * throws explicitly (REMED-03) — this function never returns a silent
 * success for "nothing to do".
 */
export async function remediateApprovedActions(campaignId: string, actor: string | null): Promise<RemediateResult> {
  const result = await postgresClient.transaction(async (client) => {
    const rowsRes = await client.query<RemediationActionRow>(
      `SELECT id::text, action_type, status FROM remediation_actions WHERE campaign_id = $1 FOR UPDATE`,
      [campaignId]
    );

    if (rowsRes.rows.length === 0) {
      throw new RemediationValidationError('No remediation actions to remediate — nothing approved');
    }

    const actions: RemediateResultAction[] = [];
    for (const row of rowsRes.rows) {
      if (row.status === 'approved') {
        await client.query(`UPDATE remediation_actions SET status = 'completed' WHERE id = $1`, [row.id]);
        await writeAuditEvent(
          {
            campaignId,
            actor,
            eventType: 'remediation_completed',
            payload: { actionId: row.id, actionType: row.action_type },
          },
          client
        );
        actions.push({ id: row.id, actionType: row.action_type, status: 'completed', alreadyCompleted: false });
      } else {
        // Already-completed (or otherwise non-approved) rows are left
        // untouched and generate no audit row — idempotency (REMED-04).
        actions.push({ id: row.id, actionType: row.action_type, status: 'completed', alreadyCompleted: true });
      }
    }

    return { campaignId, actions };
  });

  // Phase 23 D-04 carve-out: post the real customer-visible acknowledgment
  // note AFTER the transaction has committed — only when an approved
  // acknowledge_user row was actually transitioned this pass (never on the
  // already-completed idempotent re-run). Never held inside the DB
  // transaction (network I/O), and never allowed to propagate (the DB
  // transition already succeeded; generateAndPostAcknowledgment already
  // isolates per-ticket failures internally).
  const shouldPostAcknowledgment = result.actions.some(
    (action) => action.actionType === 'acknowledge_user' && action.alreadyCompleted === false
  );
  if (shouldPostAcknowledgment) {
    try {
      await generateAndPostAcknowledgment(campaignId);
    } catch (err) {
      console.error('[REMEDIATE] acknowledge_user note post failed', campaignId, err);
    }
  }

  return result;
}

// =============================================================================
// markCampaignFalsePositive (D-04 guard, REMED-05, REMED-06)
// =============================================================================

interface CampaignStatusRow {
  status: string;
}

export interface MarkFalsePositiveResult {
  campaignId: string;
  status: 'false_positive';
  auditEventId: string;
}

/**
 * D-04 guard: rejects with RemediationConflictError when any
 * approved/completed remediation exists for the campaign — a campaign can
 * never be both remediated and false-positive. Otherwise sets
 * campaigns.status='false_positive' and writes one atomic audit row
 * recording the previous status and the optional reason. False-positive
 * reversibility is out of scope (CONTEXT.md Deferred Ideas) — no un-mark
 * path exists.
 */
export async function markCampaignFalsePositive(
  campaignId: string,
  actor: string | null,
  reason?: string
): Promise<MarkFalsePositiveResult> {
  return postgresClient.transaction(async (client) => {
    const guardRes = await client.query<{ id: string }>(
      `SELECT id FROM remediation_actions
        WHERE campaign_id = $1 AND status IN ('approved', 'completed')
        FOR UPDATE
        LIMIT 1`,
      [campaignId]
    );
    if (guardRes.rows.length > 0) {
      throw new RemediationConflictError(
        'Cannot mark false positive: campaign already has approved or completed remediation'
      );
    }

    const campaignRes = await client.query<CampaignStatusRow>(
      `SELECT status FROM campaigns WHERE id = $1`,
      [campaignId]
    );
    const campaign = campaignRes.rows[0];
    if (!campaign) {
      throw new RemediationValidationError('Campaign not found');
    }
    const previousStatus = campaign.status;

    await client.query(
      `UPDATE campaigns SET status = 'false_positive', updated_at = NOW() WHERE id = $1`,
      [campaignId]
    );

    const auditEventId = await writeAuditEvent(
      {
        campaignId,
        actor,
        eventType: 'campaign_marked_false_positive',
        payload: { previousStatus, reason: reason ?? null },
      },
      client
    );

    return { campaignId, status: 'false_positive', auditEventId };
  });
}

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

export interface MarkAccidentalReportResult {
  campaignId: string;
  status: 'accidental_report';
  auditEventId: string;
  notePosted: boolean;
  noteError?: string;
}

/**
 * D-04 guard: rejects with RemediationConflictError when any
 * approved/completed remediation exists for the campaign — mirrors
 * markCampaignFalsePositive exactly. Otherwise sets
 * campaigns.status='accidental_report' and writes one atomic audit row
 * recording the previous status and the optional reason. AFTER the
 * transaction commits, posts a fixed-template customer-visible note to every
 * reporting employee's ticket (generateAndPostAccidentalReportNote) — this
 * external Autotask call runs outside the FOR UPDATE-locked transaction and
 * its failure is caught/logged, never propagated: the committed status
 * change is returned regardless, with notePosted/noteError reflecting the
 * note outcome.
 */
export async function markCampaignAccidentalReport(
  campaignId: string,
  actor: string | null,
  reason?: string
): Promise<MarkAccidentalReportResult> {
  const result = await postgresClient.transaction(async (client) => {
    const guardRes = await client.query<{ id: string }>(
      `SELECT id FROM remediation_actions
        WHERE campaign_id = $1 AND status IN ('approved', 'completed')
        FOR UPDATE
        LIMIT 1`,
      [campaignId]
    );
    if (guardRes.rows.length > 0) {
      throw new RemediationConflictError(
        'Cannot mark accidental report: campaign already has approved or completed remediation'
      );
    }

    const campaignRes = await client.query<CampaignStatusRow>(
      `SELECT status FROM campaigns WHERE id = $1`,
      [campaignId]
    );
    const campaign = campaignRes.rows[0];
    if (!campaign) {
      throw new RemediationValidationError('Campaign not found');
    }
    const previousStatus = campaign.status;

    await client.query(
      `UPDATE campaigns SET status = 'accidental_report', updated_at = NOW() WHERE id = $1`,
      [campaignId]
    );

    const auditEventId = await writeAuditEvent(
      {
        campaignId,
        actor,
        eventType: 'campaign_marked_accidental_report',
        payload: { previousStatus, reason: reason ?? null },
      },
      client
    );

    return { campaignId, status: 'accidental_report' as const, auditEventId };
  });

  let notePosted = false;
  let noteError: string | undefined;
  try {
    await generateAndPostAccidentalReportNote(campaignId);
    notePosted = true;
  } catch (err) {
    console.error('[MARK-ACCIDENTAL-REPORT] accidental-report note post failed', campaignId, err);
    noteError = err instanceof Error ? err.message : 'Unknown error';
  }

  return { ...result, notePosted, noteError };
}

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

export interface AutoPostAcknowledgmentResult {
  posted: boolean;
}

interface ExistingAckActionRow {
  id: string;
}

/**
 * Idempotent, audit-persisting auto-post orchestrator for the auto_report
 * webhook path (runGatedPhishingStages in webhook-service.ts). Mirrors
 * remediateApprovedActions's shape: state write + audit row inside one
 * transaction, customer-visible note post AFTER commit, note-post failure
 * caught/logged and never propagated (the DB transition already committed).
 *
 * Unlike the manual path, this function performs the FIRST write of the
 * campaign's acknowledge_user row: the persisted remediation_actions row it
 * inserts is BOTH the idempotency record a repeat ticket-create webhook for
 * the same campaign reads on its next pass AND the row the Action Area /
 * Timeline UI already render from (closes WR-01, the root cause of CR-01 —
 * repeat webhooks were re-posting the customer-visible note on every
 * additional report joining an already-acknowledged campaign).
 *
 * The `SELECT ... FROM campaigns WHERE id = $1 FOR UPDATE` row lock
 * serializes concurrent webhooks for the same campaign so two simultaneous
 * ticket-create events cannot both pass the existence check and double-post.
 */
export async function autoPostAcknowledgment(
  campaignId: string,
  actor: string | null
): Promise<AutoPostAcknowledgmentResult> {
  const { inserted } = await postgresClient.transaction(async (client) => {
    await client.query(`SELECT id FROM campaigns WHERE id = $1 FOR UPDATE`, [campaignId]);

    const existingRes = await client.query<ExistingAckActionRow>(
      `SELECT id FROM remediation_actions WHERE campaign_id = $1 AND action_type = 'acknowledge_user' LIMIT 1`,
      [campaignId]
    );
    if (existingRes.rows.length > 0) {
      // Already posted for this campaign — no insert, no audit, no note.
      return { inserted: false };
    }

    const insertRes = await client.query<{ id: string }>(
      `INSERT INTO remediation_actions (campaign_id, action_type, status, approved_by, approved_at)
       VALUES ($1, 'acknowledge_user', 'completed', $2, NOW())
       RETURNING id::text AS id`,
      [campaignId, actor]
    );
    await writeAuditEvent(
      {
        campaignId,
        actor,
        eventType: 'remediation_completed',
        payload: { actionId: insertRes.rows[0].id, actionType: 'acknowledge_user', auto: true },
      },
      client
    );

    return { inserted: true };
  });

  if (inserted) {
    try {
      await generateAndPostAcknowledgment(campaignId);
    } catch (err) {
      console.error('[AUTO-REMEDIATE] acknowledge_user note post failed', campaignId, err);
    }
  }

  return { posted: inserted };
}
