import type {
  CaptureResult,
  RegexMatchResult,
  RegexReplacementResult,
} from "../model/match";
import type {
  ReplacementSyntaxResult,
  ReplacementToken,
  SourceRange,
} from "../model/syntax";

export const MAXIMUM_REPLACEMENT_PREVIEW_MATCHES = 200;
export const MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS = 2_000;
export const MAXIMUM_REPLACEMENT_PREVIEW_TOKEN_EVALUATIONS = 50_000;
export const MAXIMUM_REPLACEMENT_MATCH_PREVIEW_UTF16 = 4 * 1024;
export const MAXIMUM_REPLACEMENT_CONTRIBUTION_PREVIEW_UTF16 = 1_024;

export interface ReplacementContributionPreview {
  readonly id: string;
  readonly matchNumber: number;
  readonly token: ReplacementToken;
  readonly valuePreview: string;
  readonly valueLengthUtf16: number;
  readonly valueLengthExact: boolean;
  readonly valueTruncated: boolean;
  readonly sourceRange?: SourceRange;
  readonly outputRange?: SourceRange;
}

export interface ReplacementMatchPreview {
  readonly id: string;
  readonly matchNumber: number;
  readonly originalRange: SourceRange;
  readonly originalPreview: string;
  readonly originalLengthUtf16: number;
  readonly originalTruncated: boolean;
  readonly replacementPreview: string;
  readonly replacementLengthUtf16: number;
  readonly replacementLengthExact: boolean;
  readonly replacementTruncated: boolean;
  readonly outputRange?: SourceRange;
  readonly contributions: readonly ReplacementContributionPreview[];
}

export interface ReplacementPreview {
  readonly matches: readonly ReplacementMatchPreview[];
  readonly totalMatches: number;
  readonly totalContributions: number;
  readonly renderedContributions: number;
  readonly truncated: boolean;
}

interface ContributionValue {
  readonly lengthUtf16: number;
  readonly lengthExact: boolean;
  readonly preview: (maximumUtf16: number) => string;
  readonly sourceRange?: SourceRange;
}

interface CaptureLookup {
  readonly byNumber: ReadonlyMap<number, CaptureResult>;
  readonly byName: ReadonlyMap<string, CaptureResult>;
}

function boundedSlice(
  value: string,
  start: number,
  end: number,
  maximumUtf16: number,
): string {
  if (maximumUtf16 <= 0 || end <= start) return "";
  return value.slice(start, Math.min(end, start + maximumUtf16));
}

function indexCaptures(match: RegexMatchResult): CaptureLookup {
  const byNumber = new Map<number, CaptureResult>();
  const byName = new Map<string, CaptureResult>();
  for (const capture of match.captures) {
    byNumber.set(capture.groupNumber, capture);
    if (capture.groupName === undefined) continue;
    const existing = byName.get(capture.groupName);
    if (
      existing === undefined ||
      (existing.status === "did-not-participate" &&
        capture.status !== "did-not-participate")
    ) {
      byName.set(capture.groupName, capture);
    }
  }
  return { byNumber, byName };
}

function captureForToken(
  token: ReplacementToken,
  captures: CaptureLookup,
): CaptureResult | undefined {
  if (token.captureNumber !== undefined) {
    return captures.byNumber.get(token.captureNumber);
  }
  if (token.captureName !== undefined) {
    return captures.byName.get(token.captureName);
  }
  return undefined;
}

function sourceContribution(
  subject: string,
  range: SourceRange,
): ContributionValue {
  return {
    lengthUtf16: Math.max(0, range.endUtf16 - range.startUtf16),
    lengthExact: true,
    preview: (maximumUtf16) =>
      boundedSlice(subject, range.startUtf16, range.endUtf16, maximumUtf16),
    sourceRange: range,
  };
}

function literalContribution(value: string): ContributionValue {
  return {
    lengthUtf16: value.length,
    lengthExact: true,
    preview: (maximumUtf16) => value.slice(0, maximumUtf16),
  };
}

function contributionValue(
  token: ReplacementToken,
  match: RegexMatchResult,
  captures: CaptureLookup,
  subject: string,
): ContributionValue {
  switch (token.kind) {
    case "literal":
    case "invalid":
      return literalContribution(token.raw);
    case "escaped-dollar":
      return literalContribution("$");
    case "full-match":
      return sourceContribution(subject, match.range);
    case "prefix":
      return sourceContribution(subject, {
        startUtf16: 0,
        endUtf16: match.range.startUtf16,
      });
    case "suffix":
      return sourceContribution(subject, {
        startUtf16: match.range.endUtf16,
        endUtf16: subject.length,
      });
    case "subject":
      return sourceContribution(subject, {
        startUtf16: 0,
        endUtf16: subject.length,
      });
    case "highest-capture": {
      const capture = [...captures.byNumber.values()]
        .filter((candidate) => candidate.status !== "did-not-participate")
        .sort((left, right) => right.groupNumber - left.groupNumber)[0];
      if (capture?.range) return sourceContribution(subject, capture.range);
      const fallback = literalContribution(capture?.value ?? "");
      return capture?.status === "truncated"
        ? { ...fallback, lengthExact: false }
        : fallback;
    }
    case "engine-special":
      return {
        lengthUtf16: 0,
        lengthExact: false,
        preview: () => "",
      };
    case "numbered-capture":
    case "named-capture": {
      const capture = captureForToken(token, captures);
      if (capture?.status === "did-not-participate") {
        return literalContribution("");
      }
      if (capture?.range) return sourceContribution(subject, capture.range);
      const fallback = literalContribution(capture?.value ?? "");
      return capture?.status === "truncated"
        ? { ...fallback, lengthExact: false }
        : fallback;
    }
  }
}

