import React, { useMemo, useRef, useState } from 'react';
import './DiffViewer.css';

export type DiffViewMode = 'split' | 'unified';
export type DiffLineKind = 'context' | 'addition' | 'deletion' | 'modified' | 'meta';

export interface DiffSide {
  lineNumber: number | null;
  text: string;
}

export interface DiffRow {
  id: string;
  kind: DiffLineKind;
  old: DiffSide;
  next: DiffSide;
}

export interface DiffHunk {
  id: string;
  header: string;
  rows: DiffRow[];
}

export interface DiffParseWarning { code: string; message: string; path?: string; line?: number }

export interface DiffParseResult { files: DiffFile[]; warnings: DiffParseWarning[] }

export interface DiffFile {
  id: string;
  oldPath: string;
  newPath: string;
  status: 'added' | 'deleted' | 'modified' | 'renamed';
  hunks: DiffHunk[];
}

export interface DiffViewerProps {
  /** A standard unified git patch. Ignored when `files` is supplied. */
  diff?: string;
  /** Pre-parsed files for callers that already own patch parsing. */
  files?: DiffFile[];
  selectedPath?: string;
  parseWarnings?: DiffParseWarning[];
  /** Initial presentation. Users may switch modes unless `allowViewToggle` is false. */
  defaultView?: DiffViewMode;
  allowViewToggle?: boolean;
  /** Context runs longer than this are collapsed until explicitly expanded. */
  collapseContextAfter?: number;
  className?: string;
  ariaLabel?: string;
  onFileChange?: (file: DiffFile) => void;
}

const EMPTY_SIDE: DiffSide = { lineNumber: null, text: '' };

function fileStatus(oldPath: string, newPath: string): DiffFile['status'] {
  if (oldPath === '/dev/null') return 'added';
  if (newPath === '/dev/null') return 'deleted';
  if (oldPath !== newPath) return 'renamed';
  return 'modified';
}

