# A collection of bibliographic entries, built on top of Text::BibTeX.
# Adam Sampson <ats@offog.org>

package Bibliography;

use strict;
use warnings;
use File::Temp qw(tempdir);
use POSIX qw(getcwd);

our (@ISA, @EXPORT_OK);
BEGIN {
	require Exporter;
	@ISA = qw(Exporter);
	@EXPORT_OK = qw(escape get_entry_date key_from_date);
}

use Text::BibTeX qw(:nameparts :joinmethods);
use Text::BibTeX::NameFormat;

#{{{  methods
sub new ($) {
	my $class = shift;

	my $self = {
		entries => {},
		filenames => {},

		bibtex_url => '',
		thumbnails_dir => '',
		thumbnails_url => '',
	};

	bless ($self, $class);
	return $self;
}

# Set an option.
# (This is a bit of a bodge.)
sub set ($$$) {
	my ($self, $name, $value) = @_;
	$self->{$name} = $value;
}

# Return a list of entries in the bibliography.
sub entries ($) {
	my ($self) = @_;
	return values %{$self->{entries}};
}

# Sort a list of entries by the keys generated by the provided function.
sub sort_entries ($$@) {
	my ($self, $make_key, @entries) = @_;

	my %sort_keys = ();
	foreach my $entry (@entries) {
		$sort_keys{$entry->key()} = &$make_key($entry);
	}
	return sort { $sort_keys{$a->key()} cmp $sort_keys{$b->key()} } @entries;
}

# Return a list of entries, sorted by the keys generated by the provided function.
sub sorted_entries ($$) {
	my ($self, $make_key) = @_;

	return $self->sort_entries($make_key, $self->entries());
}

# Return an entry given its key.
sub entry ($$) {
	my ($self, $key) = @_;
	return $self->{entries}->{$key};
}

# Return the BibTeX filename for an entry given its key.
sub filename ($$) {
	my ($self, $key) = @_;
	return $self->{filenames}->{$key};
}

# Load entries from BibTeX files.
sub load ($@) {
	my ($self, @filenames) = @_;

	foreach my $filename (@filenames) {
		my $bib = new Text::BibTeX::File($filename);
		$bib->set_structure('Bib');

		while (my $entry = new Text::BibTeX::Entry($bib)) {
			next if $entry->type() eq 'comment';

			my $key = $entry->key();
			$self->{entries}->{$key} = $entry;
			$self->{filenames}->{$key} = $filename;
		}
		$bib->close();
	}
}

# Resolve cross-references: when an entry refers to another (e.g. an
# inproceedings referring to a proceedings), import everything from the
# referred-to entry that isn't already present.
sub resolve_crossrefs ($) {
	my ($self) = @_;

	foreach my $entry (values %{$self->{entries}}) {
		my $crossref = $entry->get('crossref');
		if (defined $crossref) {
			my $from = $self->{entries}->{$crossref};
			foreach my $field ($from->fieldlist()) {
				unless ($entry->exists($field)) {
					$entry->set($field, $from->get($field));
				}
			}
		}
	}
}

# Format a name as a string.
sub format_name ($$) {
	my ($self, $name) = @_;

	my $format = new Text::BibTeX::NameFormat('fvjl', 0);
	foreach my $part (BTN_FIRST, BTN_VON, BTN_LAST, BTN_JR) {
		$format->set_options($part, 0, BTJ_SPACE, BTJ_SPACE);
	}

	return escape($format->apply($name));
}

# Format a list of names as a string.
sub format_names ($@) {
	my ($self, @names) = @_;

	my @formatted = map { $self->format_name($_) } @names;

	# Join the last two items with an "and".
	if (scalar @formatted >= 2) {
		my $last = pop @formatted;
		my $secondlast = pop @formatted;
		push @formatted, "$secondlast and $last";
	}

	return join(', ', @formatted);
}