function availableOutputRange(
  startUtf16: number,
  endUtf16: number,
  outputLengthUtf16: number,
): SourceRange | undefined {
  return endUtf16 <= outputLengthUtf16 ? { startUtf16, endUtf16 } : undefined;
}

export function buildReplacementPreview(
  syntax: ReplacementSyntaxResult,
  result: RegexReplacementResult,
  subject: string,
): ReplacementPreview {
  const engineMatches = result.execution.matches;
  const maximumMatchesWithinEvaluationBudget =
    syntax.tokens.length === 0
      ? MAXIMUM_REPLACEMENT_PREVIEW_MATCHES
      : Math.floor(
          MAXIMUM_REPLACEMENT_PREVIEW_TOKEN_EVALUATIONS / syntax.tokens.length,
        );
  const visibleMatches = engineMatches.slice(
    0,
    Math.min(
      MAXIMUM_REPLACEMENT_PREVIEW_MATCHES,
      maximumMatchesWithinEvaluationBudget,
    ),
  );
  const matches: ReplacementMatchPreview[] = [];
  let renderedContributions = 0;
  let nextSourcePosition = 0;
  let outputCursor = 0;
  let outputPositionExact = true;

  for (const match of visibleMatches) {
    const captures = indexCaptures(match);
    outputCursor += Math.max(0, match.range.startUtf16 - nextSourcePosition);
    const outputStart = outputCursor;
    const contributions: ReplacementContributionPreview[] = [];
    const replacementParts: string[] = [];
    let remainingReplacementPreview = MAXIMUM_REPLACEMENT_MATCH_PREVIEW_UTF16;
    let replacementLengthUtf16 = 0;
    let replacementLengthExact = true;

    for (const token of syntax.tokens) {
      const value = contributionValue(token, match, captures, subject);
      const contributionStart = outputCursor + replacementLengthUtf16;
      const contributionEnd = contributionStart + value.lengthUtf16;
      const contributionRangeExact =
        outputPositionExact && replacementLengthExact && value.lengthExact;
      if (renderedContributions < MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS) {
        const valuePreview = value.preview(
          MAXIMUM_REPLACEMENT_CONTRIBUTION_PREVIEW_UTF16,
        );
        contributions.push({
          id: `replacement-contribution-${match.matchNumber}-${token.id}`,
          matchNumber: match.matchNumber,
          token,
          valuePreview,
          valueLengthUtf16: value.lengthUtf16,
          valueLengthExact: value.lengthExact,
          valueTruncated:
            !value.lengthExact || valuePreview.length < value.lengthUtf16,
          ...(value.sourceRange ? { sourceRange: value.sourceRange } : {}),
          ...(contributionRangeExact &&
          availableOutputRange(
            contributionStart,
            contributionEnd,
            result.output.length,
          )
            ? {
                outputRange: {
                  startUtf16: contributionStart,
                  endUtf16: contributionEnd,
                },
              }
            : {}),
        });
        renderedContributions += 1;
      }
      if (remainingReplacementPreview > 0) {
        const preview = value.preview(remainingReplacementPreview);
        replacementParts.push(preview);
        remainingReplacementPreview -= preview.length;
      }
      replacementLengthUtf16 += value.lengthUtf16;
      replacementLengthExact &&= value.lengthExact;
    }

    const outputEnd = outputStart + replacementLengthUtf16;
    const originalLengthUtf16 = match.range.endUtf16 - match.range.startUtf16;
    const originalPreview = boundedSlice(
      subject,
      match.range.startUtf16,
      match.range.endUtf16,
      MAXIMUM_REPLACEMENT_MATCH_PREVIEW_UTF16,
    );
    const replacementPreview = replacementParts.join("");
    matches.push({
      id: `replacement-match-${match.matchNumber}`,
      matchNumber: match.matchNumber,
      originalRange: match.range,
      originalPreview,
      originalLengthUtf16,
      originalTruncated: originalPreview.length < originalLengthUtf16,
      replacementPreview,
      replacementLengthUtf16,
      replacementLengthExact,
      replacementTruncated:
        !replacementLengthExact ||
        replacementPreview.length < replacementLengthUtf16,
      ...(outputPositionExact &&
      replacementLengthExact &&
      availableOutputRange(outputStart, outputEnd, result.output.length)
        ? {
            outputRange: {
              startUtf16: outputStart,
              endUtf16: outputEnd,
            },
          }
        : {}),
      contributions,
    });
    outputCursor = outputEnd;
    outputPositionExact &&= replacementLengthExact;
    nextSourcePosition = match.range.endUtf16;
  }

  const totalContributions = engineMatches.length * syntax.tokens.length;
  return {
    matches,
    totalMatches: engineMatches.length,
    totalContributions,
    renderedContributions,
    truncated:
      matches.length < engineMatches.length ||
      renderedContributions < totalContributions,
  };
}
