import {
  isValidEmail,
  normalizeEmail,
  normalizePersonName,
  normalizePhilippineMobile,
} from '../format/index.js';
import { parseDelimited, type DelimitedFile, type Delimiter } from './delimited.js';

/**
 * Planning a bulk lead import (SPEC.md §9).
 *
 * The whole design rests on one decision: **this module never writes anything.**
 * It reads a file and returns a plan — what would be created, what would be
 * skipped, and exactly why — so staff can look at the outcome before it
 * happens. A bulk import that fails halfway, or that quietly drops a fifth of
 * the rows, is far worse than one that refuses to start.
 *
 * Three rules follow from that, and each is tested:
 *
 * 1. **Consent is never invented.** A historical export carries no proof that
 *    anyone agreed to be contacted. Rows import with `consentGiven: false`
 *    unless the file supplies an explicit consent column, and marketing to them
 *    is then a decision a person has to make (ASSUMPTIONS.md C9).
 * 2. **Duplicates are reported, never merged** — the same stance the CRM takes
 *    everywhere else. Two enquiries from one family mailbox are two people.
 * 3. **The original enquiry date is preserved.** Stamping every historical lead
 *    with today's date would destroy the funnel history the import exists to
 *    recover.
 */

/* -------------------------------------------------------------------------- */
/* Column mapping                                                             */
/* -------------------------------------------------------------------------- */

export type LeadImportField =
  | 'fullName'
  | 'email'
  | 'mobile'
  | 'city'
  | 'province'
  | 'campaign'
  | 'programOfInterest'
  | 'createdAt'
  | 'consent'
  | 'notes'
  | 'utmSource'
  | 'utmMedium'
  | 'utmCampaign'
  | 'utmContent'
  | 'utmTerm';

/**
 * Header aliases, normalized to lowercase alphanumerics.
 *
 * The Facebook Lead Ads export names are first in each list because that is the
 * file this feature exists to read; the rest are what the same data is called
 * when it has been through a spreadsheet on the way (SPEC.md §9).
 */
const FIELD_ALIASES: Readonly<Record<LeadImportField, readonly string[]>> = {
  fullName: ['fullname', 'full_name', 'name', 'leadname', 'contactname', 'pangalan'],
  email: ['email', 'emailaddress', 'email_address', 'e_mail'],
  mobile: [
    'phonenumber',
    'phone_number',
    'mobile',
    'mobilenumber',
    'contactnumber',
    'phone',
    'cellphone',
    'cellphonenumber',
  ],
  city: ['city', 'citytown', 'municipality', 'town'],
  province: ['province', 'stateprovince', 'state', 'region'],
  campaign: ['campaignname', 'campaign_name', 'campaign', 'adname', 'ad_name'],
  programOfInterest: [
    'programofinterest',
    'program',
    'course',
    'courseofinterest',
    'programinterested',
    'preferredprogram',
  ],
  createdAt: ['createdtime', 'created_time', 'createdat', 'datesubmitted', 'submittedat', 'date'],
  consent: ['consent', 'consentgiven', 'dataprivacyconsent', 'optin', 'marketingconsent'],
  notes: ['notes', 'remarks', 'message', 'comments', 'inquiry'],
  utmSource: ['utmsource', 'utm_source', 'source'],
  utmMedium: ['utmmedium', 'utm_medium', 'medium'],
  utmCampaign: ['utmcampaign', 'utm_campaign'],
  utmContent: ['utmcontent', 'utm_content', 'adsetname', 'adset_name'],
  utmTerm: ['utmterm', 'utm_term'],
};

function canonicalizeHeader(header: string): string {
  return header.toLowerCase().replace(/[^a-z0-9]/g, '');
}

export type ColumnMap = Partial<Record<LeadImportField, number>>;

/**
 * Matches header names to fields. Unrecognized columns are ignored rather than
 * rejected — a Lead Ads export carries a dozen advertising identifiers that are
 * of no interest here, and refusing the file over them would be useless.
 */
export function mapColumns(header: readonly string[]): ColumnMap {
  const map: ColumnMap = {};
  const canonical = header.map(canonicalizeHeader);

  for (const [field, aliases] of Object.entries(FIELD_ALIASES) as [
    LeadImportField,
    readonly string[],
  ][]) {
    const index = canonical.findIndex((name) => aliases.includes(name));
    if (index !== -1) map[field] = index;
  }

  return map;
}

/* -------------------------------------------------------------------------- */
/* Value coercion                                                             */
/* -------------------------------------------------------------------------- */

