import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/services/file_service.dart';
import 'package:ocideck/services/image_service.dart';
import 'package:ocideck/services/markdown_service.dart';
import 'package:ocideck/state/deck_provider.dart';

DeckNotifier _notifier() {
  final md = MarkdownService();
  final file = FileService(md, ImageService(), () => const ThemeProfile());
  return DeckNotifier(md, file);
}

void main() {
  test('newDeck creates one title slide and is dirty', () {
    final n = _notifier();
    n.newDeck('Mijn deck');
    expect(n.state.isOpen, isTrue);
    expect(n.state.deck!.slides, hasLength(1));
    expect(n.state.deck!.slides.single.type, SlideType.title);
    expect(n.state.deck!.slides.single.title, 'Mijn deck');
    expect(n.state.isDirty, isTrue);
  });

  test('loadDeck applies the active profile and resolves its relative logo', () {
    final temp = Directory.systemTemp.createTempSync(
      'ocideck_recovered_logo_test_',
    );
    addTearDown(() => temp.deleteSync(recursive: true));
    final logo = File('${temp.path}/logos/client.png')
      ..createSync(recursive: true)
      ..writeAsBytesSync([1, 2, 3]);

    final md = MarkdownService();
    // Styling comes from the active style profile, not from the deck/markdown.
    final file = FileService(
      md,
      ImageService(),
      () => const ThemeProfile(logoPath: 'logos/client.png'),
      homeDirectory: () => temp.path,
    );
    final notifier = DeckNotifier(md, file);
    notifier.loadDeck(
      Deck(
        title: 'Hersteld',
        // The deck's own profile is ignored on load.
        themeProfile: const ThemeProfile(logoPath: 'should-be-ignored.png'),
        slides: [Slide.create(SlideType.title)],
      ),
    );

    expect(notifier.state.filePath, isNull);
    expect(notifier.state.deck!.projectPath, isNull);
    expect(notifier.state.deck!.themeProfile.logoPath, logo.path);
  });

  test('addSlide inserts right after the given index', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets); // appended -> index 1
    n.addSlide(SlideType.image, afterIndex: 0); // inserted at index 1
    final types = n.state.deck!.slides.map((s) => s.type).toList();
    expect(types, [SlideType.title, SlideType.image, SlideType.bullets]);
  });

  test('removeSlide removes a slide but never the last one', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets);
    n.removeSlide(0);
    expect(n.state.deck!.slides, hasLength(1));
    // Now only one remains; removing again is a no-op.
    n.removeSlide(0);
    expect(n.state.deck!.slides, hasLength(1));
  });

  test('duplicateSlide inserts a copy with a fresh id', () {
    final n = _notifier()..newDeck('D');
    final originalId = n.state.deck!.slides.first.id;
    n.duplicateSlide(0);
    final slides = n.state.deck!.slides;
    expect(slides, hasLength(2));
    expect(slides[1].title, slides[0].title);
    expect(slides[1].id, isNot(originalId));
  });

  test('insertSlides duplicates with fresh ids and returns insert index', () {
    final n = _notifier()..newDeck('D');
    final source = Slide.create(SlideType.bullets).copyWith(title: 'Bron');
    final at = n.insertSlides([source], afterIndex: 0);
    expect(at, 1);
    expect(n.state.deck!.slides, hasLength(2));
    expect(n.state.deck!.slides[1].title, 'Bron');
    expect(n.state.deck!.slides[1].id, isNot(source.id));
  });

  test('copying a selection into another deck leaves the source intact', () {
    // Source deck with three slides; "select" indices 0 and 2.
    final source = _notifier()..newDeck('Bron');
    source.addSlide(SlideType.bullets); // 1
    source.addSlide(SlideType.image); // 2
    final picked = [source.state.deck!.slides[0], source.state.deck!.slides[2]];
    final sourceIdsBefore = source.state.deck!.slides.map((s) => s.id).toList();

    // Target deck receives the copies (appended at the end).
    final target = _notifier()..newDeck('Doel');
    final at = target.insertSlides(picked);

    expect(at, 1); // appended after the single title slide
    expect(target.state.deck!.slides, hasLength(3));
    expect(target.state.deck!.slides[1].type, SlideType.title);
    expect(target.state.deck!.slides[2].type, SlideType.image);
    // Copies must have fresh ids, not the source ids.
    expect(
      target.state.deck!.slides[1].id,
      isNot(anyOf(picked.map((s) => s.id))),
    );
    // The source deck is untouched.
    expect(
      source.state.deck!.slides.map((s) => s.id).toList(),
      sourceIdsBefore,
    );
  });

  test('reorderSlides moves a slide to a new position', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets); // 1
    n.addSlide(SlideType.image); // 2
    n.reorderSlides(2, 0);
    expect(n.state.deck!.slides.first.type, SlideType.image);
  });

  test('updateSlide replaces the slide at an index', () {
    final n = _notifier()..newDeck('D');
    final updated = n.state.deck!.slides.first.copyWith(title: 'Nieuw');
    n.updateSlide(0, updated);
    expect(n.state.deck!.slides.first.title, 'Nieuw');
  });

  test('clearAllChecklists unchecks every checklist item across the deck', () {
    final n = _notifier()..newDeck('D');
    final s1 = Slide.create(SlideType.bullets).copyWith(
      listStyle: ListStyle.checklist,
      bullets: ['[x] Klaar', '\t[X] Subklaar', '[ ] Open'],
    );
    final s2 = Slide.create(SlideType.bullets).copyWith(
      listStyle: ListStyle.checklist,
      bullets: ['[x] Eerste kolom'],
      bullets2: ['[x] Tweede kolom', '[ ] Nog open'],
    );
    n.loadDeck(n.state.deck!.copyWith(slides: [s1, s2]));

    expect(n.checkedChecklistCount, 4);
    final revisionBefore = n.state.revision;

    n.clearAllChecklists();

    expect(n.checkedChecklistCount, 0);
    final out = n.state.deck!.slides;
    expect(out[0].bullets, ['[ ] Klaar', '\t[ ] Subklaar', '[ ] Open']);
    expect(out[1].bullets, ['[ ] Eerste kolom']);
    expect(out[1].bullets2, ['[ ] Tweede kolom', '[ ] Nog open']);
    // Revision bumps so the open slide editor remounts and reflects the change.
    expect(n.state.revision, greaterThan(revisionBefore));
  });

  test(
    'clearAllChecklists is a single undoable step that restores the checks',
    () {
      final n = _notifier()..newDeck('D');
      final slide = Slide.create(SlideType.bullets).copyWith(
        listStyle: ListStyle.checklist,
        bullets: ['[x] Klaar', '[ ] Open'],
        bullets2: ['[x] Tweede'],
      );
      n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
      expect(n.checkedChecklistCount, 2);

      n.clearAllChecklists();
      expect(n.checkedChecklistCount, 0);
      expect(n.state.canUndo, isTrue);
      final revisionAfterClear = n.state.revision;

      n.undo();

      // One undo restores every checked item in both columns...
      expect(n.checkedChecklistCount, 2);
      expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']);
      expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
      // ...and bumps the revision again so the open editor reflects the restore.
      expect(n.state.revision, greaterThan(revisionAfterClear));
    },
  );

  test('clearAllChecklists is a no-op when nothing is checked', () {
    final n = _notifier()..newDeck('D');
    final slide = Slide.create(
      SlideType.bullets,
    ).copyWith(listStyle: ListStyle.checklist, bullets: ['[ ] Open']);
    n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
    expect(n.state.canUndo, isFalse);

    n.clearAllChecklists();

    // No checked items, so no history entry is recorded.
    expect(n.state.canUndo, isFalse);
    expect(n.checkedChecklistCount, 0);
  });

  test('updateMeta changes deck title/theme/paginate', () {
    final n = _notifier()..newDeck('D');
    n.updateMeta(title: 'Andere titel', theme: 'custom', paginate: false);
    expect(n.state.deck!.title, 'Andere titel');
    expect(n.state.deck!.theme, 'custom');
    expect(n.state.deck!.paginate, isFalse);
  });

  test('updateInfo can update the presentation title', () {
    final n = _notifier()..newDeck('D');
    n.updateInfo(title: 'Nieuwe presentatietitel', author: 'Auteur');
    expect(n.state.deck!.title, 'Nieuwe presentatietitel');
    expect(n.state.deck!.author, 'Auteur');
  });

  test('generateMarkdown and applyMarkdown round-trip the deck', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bulletsImage, afterIndex: 0);
    n.updateSlide(
      1,
      n.state.deck!.slides[1].copyWith(
        title: 'Met beeld',
        bullets: ['Punt'],
        imagePath: 'images/x.png',
      ),
    );

    final markdown = n.generateMarkdown();
    expect(n.applyMarkdown(markdown), isTrue);

    final slides = n.state.deck!.slides;
    expect(slides, hasLength(2));
    expect(slides[1].type, SlideType.bulletsImage);
    expect(slides[1].imagePath, 'images/x.png');
  });

  test('slide operations are no-ops when no deck is open', () {
    final n = _notifier();
    n.addSlide(SlideType.bullets);
    n.removeSlide(0);
    n.duplicateSlide(0);
    expect(n.state.isOpen, isFalse);
  });

  // ── Ongedaan maken / opnieuw uitvoeren ─────────────────────────────────────

  test('a fresh deck has nothing to undo or redo', () {
    final n = _notifier()..newDeck('D');
    expect(n.canUndo, isFalse);
    expect(n.canRedo, isFalse);
    expect(n.state.canUndo, isFalse);
    expect(n.state.canRedo, isFalse);
  });

  test('undo reverts the last structural change and redo re-applies it', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets);
    expect(n.state.deck!.slides, hasLength(2));
    expect(n.canUndo, isTrue);

    n.undo();
    expect(n.state.deck!.slides, hasLength(1));
    expect(n.canUndo, isFalse);
    expect(n.canRedo, isTrue);

    n.redo();
    expect(n.state.deck!.slides, hasLength(2));
    expect(n.canRedo, isFalse);
  });

  test('undo/redo bumps revision so editors can re-sync', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets);
    final rev0 = n.state.revision;
    n.undo();
    expect(n.state.revision, rev0 + 1);
    n.redo();
    expect(n.state.revision, rev0 + 2);
  });

  test('rapid edits to the same slide coalesce into one undo step', () {
    final n = _notifier()..newDeck('D');
    // Three quick edits to slide 0 within the coalesce window.
    n.updateSlide(0, n.state.deck!.slides.first.copyWith(title: 'A'));
    n.updateSlide(0, n.state.deck!.slides.first.copyWith(title: 'AB'));
    n.updateSlide(0, n.state.deck!.slides.first.copyWith(title: 'ABC'));
    expect(n.state.deck!.slides.first.title, 'ABC');

    // A single undo jumps back past the whole burst to the original title.
    n.undo();
    expect(n.state.deck!.slides.first.title, 'D');
    expect(n.canUndo, isFalse);
  });

  test('a new edit clears the redo stack', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets);
    n.undo();
    expect(n.canRedo, isTrue);
    n.addSlide(SlideType.image); // diverging edit
    expect(n.canRedo, isFalse);
  });

  test('undo and redo are no-ops when their stacks are empty', () {
    final n = _notifier()..newDeck('D');
    n.undo(); // nothing to undo
    expect(n.state.deck!.slides, hasLength(1));
    n.redo(); // nothing to redo
    expect(n.state.deck!.slides, hasLength(1));
  });

  test('loading a deck clears the history', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets);
    expect(n.canUndo, isTrue);
    n.loadDeck(n.state.deck!);
    expect(n.canUndo, isFalse);
    expect(n.canRedo, isFalse);
  });

  // ── Slides overslaan ───────────────────────────────────────────────────────

  test('toggleSkip flips a single slide and reports the count', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets);
    expect(n.skippedCount, 0);

    n.toggleSkip(1);
    expect(n.state.deck!.slides[1].skipped, isTrue);
    expect(n.state.deck!.slides[0].skipped, isFalse);
    expect(n.skippedCount, 1);

    n.toggleSkip(1);
    expect(n.state.deck!.slides[1].skipped, isFalse);
    expect(n.skippedCount, 0);
  });

  test('toggleSkip is undoable', () {
    final n = _notifier()..newDeck('D');
    n.toggleSkip(0);
    expect(n.state.deck!.slides.first.skipped, isTrue);
    n.undo();
    expect(n.state.deck!.slides.first.skipped, isFalse);
  });

  test('clearAllSkips resets every slide in one step', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets);
    n.addSlide(SlideType.image);
    n.toggleSkip(0);
    n.toggleSkip(2);
    expect(n.skippedCount, 2);

    n.clearAllSkips();
    expect(n.skippedCount, 0);

    // One undo brings back the whole skipped set.
    n.undo();
    expect(n.skippedCount, 2);
  });

  test('clearAllSkips is a no-op when nothing is skipped', () {
    final n = _notifier()..newDeck('D');
    final before = n.canUndo;
    n.clearAllSkips();
    expect(n.canUndo, before); // geen nieuwe ongedaan-maken-stap
  });

  // ── Bulk-acties (multi-select) ─────────────────────────────────────────────

  test('removeSlides deletes several at once but keeps at least one', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets); // 1
    n.addSlide(SlideType.image); // 2
    n.addSlide(SlideType.quote); // 3  → 4 slides total
    n.removeSlides({1, 3});
    final types = n.state.deck!.slides.map((s) => s.type).toList();
    expect(types, [SlideType.title, SlideType.image]);

    // Mag nooit alles verwijderen.
    n.removeSlides({0, 1});
    expect(n.state.deck!.slides, hasLength(2));
  });

  test('removeSlides is one undoable step', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets);
    n.addSlide(SlideType.image);
    n.removeSlides({1, 2});
    expect(n.state.deck!.slides, hasLength(1));
    n.undo();
    expect(n.state.deck!.slides, hasLength(3));
  });

  test('setSkippedForSlides toggles skip on a set in one step', () {
    final n = _notifier()..newDeck('D');
    n.addSlide(SlideType.bullets);
    n.addSlide(SlideType.image);
    n.setSkippedForSlides({0, 2}, true);
    expect(n.state.deck!.slides[0].skipped, isTrue);
    expect(n.state.deck!.slides[1].skipped, isFalse);
    expect(n.state.deck!.slides[2].skipped, isTrue);
    expect(n.skippedCount, 2);

    n.setSkippedForSlides({0, 2}, false);
    expect(n.skippedCount, 0);
  });

  // ── Zoeken & vervangen ─────────────────────────────────────────────────────

  test('countMatches and replaceAll span all text fields', () {
    final n = _notifier()..newDeck('Acme jaarverslag');
    n.updateSlide(
      0,
      n.state.deck!.slides.first.copyWith(
        title: 'Acme groeit',
        bullets: ['Acme wint', 'overig'],
        notes: 'Acme intern',
      ),
    );

    expect(n.countMatches('Acme'), 3);

    final replaced = n.replaceAll('Acme', 'Globex');
    expect(replaced, 3);
    expect(n.countMatches('Acme'), 0);

    final slide = n.state.deck!.slides.first;
    expect(slide.title, 'Globex groeit');
    expect(slide.bullets.first, 'Globex wint');
    expect(slide.notes, 'Globex intern');
  });

  test('replaceAll is case-insensitive by default and exact when asked', () {
    final n = _notifier()..newDeck('D');
    n.updateSlide(
      0,
      n.state.deck!.slides.first.copyWith(title: 'Test test TEST'),
    );

    // Default: alle drie, ongeacht hoofdletters.
    expect(n.countMatches('test'), 3);
    // Hoofdlettergevoelig: alleen de exacte 'test'.
    expect(n.countMatches('test', caseSensitive: true), 1);

    n.replaceAll('test', 'x', caseSensitive: true);
    expect(n.state.deck!.slides.first.title, 'Test x TEST');
  });

  test('replaceAll is a single undoable step and a no-op without matches', () {
    final n = _notifier()..newDeck('D');
    n.updateSlide(
      0,
      n.state.deck!.slides.first.copyWith(title: 'Hallo wereld'),
    );
    final undosBefore = n.canUndo;

    expect(n.replaceAll('xyz', 'q'), 0); // geen match → niets verandert
    expect(n.canUndo, undosBefore);

    n.replaceAll('wereld', 'aarde');
    expect(n.state.deck!.slides.first.title, 'Hallo aarde');
    n.undo(); // één stap terug herstelt de hele vervanging
    expect(n.state.deck!.slides.first.title, 'Hallo wereld');
  });

  test('find and replace covers the second column and both column titles', () {
    final n = _notifier();
    n.loadDeck(
      Deck(
        title: 'D',
        slides: [
          Slide.create(SlideType.twoBullets).copyWith(
            title: 'foo title',
            columnTitle1: 'foo left',
            columnTitle2: 'foo right',
            bullets: ['foo a'],
            bullets2: ['foo b', 'foo c'],
          ),
        ],
      ),
    );

    // title + columnTitle1 + columnTitle2 + bullets(1) + bullets2(2) = 6
    expect(n.countMatches('foo'), 6);
    expect(n.replaceAll('foo', 'bar'), 6);

    final s = n.state.deck!.slides.first;
    expect(s.columnTitle1, 'bar left');
    expect(s.columnTitle2, 'bar right');
    expect(s.bullets2, ['bar b', 'bar c']);
  });
}
