import {
  findCommitMapsWithBlankLines,
  findCommitsMissingId,
  findCommitsOutsideCommitMap,
  findRepeatedCommitIds,
  findUnclosedCommitMaps,
} from "./commit-map-syntax";
import { LEADING_HEADING_PATTERN, stripLeadingHeadings } from "./lint-fix";
import { findTakenQuizIds, fixTakenQuizIds } from "./quiz-lint";
import type { Mode } from "./types";

/**
 * What a rule knows beyond the text in front of it.
 *
 * A regex sees one document; some rules are about the course around it. The
 * context carries what the loader could see and the rule could not.
 */
export interface LintContext {
  /** Quiz ids owned by other videos in this course. */
  courseQuizIds: string[];
  /** Whether the text is still arriving from the model. */
  isStreaming: boolean;
}

export const EMPTY_LINT_CONTEXT: LintContext = {
  courseQuizIds: [],
  isStreaming: false,
};

/**
 * Represents a single lint rule that can be applied to article writer output.
 */
export interface LintRule {
  /** Unique identifier for the rule */
  id: string;
  /** Human-readable name of the rule */
  name: string;
  /** Description of what the rule checks for */
  description: string;
  /** Modes this rule applies to (null = all modes) */
  modes: Mode[] | null;
  /** Regular expression pattern to detect violations */
  pattern: RegExp;
  /** Instruction to include in fix message (static string or function that receives matched text) */
  fixInstruction: string | ((matches: string[]) => string);
  /** If true, the pattern must be present (violation if missing). Default: false (violation if present) */
  required?: boolean;
  /** Optional filter to exclude false-positive matches. Return true to keep the match. */
  matchFilter?: (match: string) => boolean;
  /**
   * Skips the rule while the model is still writing.
   *
   * A rule about the *shape* of a block is transiently false all the way
   * through a document being written — an unclosed tag is the normal state of
   * a tag half-arrived. Reporting it would make the Fix count cry wolf on
   * every keystroke, so these rules read the settled document only.
   */
  skipWhileStreaming?: boolean;
  /**
   * Optional deterministic repair. When present, and when it actually changes
   * the document, the fix is applied directly and the rule's `fixInstruction`
   * is not sent to the model. Only reachable in document modes, where we own
   * the text.
   */
  deterministicFix?: (text: string, context: LintContext) => string;
  /**
   * Replaces the `pattern` scan for a rule a regex cannot express. Returns one
   * entry per offending thing found, which become the violation's `matches`.
   */
  detect?: (text: string, context: LintContext) => string[];
}

/**
 * Represents a detected violation of a lint rule.
 */
export interface LintViolation {
  /** The rule that was violated */
  rule: LintRule;
  /** Number of times the violation appears */
  count: number;
  /** The actual matched text for each violation */
  matches: string[];
}

/**
 * Represents a custom banned phrase with optional case sensitivity.
 */
export interface BannedPhrase {
  /** The phrase pattern (can include regex syntax) */
  pattern: string;
  /** Human-readable description */
  readable: string;
  /** Whether the match should be case sensitive */
  caseSensitive: boolean;
}

/**
 * Default banned phrases that are dead giveaways of LLM-generated content.
 * These should be avoided in all article writing.
 * Some patterns use regex syntax for context-aware matching.
 */
export const DEFAULT_BANNED_PHRASES: BannedPhrase[] = [
  {
    pattern: "uncomfortable truth",
    readable: "uncomfortable truth",
    caseSensitive: false,
  },
  { pattern: "hard truth", readable: "hard truth", caseSensitive: false },
  {
    pattern: "spoiler[,:]",
    readable: "spoiler (when followed by comma or colon)",
    caseSensitive: false,
  },
  {
    pattern: "here's the thing:",
    readable: "here's the thing:",
    caseSensitive: false,
  },
  { pattern: "yeah", readable: "yeah", caseSensitive: false },
  {
    pattern: "it's kind of like",
    readable: "it's kind of like",
    caseSensitive: false,
  },
  {
    pattern: "game[ -]?changer",
    readable: "game changer / game-changer",
    caseSensitive: false,
  },
  {
    pattern: "the reality[?!:;.,]",
    readable: "the reality (when followed by punctuation)",
    caseSensitive: false,
  },
  {
    pattern: "the good news[?:]",
    readable: "the good news (when followed by ? or :)",
    caseSensitive: false,
  },
  {
    pattern: "the irony[?]",
    readable: "the irony (when followed by ?)",
    caseSensitive: false,
  },
  {
    pattern: "problem solved[.!]",
    readable: "problem solved (when followed by . or !)",
    caseSensitive: false,
  },
];

/**
 * Creates individual lint rules for each banned phrase.
 * This allows the fix message to only include phrases that were actually detected.
 */
export function createBannedPhraseRules(phrases: BannedPhrase[]): LintRule[] {
  return phrases.map((phrase, index) => ({
    id: `no-llm-phrase-${index}`,
    name: "No LLM Phrases",
    description: "Phrases that are dead giveaways of LLM-generated content",
    modes: null,
    pattern: new RegExp(phrase.pattern, phrase.caseSensitive ? "g" : "gi"),
    fixInstruction: `Remove or rephrase: "${phrase.readable}"`,
  }));
}