const TRUTHY = new Set(['true', 'yes', 'y', '1', 'oo', 'opted in', 'opt in', 'agreed', 'consented']);
const FALSY = new Set(['false', 'no', 'n', '0', 'hindi', 'opted out', 'declined', '']);

/** Reads a consent cell. Anything unrecognized is treated as *no* consent. */
export function parseConsent(raw: string | undefined): boolean {
  if (raw === undefined) return false;
  const value = raw.trim().toLowerCase();
  if (TRUTHY.has(value)) return true;
  if (FALSY.has(value)) return false;
  // Deliberately conservative: an unreadable consent value is not consent.
  return false;
}

const ISO_DATE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?)?/;

/**
 * Parses an enquiry timestamp.
 *
 * Only ISO 8601 forms are accepted. `03/04/2026` is genuinely ambiguous —
 * 3 April or 4 March depending on who exported it — and guessing would silently
 * misdate a whole batch of leads. An unreadable date is reported, and the row
 * imports with today's date and a warning saying so (ASSUMPTIONS.md C10).
 */
export function parseEnquiryDate(raw: string | undefined): Date | null {
  if (!raw) return null;
  const value = raw.trim();
  if (!ISO_DATE.test(value)) return null;

  const parsed = new Date(value);
  if (Number.isNaN(parsed.getTime())) return null;
  return parsed;
}

/* -------------------------------------------------------------------------- */
/* The plan                                                                   */
/* -------------------------------------------------------------------------- */

export interface LeadImportValues {
  readonly fullName: string;
  readonly email: string | null;
  readonly mobile: string | null;
  readonly city: string | null;
  readonly province: string | null;
  readonly campaign: string | null;
  readonly programOfInterest: string | null;
  readonly createdAt: Date | null;
  readonly consentGiven: boolean;
  readonly notes: string | null;
  readonly utmSource: string | null;
  readonly utmMedium: string | null;
  readonly utmCampaign: string | null;
  readonly utmContent: string | null;
  readonly utmTerm: string | null;
}

export type LeadImportRowStatus = 'READY' | 'DUPLICATE_IN_FILE' | 'INVALID';

export interface PlannedLeadRow {
  /** 1-based source line, so an error names a row staff can actually find. */
  readonly line: number;
  readonly status: LeadImportRowStatus;
  /** Why the row cannot be imported. Non-empty only when `INVALID`. */
  readonly errors: readonly string[];
  /** Imported anyway, but something was dropped or assumed. */
  readonly warnings: readonly string[];
  readonly values: LeadImportValues | null;
  /** Set when `DUPLICATE_IN_FILE`: the earlier line holding the same person. */
  readonly duplicateOfLine?: number;
}

export interface LeadImportPlan {
  readonly delimiter: Delimiter;
  readonly columns: ColumnMap;
  /** Header names that were not recognized. Shown so staff can spot a typo. */
  readonly unmappedHeaders: readonly string[];
  readonly rows: readonly PlannedLeadRow[];
  readonly summary: LeadImportSummary;
}

export interface LeadImportSummary {
  readonly total: number;
  readonly ready: number;
  readonly duplicateInFile: number;
  readonly invalid: number;
  /** Ready rows carrying no consent — these may not be marketed to. */
  readonly withoutConsent: number;
}

function cell(row: readonly string[], index: number | undefined): string | undefined {
  if (index === undefined) return undefined;
  const value = row[index];
  if (value === undefined) return undefined;
  const trimmed = value.trim();
  return trimmed.length === 0 ? undefined : trimmed;
}

/**
 * Reads a file and works out what importing it would do.
 *
 * Pure: no database, no clock beyond `now`, no side effects. Given the same
 * text it returns the same plan, which is what makes the preview staff approve
 * and the import that follows the same decision rather than two guesses.
 */
