use lib 'lib';
use lib 'specs/support';
use BDD::Behave;
use BloginTest;
use Blogin::Site;
use Blogin::Search;
use Template::HAML;
use JSON::Fast;

my $PAGINATED = 'specs/fixtures/paginated'.IO;


sub build(IO::Path:D $out, *%options) {
  Blogin::Site::build(
    content => $PAGINATED.add('content'),
    out     => $out,
    layouts => $PAGINATED.add('layouts'),
    |%options,
  );
}

my @FIXTURE-INDEX =
  %( title => 'Raku Grammars', url => '/posts/grammars', tags => ['raku'],   text => 'grammars parse text' ),
  %( title => 'A Web Note',    url => '/posts/web',      tags => ['web'],    text => 'raku on the web' ),
  %( title => 'Cooking',       url => '/posts/cooking',  tags => ['food'],   text => 'a raku mention once' );

describe 'the ranking logic', {
  it 'weights a title hit above a body hit', {
    my @ranked = Blogin::Search::rank(@FIXTURE-INDEX, 'raku');
    expect(@ranked[0]<title>).to.eq('Raku Grammars');
  }

  it 'ranks a tag hit above a body-only hit', {
    my @ranked = Blogin::Search::rank(@FIXTURE-INDEX, 'raku');
    expect(@ranked[1]<title>).to.eq('A Web Note');
  }

  it 'excludes records with no hits', {
    my @ranked = Blogin::Search::rank(@FIXTURE-INDEX, 'grammars');
    expect(@ranked.elems).to.eq(1);
  }

  it 'caps the number of results', {
    my @ranked = Blogin::Search::rank(@FIXTURE-INDEX, 'raku', cap => 2);
    expect(@ranked.elems).to.eq(2);
  }

  it 'returns nothing for an empty query', {
    expect(Blogin::Search::rank(@FIXTURE-INDEX, '').elems).to.eq(0);
  }

  it 'matches a word by a partial prefix as the query is typed', {
    my @ranked = Blogin::Search::rank(@FIXTURE-INDEX, 'gram');
    expect(@ranked[0]<title>).to.eq('Raku Grammars');
  }

  it 'matches a tag by a partial prefix', {
    my @ranked = Blogin::Search::rank(@FIXTURE-INDEX, 'foo');
    expect(@ranked[0]<title>).to.eq('Cooking');
  }

  it 'still matches when the prefix grows to the full word', {
    my @short = Blogin::Search::rank(@FIXTURE-INDEX, 'gram');
    my @full  = Blogin::Search::rank(@FIXTURE-INDEX, 'grammars');
    expect(@short[0]<title>).to.eq(@full[0]<title>);
  }
}

describe 'the search index', {
  let(:out, { temp-dir('search') });

  after-each { nuke(out()) }

  it 'writes search-index.json', {
    build(out());
    expect(out().add('search-index.json').e).to.be-truthy;
  }

  it 'has one record per built post', {
    build(out());
    my @records = from-json(out().add('search-index.json').slurp);
    expect(@records.elems).to.eq(4);
  }

  it 'records the post url and title', {
    build(out());
    my @records = from-json(out().add('search-index.json').slurp);
    my $alpha = @records.first({ .<url> eq '/posts/alpha' });
    expect($alpha<title>).to.eq('Alpha');
  }

  it 'truncates the body text to the configured length', {
    build(out(), search-text-length => 3);
    my @records = from-json(out().add('search-index.json').slurp);
    expect(@records.map(*.<text>.chars).max).to.be-less-than-or-equal-to(3);
  }

  it 'emits search.js', {
    build(out());
    expect(out().add('assets/js/search.js').e).to.be-truthy;
  }

  it 'injects the result cap into search.js', {
    build(out(), search-cap => 7);
    expect(out().add('assets/js/search.js').slurp.contains('BLOGIN_SEARCH_CAP = 7')).to.be-truthy;
  }

  it 'hides the results list until there are matches', {
    build(out());
    expect(out().add('assets/css/search.css').slurp.contains('[data-blogin-results]:empty')).to.be-truthy;
  }

  it 'omits search output when search is disabled', {
    build(out(), search => False);
    expect(out().add('search-index.json').e).to.be-falsy;
  }

  it 'emits a search stylesheet', {
    build(out());
    expect(out().add('assets/css/search.css').e).to.be-truthy;
  }

  it 'styles the results as a dropdown in the stylesheet', {
    build(out());
    expect(out().add('assets/css/search.css').slurp.contains('[data-blogin-results]')).to.be-truthy;
  }

  it 'omits the search stylesheet when search is disabled', {
    build(out(), search => False);
    expect(out().add('assets/css/search.css').e).to.be-falsy;
  }
}

describe 'the search stylesheet', {
  it 'positions the results relative to the search container', {
    expect(Blogin::Search::search-css().contains('.blogin-search')).to.be-truthy;
  }

  it 'renders the results as a fixed floating layer', {
    expect(Blogin::Search::search-css().contains('position: fixed')).to.be-truthy;
  }

  it 'scrolls the results vertically only, never horizontally', {
    expect(Blogin::Search::search-css().contains('overflow: hidden auto')).to.be-truthy;
  }

  it 'styles the widget without depending on a css framework', {
    expect(Blogin::Search::search-css().contains('list-group')).to.be-falsy;
  }
}

describe 'a clickable search result', {
  let(:js, { Blogin::Search::search-js() });

  it 'wraps the result title in the anchor', {
    expect(js().contains('a.appendChild(title)')).to.be-truthy;
  }

  it 'wraps the result snippet in the same anchor', {
    expect(js().contains('a.appendChild(p)')).to.be-truthy;
  }

  it 'puts only the anchor directly in the list item', {
    expect(js().contains('li.appendChild(p)')).to.be-falsy;
  }

  it 'floats the results on the body so no ancestor can trap them', {
    expect(js().contains('document.body.appendChild(results)')).to.be-truthy;
  }

  it 'positions the floated results at the input', {
    expect(js().contains('getBoundingClientRect')).to.be-truthy;
  }

  it 'gives the result anchor the padded clickable area', {
    my $css = Blogin::Search::search-css();
    expect($css.contains('[data-blogin-results] a {') && $css.contains('padding: 0.5rem 0.75rem;')).to.be-truthy;
  }
}

describe 'the search partial', {
  it 'renders a form and the script tag', {
    my $haml = HAML.new(search-paths => 'specs/fixtures/layouts');
    my $html = $haml.render(:file<search>);
    expect($html.contains('/assets/js/search.js') && $html.contains('data-blogin-search')).to.be-truthy;
  }
}