/**
 * The greeting sigil that must appear at the start of every newsletter.
 * Uses Liquid templating syntax for personalization.
 */
export const NEWSLETTER_GREETING_SIGIL = `Hey {{ subscriber.first_name | strip | default: "there" }},`;

/**
 * Base lint rules (excluding banned phrases which are customizable).
 */
export const BASE_LINT_RULES: LintRule[] = [
  {
    id: "no-em-dash",
    name: "No Em Dashes",
    description: "Em dashes (—) should not be used in content",
    modes: null, // Applies to all modes
    pattern: /—/g,
    fixInstruction: "Replace all em dashes (—) with hyphens (-) or commas",
  },
  {
    id: "newsletter-greeting",
    name: "Newsletter Greeting",
    description: "Newsletter must start with the personalized greeting sigil",
    modes: ["newsletter"],
    pattern: new RegExp(
      "^" + NEWSLETTER_GREETING_SIGIL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
    ),
    fixInstruction: `The newsletter must start with exactly: ${NEWSLETTER_GREETING_SIGIL}`,
    required: true,
  },
  {
    id: "no-leading-heading",
    name: "No Leading Heading",
    description: "Content must not start with a markdown heading",
    modes: ["article", "skill-building"],
    pattern: LEADING_HEADING_PATTERN,
    fixInstruction:
      "Remove the markdown heading from the start of the content. Do not start with a heading - begin directly with the content.",
    deterministicFix: stripLeadingHeadings,
  },
  {
    id: "quiz-id-taken",
    name: "Quiz Id Taken",
    description:
      "A quiz id must be unique across the course — reader answers are keyed to it",
    modes: null,
    // Never scanned: `detect` sees the course, which a regex cannot.
    pattern: /(?!)/,
    detect: (text, context) => findTakenQuizIds(text, context.courseQuizIds),
    deterministicFix: (text, context) =>
      fixTakenQuizIds(text, context.courseQuizIds),
    fixInstruction: (matches) =>
      `Rename these quiz ids — another lesson in the course already uses them: ${matches.join(", ")}`,
  },
  // The commit map rules. All of them read the block's shape, so all of them
  // wait for the writer to stop. None has a deterministic fix: only the author
  // knows which slug a broken entry meant.
  {
    id: "commit-map-unclosed",
    name: "Unclosed Commit Map",
    description:
      "A <CommitMap> with no closing tag escapes the whole lesson body on the site",
    modes: null,
    // Never scanned: `detect` reads the block structure, which a regex cannot.
    pattern: /(?!)/,
    skipWhileStreaming: true,
    detect: (text) => findUnclosedCommitMaps(text),
    fixInstruction:
      "Close the <CommitMap> block with </CommitMap>. Left open, the site renders the entire lesson body as escaped text.",
  },
  {
    id: "commit-map-blank-line",
    name: "Blank Line In Commit Map",
    description: "A commit map must be one unbroken run of lines",
    modes: null,
    pattern: /(?!)/,
    skipWhileStreaming: true,
    detect: (text) => findCommitMapsWithBlankLines(text),
    fixInstruction:
      "Remove the blank lines inside the <CommitMap> block. The opening tag, every <Commit>, and the closing tag must sit on consecutive lines.",
  },
  {
    id: "commit-missing-id",
    name: "Commit Without An Id",
    description: "Every commit map entry names a commit by its slug",
    modes: null,
    pattern: /(?!)/,
    skipWhileStreaming: true,
    detect: (text) => findCommitsMissingId(text),
    fixInstruction: (matches) =>
      `Give these <Commit> entries an id — the slug of the commit they name, or "main" for the course start: ${matches.join(", ")}`,
  },
  {
    id: "commit-id-repeated",
    name: "Repeated Commit Id",
    description: "A commit map names each commit once",
    modes: null,
    pattern: /(?!)/,
    skipWhileStreaming: true,
    detect: (text) => findRepeatedCommitIds(text),
    fixInstruction: (matches) =>
      `Remove the duplicate entries — the map names these commits more than once: ${matches.join(", ")}`,
  },
  {
    id: "commit-outside-commit-map",
    name: "Commit Outside A Commit Map",
    description: "A <Commit> only means anything inside a <CommitMap>",
    modes: null,
    pattern: /(?!)/,
    skipWhileStreaming: true,
    detect: (text) => findCommitsOutsideCommitMap(text),
    fixInstruction: (matches) =>
      `Move these <Commit> entries inside a <CommitMap> block: ${matches.join(", ")}`,
  },
];

/**
 * All lint rules for the article writer (using default banned phrases).
 * For custom banned phrases, use getLintRulesWithPhrases() instead.
 */
export const LINT_RULES: LintRule[] = [
  ...BASE_LINT_RULES,
  ...createBannedPhraseRules(DEFAULT_BANNED_PHRASES),
];

/**
 * Get lint rules with custom banned phrases.
 */
export function getLintRulesWithPhrases(phrases: BannedPhrase[]): LintRule[] {
  if (phrases.length === 0) {
    return BASE_LINT_RULES;
  }
  return [...BASE_LINT_RULES, ...createBannedPhraseRules(phrases)];
}