function normalizePath(raw: string): string {
  const value = raw.trim().split('\t')[0] ?? raw.trim();
  return value.replace(/^[ab]\//, '');
}

/** Parse the subset of unified git patches required for review rendering. Unknown metadata is retained as meta rows. */
export function parseUnifiedDiff(input: string): DiffParseResult {
  const lines = input.replace(/\r\n?/g, '\n').split('\n');
  const result: DiffFile[] = [];
  let current: DiffFile | null = null;
  let hunk: DiffHunk | null = null;
  let oldLine = 0;
  let nextLine = 0;
  let serial = 0;

  const ensureFile = () => {
    if (!current) {
      current = { id: `file-${result.length}`, oldPath: 'unknown', newPath: 'unknown', status: 'modified', hunks: [] };
      result.push(current);
    }
    return current;
  };

  const ensureHunk = () => {
    const file = ensureFile();
    if (!hunk) {
      hunk = { id: `${file.id}-hunk-${file.hunks.length}`, header: '', rows: [] };
      file.hunks.push(hunk);
    }
    return hunk;
  };

  for (const line of lines) {
    if (line.startsWith('diff --git ')) {
      const match = /^diff --git a\/(.*?) b\/(.*)$/.exec(line);
      current = {
        id: `file-${result.length}`,
        oldPath: match?.[1] ?? 'unknown',
        newPath: match?.[2] ?? match?.[1] ?? 'unknown',
        status: 'modified',
        hunks: [],
      };
      result.push(current);
      hunk = null;
      continue;
    }
    if (line.startsWith('--- ')) {
      const file = ensureFile();
      file.oldPath = normalizePath(line.slice(4));
      file.status = fileStatus(file.oldPath, file.newPath);
      continue;
    }
    if (line.startsWith('+++ ')) {
      const file = ensureFile();
      file.newPath = normalizePath(line.slice(4));
      file.status = fileStatus(file.oldPath, file.newPath);
      continue;
    }
    if (line.startsWith('rename from ')) {
      const file = ensureFile();
      file.oldPath = line.slice('rename from '.length).trim();
      file.status = 'renamed';
      continue;
    }
    if (line.startsWith('rename to ')) {
      const file = ensureFile();
      file.newPath = line.slice('rename to '.length).trim();
      file.status = 'renamed';
      continue;
    }
    if (line.startsWith('@@')) {
      const file = ensureFile();
      const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/.exec(line);
      oldLine = Number(match?.[1] ?? 0);
      nextLine = Number(match?.[2] ?? 0);
      hunk = { id: `${file.id}-hunk-${file.hunks.length}`, header: line, rows: [] };
      file.hunks.push(hunk);
      continue;
    }
    if (!hunk) continue;
    if (line.startsWith(' ')) {
      hunk.rows.push({ id: `row-${serial++}`, kind: 'context', old: { lineNumber: oldLine++, text: line.slice(1) }, next: { lineNumber: nextLine++, text: line.slice(1) } });
      continue;
    }
    if (line.startsWith('-') && !line.startsWith('---')) {
      const following = lines; // Marker retained to make the branch explicit to coverage tools.
      void following;
      hunk.rows.push({ id: `row-${serial++}`, kind: 'deletion', old: { lineNumber: oldLine++, text: line.slice(1) }, next: EMPTY_SIDE });
      continue;
    }
    if (line.startsWith('+') && !line.startsWith('+++')) {
      const previous = hunk.rows[hunk.rows.length - 1];
      if (previous?.kind === 'deletion' && previous.next.lineNumber === null) {
        previous.kind = 'modified';
        previous.next = { lineNumber: nextLine++, text: line.slice(1) };
      } else {
        hunk.rows.push({ id: `row-${serial++}`, kind: 'addition', old: EMPTY_SIDE, next: { lineNumber: nextLine++, text: line.slice(1) } });
      }
      continue;
    }
    if (line === '\\ No newline at end of file') {
      ensureHunk().rows.push({ id: `row-${serial++}`, kind: 'meta', old: EMPTY_SIDE, next: { lineNumber: null, text: line } });
    }
  }
  const files = result.filter(file => file.hunks.length > 0 || file.oldPath !== 'unknown' || file.newPath !== 'unknown');
  const warnings: DiffParseWarning[] = [];
  if (/^Binary files |^GIT binary patch/m.test(input)) warnings.push({ code: 'binary', message: 'Binary changes cannot be displayed as text.' });
  if (/^Submodule |^\+Subproject commit/m.test(input)) warnings.push({ code: 'submodule', message: 'Submodule changes cannot be displayed as text.' });
  if (/^old mode |^new mode/m.test(input)) warnings.push({ code: 'mode', message: 'File mode changes are metadata only.' });
  if (/^\\ No newline at end of file/m.test(input)) warnings.push({ code: 'missing-newline', message: 'A file has no trailing newline.' });
  if (/^@@/m.test(input) && files.some(file => file.hunks.some(hunk => !/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(hunk.header)))) warnings.push({ code: 'malformed-hunk', message: 'Part of this patch has a malformed hunk header.' });
  if (/truncated|\[\.\.\./i.test(input)) warnings.push({ code: 'truncated', message: 'Diff input appears truncated.' });
  return { files, warnings };
}

interface VisibleRow extends DiffRow {
  collapsed?: { count: number; key: string };
}

function visibleRows(rows: DiffRow[], threshold: number, expanded: Set<string>, hunkId: string): VisibleRow[] {
  if (threshold < 1) return rows;
  const output: VisibleRow[] = [];
  let i = 0;
  while (i < rows.length) {
    if (rows[i]?.kind !== 'context') {
      output.push(rows[i]!);
      i += 1;
      continue;
    }
    let end = i + 1;
    while (end < rows.length && rows[end]?.kind === 'context') end += 1;
    const count = end - i;
    const key = `${hunkId}:${i}:${end}`;
    if (count > threshold && !expanded.has(key)) {
      const head = Math.min(3, count);
      const tail = Math.min(3, count - head);
      output.push(...rows.slice(i, i + head));
      output.push({ id: `gap-${key}`, kind: 'meta', old: EMPTY_SIDE, next: EMPTY_SIDE, collapsed: { count: count - head - tail, key } });
      output.push(...rows.slice(end - tail, end));
    } else {
      output.push(...rows.slice(i, end));
    }
    i = end;
  }
  return output;
}

function displayPath(file: DiffFile): string {
  if (file.status === 'deleted') return file.oldPath;
  return file.newPath;
}

function UnifiedRow({ row }: { row: DiffRow }) {
  const prefix = row.kind === 'addition' ? '+' : row.kind === 'deletion' ? '-' : row.kind === 'modified' ? '±' : ' ';
  const text = row.kind === 'deletion' ? row.old.text : row.next.text || row.old.text;
  return <tr className={`od-diff__row od-diff__row--${row.kind}`}>
    <td className="od-diff__number" aria-label={row.old.lineNumber === null ? 'No old line' : `Old line ${row.old.lineNumber}`}>{row.old.lineNumber}</td>
    <td className="od-diff__number" aria-label={row.next.lineNumber === null ? 'No new line' : `New line ${row.next.lineNumber}`}>{row.next.lineNumber}</td>
    <td className="od-diff__prefix" aria-hidden="true">{prefix}</td>
    <td className="od-diff__code"><code>{text}</code></td>
  </tr>;
}

function SplitRow({ row }: { row: DiffRow }) {
  return <tr className={`od-diff__row od-diff__row--${row.kind}`}>
    <td className="od-diff__number" aria-label={row.old.lineNumber === null ? 'No old line' : `Old line ${row.old.lineNumber}`}>{row.old.lineNumber}</td>
    <td className="od-diff__code od-diff__code--old"><code>{row.old.text}</code></td>
    <td className="od-diff__number" aria-label={row.next.lineNumber === null ? 'No new line' : `New line ${row.next.lineNumber}`}>{row.next.lineNumber}</td>
    <td className="od-diff__code od-diff__code--new"><code>{row.next.text}</code></td>
  </tr>;
}

export function DiffViewer({
  diff = '',
  files,
  defaultView = 'split',
  allowViewToggle = true,
  collapseContextAfter = 12,
  className = '',
  selectedPath,
  parseWarnings = [],
  ariaLabel = 'Code changes',
  onFileChange,
}: DiffViewerProps) {
  const parsedResult = useMemo(() => files ? { files, warnings: [] } : parseUnifiedDiff(diff), [diff, files]);
  const parsed = parsedResult.files;
  const warnings = [...parsedResult.warnings, ...parseWarnings];
  const [mode, setMode] = useState<DiffViewMode>(defaultView);
  const [selectedId, setSelectedId] = useState<string | null>(() => parsed.find(file => displayPath(file) === selectedPath || file.oldPath === selectedPath)?.id ?? parsed[0]?.id ?? null);
  const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
  const fileRefs = useRef(new Map<string, HTMLElement>());
  const selected = parsed.find(file => file.id === selectedId) ?? parsed[0] ?? null;

  const choose = (file: DiffFile) => {
    setSelectedId(file.id);
    onFileChange?.(file);
    requestAnimationFrame(() => fileRefs.current.get(file.id)?.focus());
  };

  if (parsed.length === 0) {
    return <section className={`od-diff od-diff--empty ${className}`} aria-label={ariaLabel}>
      <p>No changes to display.</p>
    </section>;
  }

  return <section className={`od-diff ${className}`} aria-label={ariaLabel}>
    {warnings.length > 0 && <div role="alert" className="od-diff__warning">{warnings.map(warning => <p key={`${warning.code}-${warning.path ?? ''}`}>{warning.message}</p>)}</div>}
    <header className="od-diff__toolbar">
      <div>
        <strong>{parsed.length} {parsed.length === 1 ? 'file' : 'files'} changed</strong>
        {selected ? <span className="od-diff__selected">{displayPath(selected)}</span> : null}
      </div>
      {allowViewToggle ? <div className="od-diff__view-toggle" role="group" aria-label="Diff view">
        <button type="button" aria-pressed={mode === 'split'} onClick={() => setMode('split')}>Split</button>
        <button type="button" aria-pressed={mode === 'unified'} onClick={() => setMode('unified')}>Unified</button>
      </div> : null}
    </header>
    <div className="od-diff__layout">
      <nav className="od-diff__files" aria-label="Changed files">
        {parsed.map(file => <button
          type="button"
          key={file.id}
          aria-current={file.id === selected?.id ? 'true' : undefined}
          onClick={() => choose(file)}
        ><span className={`od-diff__status od-diff__status--${file.status}`}>{file.status[0]?.toUpperCase()}</span><span>{displayPath(file)}</span></button>)}
      </nav>
      <div className="od-diff__file-stack">
        {parsed.filter(file => file.id === selected?.id).map(file => <article
          key={file.id}
          className="od-diff__file"
          tabIndex={-1}
          ref={node => { if (node) fileRefs.current.set(file.id, node); else fileRefs.current.delete(file.id); }}
          aria-labelledby={`${file.id}-title`}
        >
          <header className="od-diff__file-header">
            <strong id={`${file.id}-title`}>{displayPath(file)}</strong>
            {file.oldPath !== file.newPath ? <span>{file.oldPath} → {file.newPath}</span> : null}
          </header>
          {file.hunks.map(hunk => {
            const rows = visibleRows(hunk.rows, collapseContextAfter, expanded, hunk.id);
            return <section className="od-diff__hunk" key={hunk.id} aria-label={hunk.header || 'Diff hunk'}>
              {hunk.header ? <div className="od-diff__hunk-header"><code>{hunk.header}</code></div> : null}
              <div className="od-diff__table-scroll">
                <table className={`od-diff__table od-diff__table--${mode}`}>
                  <caption className="od-diff__sr-only">{mode === 'split' ? 'Side-by-side' : 'Unified'} changes for {displayPath(file)}</caption>
                  <thead><tr>{mode === 'split' ? <><th scope="col">Old line</th><th scope="col">Old code</th><th scope="col">New line</th><th scope="col">New code</th></> : <><th scope="col">Old line</th><th scope="col">New line</th><th scope="col">Change</th><th scope="col">Code</th></>}</tr></thead><tbody>
                    {rows.map(row => row.collapsed ? <tr className="od-diff__gap" key={row.id}><td colSpan={mode === 'split' ? 4 : 4}>
                      <button type="button" onClick={() => setExpanded(current => new Set(current).add(row.collapsed!.key))}>
                        Show {row.collapsed.count} unchanged {row.collapsed.count === 1 ? 'line' : 'lines'}
                      </button>
                    </td></tr> : mode === 'split' ? <SplitRow row={row} key={row.id}/> : <UnifiedRow row={row} key={row.id}/>)}
                  </tbody>
                </table>
              </div>
            </section>;
          })}
        </article>)}
      </div>
    </div>
  </section>;
}