# Format a BibTeX entry as multiple lines of HTML, returned as an array.
# You'll probably want to join the list with <br> or similar.
# The first line generated will always be the title.
sub format_entry ($$) {
	my ($self, $entry) = @_;
	my @lines = ();

	my $is_phdthesis = $entry->type() eq 'phdthesis';
	my $is_proceedings = $entry->type() eq 'proceedings';
	my $is_techreport = $entry->type() eq 'techreport';

	my $title = $entry->get('title') || $entry->get('booktitle');
	unless (defined $title) {
		die "No title found for " . $entry->key() . "\n";
	}
	push @lines, "<strong>" . escape($title) . "</strong>";

	my @authors = $entry->names('author');
	if (scalar @authors > 0) {
		push @lines, $self->format_names(@authors);
	}

	if ($is_phdthesis) {
		push @lines, "PhD thesis";
	}

	my $institution = $entry->get('institution') || $entry->get('school');
	if (defined $institution) {
		push @lines, escape($institution);
	}

	my @in = ();

	my @editors = $entry->names('editor');
	if (scalar @editors > 0) {
		push @in, $self->format_names(@editors) . " (eds.)"
	}

	# We're trying to produce something like:
	# "$booktitle, number $number in volume $volume of $series"
	my @book = ();

	my $booktitle = $entry->get('booktitle') || $entry->get('journal');
	if ($is_proceedings) {
		# We've shown the booktitle already above.
		$booktitle = undef;
	}
	my $number = $entry->get('number');
	my $volume = $entry->get('volume');
	my $series = $entry->get('series');

	if (defined $booktitle) {
		push @book, "<em>" . escape($booktitle) . "</em>";
		if (defined $number || defined $volume || defined $series) {
			push @book, ", ";
		}
	}

	if (defined $volume) {
		push @book, escape("volume $volume");
	}

	if (defined $number) {
		if (defined $volume) {
			push @book, " ";
		} elsif ($is_techreport) {
			push @book, "technical report ";
		}
		push @book, escape("number $number");
	}

	if (defined $series) {
		if (defined $number || defined $volume) {
			push @book, " of ";
		}
		push @book, escape($series);
	}

	if (scalar @book > 0) {
		# Uppercase the first letter ("Volume ...").
		$book[0] =~ s/^([a-z])/uc($1)/e;

		push @in, join('', @book);
	}

	my $pages = $entry->get('pages');
	if (defined $pages) {
		my $pp = "pp.";
		unless ($pages =~ /--/) {
			$pp = "p.";
		}

		push @in, $pp . " " . escape($pages);
	}

	my $publisher = $entry->get('publisher');
	if (defined $publisher) {
		push @in, escape($publisher);
	}

	if (scalar @in > 0) {
		unless ($is_proceedings || $is_techreport) {
			$in[0] = "In " . $in[0];
		}
		push @lines, @in;
	}

	my $comment = $entry->get('cosmoscomment');
	if (defined $comment) {
		# Note this isn't escaped, so it can include links.
		push @lines, $comment;
	}

	my $note = $entry->get('note');
	if (defined $note) {
		push @lines, escape($note);
	}

	return @lines;
}

# Add hyperlinks to the output of format_entry.
sub format_entry_links ($$$) {
	my ($self, $entry, $lines) = @_;

	# Turn the doi into a link if present.
	my $doi_url = $entry->get('doi');
	if (defined $doi_url && $doi_url !~ /^http:/) {
		$doi_url = "http://dx.doi.org/$doi_url";
	}

	# Make the title a link to the url/doi.
	my $url = $entry->get('url') || $doi_url;
	if (defined $url) {
		$lines->[0] = "<a href=\"" . escape($url) . "\">" . $lines->[0] . "</a>";
		if ($url =~ /\.(pdf|jpg|png)$/) {
			$lines->[0] .= " (" . uc($1) . ")";
		}
	}

	# Add other links at the bottom.
	my @links = ();
	my $filename = $self->filename($entry->key());
	# Strip all but basename from filename.
	$filename =~ s/.*\///;
	push @links, [$self->{bibtex_url} . $filename, 'BibTeX'];

	foreach my $row (['cosmosslides', 'Slides'],
	                 ['cosmosppt', 'Slides (PowerPoint)'],
	                 ['preprint', 'Preprint']) {
		my ($key, $label) = @$row;

		my $value = $entry->get($key);
		if (defined $value) {
			push @links, [$value, $label];
		}
	}

	if (scalar @links > 0) {
		my @links_html = map { '<a href="' . escape($_->[0]) . '">' . escape($_->[1]) . '</a>' } @links;
		push @$lines, join(' ', @links_html);
	}
}