export function planLeadImport(
  text: string,
  options: { readonly now?: Date; readonly delimiter?: Delimiter } = {},
): LeadImportPlan {
  const file: DelimitedFile = parseDelimited(text, options.delimiter);
  const columns = mapColumns(file.header);

  const mappedIndexes = new Set(Object.values(columns));
  const unmappedHeaders = file.header.filter((_, index) => !mappedIndexes.has(index));

  // Seen contact points, mapped to the first line that used them.
  const seenEmail = new Map<string, number>();
  const seenMobile = new Map<string, number>();

  const rows: PlannedLeadRow[] = [];

  for (const row of file.rows) {
    const errors: string[] = [];
    const warnings: string[] = [];

    const rawName = cell(row.cells, columns.fullName);
    const rawEmail = cell(row.cells, columns.email);
    const rawMobile = cell(row.cells, columns.mobile);

    if (!rawName) errors.push('No name.');

    // A lead with no way to reach them is not a lead, and duplicate detection
    // has nothing to work with either.
    let email: string | null = null;
    if (rawEmail) {
      if (isValidEmail(rawEmail)) {
        email = normalizeEmail(rawEmail);
      } else {
        warnings.push(`"${rawEmail}" is not a usable email address; it was not imported.`);
      }
    }

    let mobile: string | null = null;
    if (rawMobile) {
      mobile = normalizePhilippineMobile(rawMobile);
      if (!mobile) {
        warnings.push(
          `"${rawMobile}" is not a Philippine mobile number; it was kept in the notes instead.`,
        );
      }
    }

    if (!email && !mobile) {
      errors.push('No usable email address or mobile number.');
    }

    if (errors.length > 0) {
      rows.push({ line: row.line, status: 'INVALID', errors, warnings, values: null });
      continue;
    }

    const duplicateLine =
      (email ? seenEmail.get(email) : undefined) ?? (mobile ? seenMobile.get(mobile) : undefined);

    const createdAtRaw = cell(row.cells, columns.createdAt);
    let createdAt = parseEnquiryDate(createdAtRaw);
    if (createdAtRaw && !createdAt) {
      warnings.push(
        `The enquiry date "${createdAtRaw}" could not be read; today's date was used instead.`,
      );
    }
    if (createdAt && options.now && createdAt.getTime() > options.now.getTime()) {
      warnings.push('The enquiry date is in the future; today’s date was used instead.');
      createdAt = null;
    }

    const consentGiven = columns.consent !== undefined && parseConsent(row.cells[columns.consent]);

    // An unparseable number is still the enquirer's own contact detail; losing
    // it entirely would defeat the purpose of the import.
    const noteParts: string[] = [];
    const rawNotes = cell(row.cells, columns.notes);
    if (rawNotes) noteParts.push(rawNotes);
    if (rawMobile && !mobile) noteParts.push(`Contact number as supplied: ${rawMobile}`);
    if (rawEmail && !email) noteParts.push(`Email as supplied: ${rawEmail}`);

    const values: LeadImportValues = {
      // The name is the one field worth normalizing: exports arrive in ALL CAPS
      // more often than not, and "MARIA DELA CRUZ" on every screen is unkind.
      fullName: normalizePersonName(rawName as string),
      email,
      mobile,
      city: cell(row.cells, columns.city) ?? null,
      province: cell(row.cells, columns.province) ?? null,
      campaign: cell(row.cells, columns.campaign) ?? null,
      programOfInterest: cell(row.cells, columns.programOfInterest) ?? null,
      createdAt,
      consentGiven,
      notes: noteParts.length > 0 ? noteParts.join('\n') : null,
      utmSource: cell(row.cells, columns.utmSource) ?? null,
      utmMedium: cell(row.cells, columns.utmMedium) ?? null,
      utmCampaign: cell(row.cells, columns.utmCampaign) ?? null,
      utmContent: cell(row.cells, columns.utmContent) ?? null,
      utmTerm: cell(row.cells, columns.utmTerm) ?? null,
    };

    if (duplicateLine !== undefined) {
      rows.push({
        line: row.line,
        status: 'DUPLICATE_IN_FILE',
        errors: [],
        warnings,
        values,
        duplicateOfLine: duplicateLine,
      });
      continue;
    }

    if (email) seenEmail.set(email, row.line);
    if (mobile) seenMobile.set(mobile, row.line);

    rows.push({ line: row.line, status: 'READY', errors: [], warnings, values });
  }

  const ready = rows.filter((row) => row.status === 'READY');

  return {
    delimiter: file.delimiter,
    columns,
    unmappedHeaders,
    rows,
    summary: {
      total: rows.length,
      ready: ready.length,
      duplicateInFile: rows.filter((row) => row.status === 'DUPLICATE_IN_FILE').length,
      invalid: rows.filter((row) => row.status === 'INVALID').length,
      withoutConsent: ready.filter((row) => row.values?.consentGiven !== true).length,
    },
  };
}

/**
 * The columns a file must supply to be importable at all.
 *
 * Reported up front so staff get "your file has no name column" rather than
 * five hundred identical row errors.
 */
export function missingRequiredColumns(columns: ColumnMap): readonly LeadImportField[] {
  const missing: LeadImportField[] = [];
  if (columns.fullName === undefined) missing.push('fullName');
  if (columns.email === undefined && columns.mobile === undefined) missing.push('email');
  return missing;
}