# Add thumbnails to the output of format_entry.
sub format_entry_thumbnails ($$$) {
	my ($self, $entry, $lines) = @_;

	my $thumbnail = $entry->key() . '.png';
	my $thumbnail_file = $self->{thumbnails_dir} . $thumbnail;
	if (-e $thumbnail_file) {
		$_ = `identify $thumbnail_file`;
		unless (/PNG (\d+)x(\d+) /) {
			die "Cannot find size of $thumbnail_file\n";
		}

		my $img = '<img style="float: right; clear: both; padding: 0 0 2px 0;" src="' . escape($self->{thumbnails_url} . $thumbnail) . '" width="' . $1 . '" height="' . $2 . '" alt="">';
		my $url = $entry->get('url') || $entry->get('doi');
		if (defined $url) {
			$img = "<a href=\"" . escape($url) . "\">$img</a>";
		}
		$lines->[0] = $img . $lines->[0];
	}
}

# Return an entry as plain text in Harvard format.
# This uses BibTeX proper to do the formatting, so it's really slow!
sub harvard_entry ($$) {
	my ($self, $entry) = @_;

	my $key = $entry->key();
	my $filename = $self->filename($key);
	$filename =~ s/\.bib$//;

	my $dir = tempdir(CLEANUP => 1);
	my $here = getcwd();

	open AUX, ">$dir/entry.aux";
	print AUX "\\citation{$key}\n";
	print AUX "\\bibstyle{agsm}\n";
	print AUX "\\bibdata{$here/$filename}\n";
	close AUX;

	chdir($dir);
	if (system("bibtex", "-terse", "entry") != 0) {
		die "bibtex failed";
	}
	chdir($here);

	open BBL, "<$dir/entry.bbl";
	my $text = "";
	for (<BBL>) {
		chomp;
		$text = "$text $_";
	}
	close BBL;

	$text =~ s/\s*,/,/g;

	$text =~ s/{\\o}/ø/g;
	$text =~ s/\$\\pi\$/pi/g;

	$text =~ s/\\begin\{thebibliography\}\{[^}]*\}//g;
	$text =~ s/\\end\{thebibliography\}//g;
	$text =~ s/\\harvarditem.*?}\s//g;
	$text =~ s/\\harvardand\\ /and /g;
	$text =~ s/\\harvardyear(left|right)\s*//g;
	$text =~ s/\{\\(?:em|bf) ([^}]*)\}/$1/g;
	$text =~ s/\\(newline|newblock)//g;
	$text =~ s/\\harvardurl\{[^}]*\}//g;
	if ($text =~ /\\/) {
		die "Unhandled backslash in '$text'\n";
	}

	$text =~ s/[{}]//g;
	$text =~ s/~/ /g;
	$text =~ s/\s+/ /g;
	$text =~ s/^\s*//;
	$text =~ s/\s*$//;

	return $text;
}
#}}}

#{{{  functions
# Escape HTML special characters, and convert TeX specials to HTML equivalents.
sub escape ($) {
	local ($_) = @_;

	s/&/&amp;/g;
	s/</&lt;/g;
	s/>/&gt;/g;

	s/---?/&mdash;/g;

	s/\{\\"o\}/&#xF6;/g;
	s/\{\\o\}/&#xF8;/g;
	s/\{\\"u\}/&#xFC;/g;
	s/\$\\pi\$/pi/g;
	if (/[\{\}]/) {
		warn "Unhandled special characters in string: '$_'\n";
		s/[\{\}]//g;
	}

	return $_;
}

my %MONTHS = (
	'January' => 1, 'February' => 2, 'March' => 3,
	'April' => 4, 'May' => 5, 'June' => 6,
	'July' => 7, 'August' => 8, 'September' => 9,
	'October' => 10, 'November' => 11, 'December' => 12,
);

# Work out a date (as accurately as possible) for an entry.
sub get_entry_date ($) {
	my ($entry) = @_;

	my $key = $entry->key();
	my $year = $entry->get('year');
	my $month = $entry->get('month');
	my $day = $entry->get('day') || 15;
	unless (defined $year && defined $month) {
		die "Entry $key does not have both month and year\n";
	}
	unless (exists $MONTHS{$month}) {
		die "Entry $key has unrecognisable month $month\n";
	}
	return sprintf("%04d-%02d-%02d", int($year), $MONTHS{$month}, int($day));
}

# Make a sort key for an entry based on its date.
sub key_from_date ($) {
	my ($entry) = @_;

	# The sort key has the entry's key appended to it, to keep the sort
	# order stable.
	return get_entry_date($entry) . "-" . $entry->key;
}
#}}}

1;
