#!/usr/bin/perl

# enables 'strict' and 'warnings'
use v5.36;

use Momjian_us;

###

use File::Basename;
use File::stat;
use File::Temp 'tempfile';
use Fcntl ':flock';
use Getopt::Std;
use POSIX 'strftime';
use Readonly;
use Text::Tabs;
use Text::Wrap;
use Time::localtime;
use Term::ANSIColor;
use Term::ReadKey;
use Encode;

# --force to prevent binary file warning
Readonly my $LESS_OPTIONS =>
  '--RAW-CONTROL-CHARS --QUIT-AT-EOF --search-options R --force --lesskey-file /u/lesskey/lesskey_ema.bin';
Readonly my $BASE => basename("$0");
# MCAL_DESCRIPTION_OFFSET is one space before the description to handle the globe character width
Readonly my $INDENT_SPACES => $ENV{MCAL_DESCRIPTION_OFFSET} + 1;

Readonly my $CAL_WIDTH0 => 200;
my %options;

# The global variables are dependent on parsing the command-line options
# so we set them here.
# need $options{color} set
set_defaults();
get_mcal_month() if ($BASE ne 'calsort');


# need STDOUT to produce ANSI so addmcal and 'today' can display it
Readonly my $TERM_TYPE => (defined($ENV{TERM}) &&
	  $ENV{TERM} ne 'dumb' &&
	  (-t STDOUT || $options{color})) ?
  ( ($ENV{TERM} eq 'xterm-256color' || $ENV{TERM} eq 'screen.xterm-256color')
	? 'ANSI-256color' :
	  'ANSI') :
  'dumb';

Readonly my $WIDTH => $ENV{WIDTH}
  // ((!-t STDIN) ? 0 : (GetTerminalSize())[0]);

# plain entry
Readonly my $TERM_RESET => ($TERM_TYPE ne 'dumb') ? color('reset') :
  '';

# calendar
# use 'reset' so we only use one escape,
# needed for /usr/lbin/bin/mcal_days too
# https://perldoc.perl.org/Term::ANSIColor
# In Connectbot's xterm-256color, "faint white"/dim (slot 7) is hard to
# distinguish from default "white" and "bold white", so we use "faint
# bright_black" (slot 8).  For Putty's xterm, "faint bright_black" is too dark
# so we use "faint white".  The server console is also xterm-256color.
# Escape sequence details:
#   https://stackoverflow.com/questions/4842424/list-of-ansi-color-escape-sequences
#   for xterm, "tput -T xterm setaf 8" is "\033[38m"
#   for xterm-256color, "tput -T xterm-256color setaf 8" is "\033[90m"
my $CAL_RESET =
  ($TERM_TYPE eq 'dumb') ? '' :
  ($TERM_TYPE eq 'ANSI-256color') ?
  color('reset faint bright_black on_black') :
  color('reset faint white on_black');

# plain entry
Readonly my $WHITE_RESET => ($TERM_TYPE ne 'dumb') ?
  color('reset bold white on_black') :
  '';

# greenbar
# reset seems redundant but needed, bold doesn't seem to undo faint.
Readonly my $GREEN_RESET => ($TERM_TYPE ne 'dumb') ?
  color('reset bold green on_black') :
  '';

# dim green
Readonly my $GREEN_DIM_RESET => ($TERM_TYPE ne 'dumb') ?
  color('reset faint green on_black') :
  '';

# dim white
my $WHITE_DIM_RESET =
  ($TERM_TYPE eq 'dumb') ? '' :
  ($TERM_TYPE eq 'ANSI-256color') ?
  color('reset faint bright_black on_black') :
  color('reset faint white on_black');

# current day
Readonly my $MARK_CURDAY => ($TERM_TYPE ne 'dumb') ? color('bold red') : '';

# active
Readonly my $CAL_ACTIVE => ($TERM_TYPE ne 'dumb') ? color('bold yellow') : '';

# must be global for sort comparison access
my (%display_date, %current_date);

# must do it here so we can get -c
# no proto as we call early
sub set_defaults
{
	@display_date{qw(day month year)} =
	  (localtime->mday(), localtime->mon(), localtime->year());
	$display_date{year} += 1900;
	$current_date{year} = $display_date{year};

	$display_date{month}++;

	# strip off leading zero
	$display_date{month} += 0;
	$current_date{month} = $display_date{month};
	return;
}


# no proto as we call early
sub get_mcal_month
{
	my %argv_opts;

	$options{args_supplied} = 1 if (@ARGV);

	getopts('abcht', \%argv_opts) or exit(1);

	$options{print_all} = $argv_opts{a};
	$options{add_birthdays} = $argv_opts{b};
	$options{color} = $argv_opts{c};
	$options{help} = $argv_opts{h};
	$options{timezone} = $argv_opts{t};

	if ($options{help})
	{
		print(
			"Usage:  $BASE [-a|-b|-c|-t]
	-a all entries
        -b birthday
        -c color
        -t time zone names\n");
		exit;
	}

	$display_date{day} = 0 if ($options{print_all});

	# Get arguments if supplied
	$display_date{month} = shift(@ARGV) if (@ARGV);
	if (@ARGV)
	{
		$display_date{year} = shift(@ARGV);
	}

	# past month specified but not year
	elsif ($display_date{month} < localtime->mon() + 1)
	{
		$display_date{year}++;
	}
	die "$BASE:  Too many arguments\n" if (@ARGV);

	# strip off leading zero
	$display_date{month} += 0;
	return;
}


# find month X months in the future
sub get_future_month    #($)
{
	return ($display_date{month} + shift() - 1) % 12 + 1;
}


# find year X months in the future, X <= 12
sub get_future_year_of_month    #($)
{
	return ($display_date{year} + ($display_date{month} + shift() > 12));
}


sub print_error_entry           #($)
{
	my $entry = shift();

	for my $key (keys %$entry)
	{
		# capitalize key
		(my $capkey = $key) =~ s/(\w+)/\L\u$1/g;
		say(STDERR "$capkey = " . $entry->{$key});
	}
	say(STDERR "\nUse 'editmcal' to fix.") if (!$is_debug);
	return;
}


sub get_meridian_order    #($)
{
	my $meridian = shift();

	return
	  !defined($meridian) ? 0 :
	  $meridian eq 'all'  ? 1 :
	  $meridian eq 'm'    ? 2 :
	  $meridian eq 'a'    ? 2 :
	  $meridian eq 'am'   ? 2 :
	  $meridian eq 'n'    ? 3 :
	  $meridian eq 'p'    ? 3 :
	  $meridian eq 'pm'   ? 3 :
	  0;
}


sub get_hour_order    #($$)
{
	my ($hour, $meridian_order) = @_;

	return ($meridian_order == 0) ? 0 :
	  # 12 becomes 0 and 13 becomes 1
	  defined($hour)         ? $hour % 12 :
	  ($meridian_order == 1) ? 8 :            # 8am
	  ($meridian_order == 2) ? 9 :            # 9am
	  ($meridian_order == 3) ? 5 : 0;         # 5pm
}


sub parse_time                                #($)
{
	my $time = shift;

	# time field already validated
	# ?: allows grouping without backreferences
	# must handle 8:00p and 8p (from "until")
	my ($hour, $minutes, $meridian) =
	  ($time =~ m{^ (?:  (\d+) (?::(\d+))?  )?  ([ap]m?|[nm])? $}ix);

	# do meridian first as it can affect 'hour'
	my $meridian_order = get_meridian_order($meridian);
	$hour = get_hour_order($hour, $meridian_order);
	$minutes //= 0;

	return ($hour, $minutes, $meridian_order);
}


# $a/$b assigned, unless prototypes are used, then forced into @_
sub entry_cmp    #($$)
{
	# months differ?
	if ($a->{month} != $b->{month})
	{
		return 1
		  if ($a->{month} < $display_date{month} &&
			$b->{month} >= $display_date{month});
		return -1
		  if ($a->{month} >= $display_date{month} &&
			$b->{month} < $display_date{month});

		# both are before or after $display_date{month}
		# so they must be in the same year
		return ($a->{month} <=> $b->{month});
	}

	# days differ?
	return ($a->{day} <=> $b->{day}) if ($a->{day} != $b->{day});

	# times differ?  At least one is not blank.
	if ($a->{time} ne $b->{time})
	{

		# One blank time?  It goes first unless they are birthdays
		# (parens), in which case they go last.
		return ($b->{description} =~ m/^\(/ ? -1 : 1)
		  if ($a->{time} && !$b->{time});
		return ($a->{description} =~ m/^\(/ ? 1 : -1)
		  if (!$a->{time} && $b->{time});

		# No blanks; both integers (years)?
		return ($a->{time} <=> $b->{time})
		  if ($a->{time} =~ m/^\d*$/ && $b->{time} =~ m/^\d*$/);

		# Not both integers;  is one an integer?  If so, years after times
		return 1 if ($a->{time} =~ m/^\d*$/);
		return -1 if ($b->{time} =~ m/^\d*$/);

		my ($a_hour, $a_minute, $a_meridian_order) = parse_time($a->{time});

		my ($b_hour, $b_minute, $b_meridian_order) = parse_time($b->{time});
		debug("$b->{time}: $b_hour:$b_minute:$b_meridian_order");

		return ($a_meridian_order <=> $b_meridian_order ||
			  $a_hour   <=> $b_hour ||
			  $a_minute <=> $b_minute);
	}

	# all the same, compare descriptions
	return 1 if ($a->{description} =~ m/^\(/ && $b->{description} !~ m/^\(/);
	return -1 if ($a->{description} !~ m/^\(/ && $b->{description} =~ m/^\(/);
	return ($a->{description} cmp $b->{description});
}


sub add_file_entries    #($)
{
	my $filename = shift();
	# a calsort failure is probably from mcal so report that to the user
	my $base = $BASE eq 'calsort' ? 'mcal' : $BASE;
	my @entries;

	open(my $mcal, '<', $filename) or sysdie("cannot open $filename");
	while (my $line = <$mcal>)
	{
		my %new_entry;

		chomp($line);

		next if ($line =~ m/^\s*$/);

		# We can handle space or tabs with regex, but it is hard
		# to handle a mix of spaces and tabs in the same field
		# separator block, so just convert everything to spaces
		$line = expand($line);

		# trim off trailing spaces
		$line =~ s/ +$//;

		# time is optional
		$line =~ m{^(\d+)/(\d+) {1,5}([^ ]*) +(.*)$} or
		  die
		  "Invalid $base date entry on line $.\n$line\nUse 'edit$base' to repair.\n";

		$new_entry{month} = $1;
		$new_entry{day} = $2;
		$new_entry{time} = $3;
		$new_entry{description} = $4;

		# skip date if already past
		next
		  if (!$options{print_all} &&
			$display_date{month} == localtime->mon() + 1 &&
			$display_date{year} == localtime->year() + 1900 &&
			$new_entry{month} == $display_date{month} &&
			$new_entry{day} < $display_date{day});


		# convert ## to military hours
		$new_entry{time} .= ':00' if ($new_entry{time} =~ m{^(\d\d?)$});

		# convert military time to am/pm
		if ($new_entry{time} =~ m{^(\d+)(:\d+)$})
		{
			# strip off leading zeros
			my $hour = $1 + 0;

			# midnight and noon need special adjustment
			($hour == 0)    ? $new_entry{time} = '12' . $2 . 'a' :
			  ($hour < 12)  ? $new_entry{time} .= 'a' :
			  ($hour == 12) ? $new_entry{time} .= 'p' :
			  ($hour < 24)  ? $new_entry{time} = ($hour - 12) . $2 . 'p' :
			  0;
		}

		# change noon to 12:00p
		$new_entry{time} =~ s{^noon$}{12:00n}i;

		# change mid to 12:00a
		$new_entry{time} =~ s{^mid$}{12:00m}i;

		# lowercase meridian
		$new_entry{time} =~ s{(\d+(?::\d+)?)([ap]m?|[nm])$}{$1\L$2}i;
		$new_entry{time} =~ s{(am|pm|noon|mid|all)$}{\L$1}i;

		# strip of trailing 'm' in 6pm
		$new_entry{time} =~ s{(\d+(?::\d+)?)([ap])m?$}{$1$2}i;

		# change 2p to 2:00p
		$new_entry{time} =~ s{^(\d+)([apnm])$}{$1:00$2}i;

		# change 2:0p to 2:00p
		$new_entry{time} =~ s{^(\d+:\d)([apnm])$}{${1}0$2}i;

		# strip off leading zeros
		$new_entry{time} =~ s{^0*(.*)$}{${1}}i;

		# Verify time format as single number(year) or time, or print error line number
		$new_entry{time} =~ m{^(\d+|\d+:\d\d[apnm]|am|pm|all)?$}i or
		  die
		  "Invalid $base time entry on line $.\n$line\nUse 'edit$base' to repair.\n";

		# add custom entries to be used later
		$new_entry{is_future_year} =
		  ($new_entry{description} =~ m/20[[:digit:]]{2} .*\* *$/);

		$new_entry{is_declined} =
		  ($new_entry{description} =~ m/(^|- )declined\b/);

		print_error_entry(\%new_entry) if ($is_debug);

		push @entries, \%new_entry;    # returned as a closure
	}
	close($mcal) or sysdie("cannot close $filename");
	return \@entries;                  # returned as a closure
}


# move first column of description over, or add "Birthday"
sub adjust_birthday_entries            #($)
{
	for my $entry (@{ shift() })
	{
		# has entry type prefix?
		$entry->{description} =~ m/^\w+: +/ ?
		  # add parens about type prefix
		  $entry->{description} =~ s/^(\w+): +/($1) - / :
		  $entry->{description} =~ s/^/(Birthday) - /i;
	}
	return;
}


sub create_calendar    #($)
{
	my $entries = shift();
	my @cal;

	# length is 23, and we add a space before and after later, so 25
	for my $calno (0 .. ($WIDTH ? $WIDTH : $CAL_WIDTH0) / 25 - 1)
	{
		my $str =
		  'ncal -b -h ' .
		  get_future_month($calno) . ' ' .
		  get_future_year_of_month($calno);

		# save array reference, one line per array element
		$cal[$calno] = [`$str`];
		chomp(@{ $cal[$calno] });
	}

	# calendar is bold, with reverse for active days, and red for
	# the current day

	# find maximum line width for calendars
	my $cal_width = 0;
	map {
		map { $cal_width = length($_) if (length($_) > $cal_width) }
		  @$_
	} @cal[ 0 .. $#cal ];

	# We add cal_width spaces, then strip length to cal_width
	# Add space on left and right of each calendar too for date matching
	# Must be done before color is added so lengths are correct
	for (@cal[ 0 .. $#cal ])
	{
		for (@$_)
		{
			$_ .= ' ' x $cal_width;
			s/^(.{$cal_width}).*$/ $1 /;
		}
	}

	# mark current day first so it will not get CAL_ACTIVE
	if ($display_date{year} == $current_date{year} &&
		$display_date{month} == $current_date{month})
	{
		for (@{ $cal[0] })
		{
			# WHITE_RESET need to turn off "dim" so "bold" appears.
			s/ ($display_date{day}) / $WHITE_RESET$MARK_CURDAY$1$CAL_RESET /;
		}
	}

	# mark active days for non-mcal and non-birthdays
	if ($BASE ne 'mcal' && $BASE ne 'birthdays')
	{
		for my $entry (@$entries)
		{
			# skip birthday entries, start with "("
			next if ($entry->{description} =~ m/^\(/);

			# skip recurring entries, end with "*"
			next if ($entry->{description} =~ m/\*\s*$/);

			# skip specially marked entries
			next if ($entry->{description} =~ m/%\s*$/);

			# skip conference call
			next
			  if ($entry->{description} =~
				m{Bruce/EDB.*(teleconference|conference call|office vacation)}i
			  );

			next if ($entry->{is_future_year});

			next if ($entry->{is_declined});

			# Use spaces instead of \b to prevent item from being highlighted multiple times
			# (escape string might appear as a word break character)
			for my $i (0 .. $#cal)
			{
				if (get_future_month($i) == $entry->{month})
				{
					for (@{ $cal[$i] })
					{
						s/ ($entry->{day}) / $CAL_ACTIVE$1$CAL_RESET /;
					}
				}
			}
		}
	}
	return \@cal;
}


sub produce_output    #($$)
{
	my ($entries, $cal) = @_;

	my $out;
	my $out_filename;
	my $LOGNAME = $ENV{LOGNAME} // '';
	my $tz_offset_cache_miss = 0;
	my $tz_offset_filename = '/u/mcal/cache/airport_tzoffs';
	# don't use Time::localtime
	# This is not adjusted for the format date, but it is close enough.
	my $local_tz_offset = strftime('%-z', CORE::localtime());
	if (!$options{args_supplied} && $LOGNAME eq 'root')
	{
		$out_filename = "/u/mcal/cache/$BASE.$TERM_TYPE.$WIDTH";
		open($out, '>', $out_filename) or
		  sysdie("cannot create $out_filename");
		flock($out, LOCK_EX) or sysdie('Locking failed');
	}
	else
	{
		($out, $out_filename) = tempfile(UNLINK => 1);
	}

	# load cache of airport time zone offsets
	my %airport_offset;
	if (-e $tz_offset_filename)
	{
		open(my $tz_offset_file, '<', $tz_offset_filename) or
		  sysdie("cannot open $tz_offset_filename");
		while (my $line = <$tz_offset_file>)
		{
			chomp($line);
			$airport_offset{$1} = $2
			  if ($line =~ m/^([^\t]+)\t([^\t]+)$/);
		}
		close($tz_offset_file) ||
		  sysdie("cannot close $tz_offset_filename");
	}

	# print calendar at top
	for my $lineno (0 .. $#{ $cal->[0] })
	{
		for my $calno (0 .. $#$cal)
		{
			# Do reset at the beginning of every line so up-arrow works.
			print({$out} $CAL_RESET . ' ') if ($calno == 0);
			print({$out} $cal->[$calno][$lineno]);
			print({$out} ($calno != $#$cal) ? ' ' : "\n");
		}
	}

	my $last_month = '';
	my $last_day = '';
	my $dow = '';
	my $bar_mode = 1;    # force start in WHITE_RESET mode

	if ($WIDTH)
	{
		# secondary lines need two extra spaces
		$Text::Wrap::columns = $WIDTH - $INDENT_SPACES - 2;
		$Text::Wrap::unexpand = 0;    # no tab expansion
	}

	my ($now_hour, $now_minute, $now_meridian_order) = parse_time(
		localtime->hour() == 0 ? '12:' . localtime->min . 'a' :
		  localtime->hour() < 12 ?
		  localtime->hour() . ':' . localtime->min . 'a' :
		  localtime->hour() == 12 ? '12:' . localtime->min . 'p' :
		  (localtime->hour() - 12) . ':' . localtime->min . 'p');

	my $is_today;

	for my $entry (@$entries)
	{
		my $airport_tz_offset;

		print({$out} "\n");

		# get entry time zone
		if ((my $airport_code = $entry->{description}) =~
			s/^.*\btime zone ([[:upper:]]{3})\b.*$/$1/)
		{
			$airport_tz_offset = $airport_offset{$airport_code};

			# not in cache?
			if (!defined($airport_tz_offset))
			{
				# This is not adjusted for the event date, so it is
				# really just today's offset, even for future events.
				# _NR used to report proper error line number
				$airport_tz_offset =
				  `awk --file /usr/lbin/bin/is_in_future.awk --source 'BEGIN {detail="time zone $airport_code"; errdetail = "$entry->{month}/$entry->{day}"; print get_time_zone_offset_no_cache();}'`;
				chomp($airport_tz_offset);
				$airport_offset{$airport_code} = $airport_tz_offset;

				$tz_offset_cache_miss = 1;
			}
		}

		# New date?, adjust color, $last_month can be blank
		if ($entry->{month} ne $last_month ||
			$entry->{day} ne $last_day)
		{
			$bar_mode = !$bar_mode;

			$last_month = $entry->{month};
			$last_day = $entry->{day};

			# cache dow
			$dow = strftime(
				'%a', 0, 0, 0,
				$entry->{day},
				$entry->{month} - 1,
				(   ($entry->{month} >= $display_date{month}) ?
					  $display_date{year} :
					  $display_date{year} + 1) - 1900);

			# Are we displaying today as the first day?
			$is_today = !$options{print_all} &&
			  $display_date{month} == localtime->mon() + 1 &&
			  $display_date{year} == localtime->year() + 1900 &&
			  $entry->{month} == $display_date{month} &&
			  $entry->{day} == $display_date{day};
		}

		my $line_color;

		# Not today?  No grey mode.
		if (!$is_today)
		{
			# Alternate dates with GREEN_RESET and WHITE_RESET.
			# Do highlighting at the beginning of every line so up-arrow works.
			$line_color = (!$entry->{is_future_year} && !$entry->{is_declined}) ?
			  ($bar_mode ? $GREEN_RESET : $WHITE_RESET) :
			  # Dim for future year entries.
			  ($bar_mode ? $GREEN_DIM_RESET : $WHITE_DIM_RESET);
		}
		# Today, and time is blank or entry is a future year?  dim
		elsif ($entry->{time} eq '' ||
			$entry->{is_future_year} ||
			$entry->{is_declined})
		{
			$line_color = $WHITE_DIM_RESET;
		}
		# Today, the time not blank?  Dim items that are past.
		else
		{
			my ($until_hour, $until_minute, $until_meridian_order);

			# This code is also in is_in_future.awk.

			# Use "until" time
			if ($entry->{description} =~
				m/[^[:alpha:]]until +(\d[\d:]*[apnm])/)
			{
				($until_hour, $until_minute, $until_meridian_order) =
				  parse_time($1);

				my ($start_hour, $start_minute, $start_meridian_order) =
				  parse_time($entry->{time});

				# Is "until" after start?  If so, add 24 hours.
				if ($start_meridian_order > $until_meridian_order ||
					($start_meridian_order == $until_meridian_order &&
						$start_hour > $until_hour) ||
					($start_meridian_order == $until_meridian_order &&
						$start_hour == $until_hour &&
						$start_minute > $until_minute))
				{
					if ($until_meridian_order < get_meridian_order('p'))
					{
						# advance to next meridian and add 12 hours
						$until_meridian_order = get_meridian_order('p');
						$until_hour += 12;
					}
					else
					{
						$until_hour += 24;

					}
				}
			}
			else
			{
				# assume it ends one hour after start
				($until_hour, $until_minute, $until_meridian_order) =
				  parse_time($entry->{time});

				# 11 a/p?
				if ($until_hour == 11)
				{
					# 11a
					if ($until_meridian_order == get_meridian_order('a'))
					{
						# set to 12:##pm
						$until_meridian_order = get_meridian_order('p');
						$until_hour =
						  get_hour_order(12, $until_meridian_order);
					}
					else
					  # 11p, force it to have a future meridian.
					{
						$until_meridian_order = get_meridian_order('p') + 1;
					}
				}
				else
				{
					$until_hour =
					  get_hour_order($until_hour + 1, $until_meridian_order);
				}
			}

			# adjust for entry time zone
			if (defined($airport_tz_offset) &&
				($until_meridian_order == 2 || $until_meridian_order == 3))
			{
				my $relative_tz_hour =
				  int($airport_tz_offset / 100) - int($local_tz_offset / 100);
				my $relative_tz_minutes =
				  $airport_tz_offset % 100 - $local_tz_offset % 100;

				# adjust minute under/overflow
				if ($relative_tz_minutes < 0)
				{
					$relative_tz_hour--;
					$relative_tz_minutes += 60;
				}
				if ($relative_tz_minutes >= 60)
				{
					$relative_tz_hour++;
					$relative_tz_minutes -= 60;
				}

				$until_hour   -= $relative_tz_hour;
				$until_minute -= $relative_tz_minutes;

				# adjust minute under/overflow
				if ($until_minute < 0)
				{
					$until_hour--;
					$until_minute += 60;
				}
				if ($until_minute >= 60)
				{
					$until_hour++;
					$until_minute -= 60;
				}

				# adjust hour under/overflow
				if ($until_meridian_order == 3 && $until_hour < 0)
				{
					$until_meridian_order = 2;
					$until_hour += 12;
				}
				elsif ($until_meridian_order == 2 && $until_hour > 11)
				{
					$until_meridian_order = 3;
					$until_hour -= 12;
				}
			}

			# Is it current, not past?
			if ((   $until_meridian_order > $now_meridian_order ||
					($until_meridian_order == $now_meridian_order &&
						$until_hour > $now_hour) ||
					($until_meridian_order == $now_meridian_order &&
						$until_hour == $now_hour &&
						$until_minute > $now_minute)))
			{
				# Is today, so can't be greenbar.
				$line_color = $WHITE_RESET;
			}
			else
			{
				$line_color =  $WHITE_DIM_RESET;
			}
		}

		# output start of date entry, length $INDENT_SPACES
		printf(
			{$out} '%s%2s/%-2s  %3s   %6s', $line_color,
			$entry->{month}, $entry->{day},
			# Output "dow" as blank for future years.
			(   !$entry->{is_future_year} ? $dow :
				  '   ',
				$entry->{time}));

		# output non-local time zone marker
		if (defined($airport_tz_offset))
		{
			# show proper globe location
			# only show icon for non-local time zones, character is double-wide
			printf(
				{$out} (
					$airport_tz_offset eq '' ||
					  $airport_tz_offset == $local_tz_offset ? '  ' :   #blank
					  $airport_tz_offset <= -100 ? '🌎' :    # America
					  $airport_tz_offset <=  300 ? '🌍' :    # Europe/Africa
					  '🌏'));    # Asia
			printf({$out} '  ');

			# Add time zone offset to airport code?
			# Compute difference in minutes
			# This is not adjusted for the event date, so it is
			# really just today's offset, even for future events.
			my $total_tz_offset_minutes = (
				int($airport_tz_offset / 100) * 60 +
				  ($airport_tz_offset % 100)) -
			  (int($local_tz_offset / 100) * 60 + ($local_tz_offset % 100));
			if ($total_tz_offset_minutes != 0)
			{
				# output time zone offset sign
				my $total_tz_offset_str =
				  $total_tz_offset_minutes >= 0 ? '+' : '-';

				# remove sign for simplicity
				$total_tz_offset_minutes = abs($total_tz_offset_minutes);

				# output time zone offset
				$total_tz_offset_str .= sprintf('%d:%02d',
					int($total_tz_offset_minutes / 60),
					int($total_tz_offset_minutes % 60));

				$entry->{description} =~
				  s/\btime zone ([[:upper:]]{3})\b/$&($total_tz_offset_str)/;
			}
		}
		else
		{
			printf({$out} '    ');
		}

		# print first description line, then remainder, indented
		# Convert from UTF-8 bytes to Perl's internal character format
		# for wrapping, then convert to UTF-8 on output.
		my @description;
		if ($WIDTH)
		{
			@description =
			  split(m{\n},
				wrap('', '', decode('UTF-8', $entry->{description})));
		}
		else
		{
			@description[0] = decode('UTF-8', $entry->{description});
		}

		for my $lineno (0 .. $#description)
		{
			# new entry always gets newline, set matching line color
			print({$out} "\n" . $line_color) if ($lineno != 0);

			($lineno == 0) ?
			  print({$out} encode('UTF-8', $description[$lineno])) :
			  print {$out} ' ' x ($INDENT_SPACES + 2) .
			  encode('UTF-8', $description[$lineno]);
		}
	}

	# update time zone offset cache?
	if ($tz_offset_cache_miss)
	{
		open(my $tz_offset_file, '>', $tz_offset_filename) or
		  sysdie("cannot open $tz_offset_filename");
		for my $airport_code (sort keys %airport_offset)
		{
			printf({$tz_offset_file} "%s\t%s\n",
				$airport_code, $airport_offset{$airport_code});
		}
		close($tz_offset_file) ||
		  sysdie("cannot close $tz_offset_filename");
	}

	# use TERM_RESET to restore cursor
	say({$out} "$TERM_RESET");

	flock($out, LOCK_UN) if (!$options{args_supplied} && $LOGNAME eq 'root');

	close($out) or sysdie("cannot close $out_filename");
	return $out_filename;
}


sub calsort    #()
{
	my $entries;
	my @files = (@ARGV) ? @ARGV : '/dev/stdin';

	map { push(@$entries, @{ add_file_entries($_) }); } @files;

	@$entries = sort entry_cmp @$entries;

	for my $entry (@$entries)
	{
		printf("%s/%s\t%s\t%s\n",
			$entry->{month}, $entry->{day},
			$entry->{time} // '', $entry->{description});
	}
	return;
}


sub can_use_cache    #()
{
	my $stat_main = stat("/u/mcal/$BASE");

	my $stat_cache = stat("/u/mcal/cache/$BASE.$TERM_TYPE.$WIDTH");

	my $local_tz = `cat /etc/timezone`;
	chomp($local_tz);

	# Can we display the cached copy for this terminal type?
	if (!$options{args_supplied} &&
		!$is_debug &&
		defined($stat_main) &&
		defined($stat_cache) &&
		$stat_main->mtime < $stat_cache->mtime &&
		# Only use cache that is less then one minute old
		# because the active entries might have changed.
		time - $stat_cache->mtime < 60 &&
		# is local time zone?
		(!defined($ENV{TZ}) || $ENV{TZ} eq $local_tz))
	{
		system(
			qq(less $LESS_OPTIONS /u/mcal/cache/$BASE.$TERM_TYPE.$WIDTH; echo -n "$TERM_RESET")
		);
		exit(0);
	}
	return;
}


# ---- main ----

if ($BASE eq 'calsort')
{
	# no locking
	$options{print_all} = 1;
	calsort;
}
else
{
	my $entries = undef;

	can_use_cache;

	if ($options{timezone})
	{
		my $tzfilename = '/u/mcal/tzdata/time-zone.list';

		open(my $tzfile, '<', $tzfilename) or
		  sysdie("cannot open $tzfilename");
		print($_) while (<$tzfile>);
		close($tzfile) || sysdie("cannot close $tzfilename");
		exit;
	}

	# Do birthday first because entries need adjustment
	if ($BASE eq 'mcal' && $options{add_birthdays})
	{
		$entries = add_file_entries('/u/mcal/birthdays');
		adjust_birthday_entries($entries);
	}
	push @$entries, @{ add_file_entries("/u/mcal/$BASE") };

	@$entries = sort entry_cmp @$entries;

	my $out_filename = produce_output($entries, create_calendar($entries));

	# use 'editmcal' for edit here?
	# less generates a warning on WIDTH=0
	(-t STDOUT && $WIDTH != 0) ?
	  system(qq(less $LESS_OPTIONS $out_filename; echo -n "$TERM_RESET")) :
	  system(qq(cat $out_filename));

	exit;
}

__END__


=head1 NAME

mcal - output Momjian calendar

=head1 SYNOPSIS

mcal [-a] [-b] [-c] [-t] [month [year]]

=head1 DESCRIPTION

mcal output the Momjian calendar as created by C<editmcal>.

=head1 OPTIONS

=over

=item B<-a>

All entries;  normally past entries are not output

=item B<-b>

Output C<birthday> entries along with mcal entries

=item B<-c>

Output color sequences, even when output is not a terminal

=item B<-t>

Output supported time zone names.  Three-letter airport codes are also recognized.

=item B<month>

Month to start output

=item B<year>

Year to start output

=back

=head1 FORMATS

Time formats for noon include:

=over

=item 12:00n

=item 12:00p

=item 12:00pm

=item 12p

=item 12pm

=item S<12>

=item noon

=back

Also supported:

=over

=item 18 (for 6:00p)

=item 18:00 (for 6:00p)

=item 9:00 (for 9:00a)

=item am

=item pm

=item all

=back

I<1400> or I<1530> (no colons) are not supported because they could be confused for years, e.g., I<birthdays>.

I<all> sort as 8am;  I<am> and 9am;  I<pm> sorts as 5pm.

I<until> must be the last entry on the line or before a semicolon.

C<addmcal> also processes missing months as the next matching day number in the future;  a blank date is assumed to be today.  A zero day can be used for unknown days.  Entries for
today are considered to be in the past once the end time has passed, or if there is no end time, one hour after the start time.

Entries ending in an asterisk (C<*>) remain after the month passes;  if a year number starts the description and the line ends with an asterisk, it is removed during
that year, e.g., C<2018 my description *>.

Ending an mcal entry with I<%minutes> at the end of an entry will generate a I<say> notification that many minutes before the event.  Online meetings with C<initiate> text have a ten
minute I<say> notification, while those without C<initiate> have a five minute notification.

=head1 EXTERNAL CALENDAR FEEDS

External calendar feeds (ICS feeds, e.g., I<Google Calendar>, I<Tripit>) are refreshed every 10 minutes.

You can add text to externally-fed entries before the dash by preceeding it with a comma or ampersand, or at the end of the line by preceeding it with a semicolon.  Most events for past
days are not updated can be freely modified or removed.   I<Tripit> continues to be updated the day after the event to handle late arrivals, unless the next day is in the next month.
While I<Tripit> flight arrival times are updated to show delays, departure times are not.  I<Meetup> entries stop being updated the day before the event.

For I<Google Calendar> events, unreplied and I<maybe> acceptance status is indicated, as is optional attendance.  Declined events do not appear.  C<Private busy> Google Calendar
entries appear as C<busy>, while C<private free> events do not appear.

Specific time zones can be specified as C<time zone XXX> in "Notes", "Description", or "Location" in I<Google Calendar> and I<Tripit>.

I<Tripit> flight arrivals in non-local time zones adjust all future calendar feed entries to the arrival time zone, until the next flight. I<Tripit> hotel checkouts and checkins and
rental care drop-offs and pickup-ups are logically adjusted based on flight times, i.e., hotel checkout might be after flight arrival, and hotel checkin might be before flight
departure.  I<Tripit> C<rail> and C<transportation> entry text C<time zone XXX> appended to the arrival address is processed like flight arrivals.  (C<Notes> are not in the calendar
export.)  Local time zone events have no time zone designation.

I<Tripit> trip names that start with a family member's names are time-zone tracked and receive I<Tripit> email notifications.  I<Tripit> trips labeled as "Non-Family:" are tracked by
the name after the colon and that name is used for its mcal entries.   (Alphabetic, dash, underscore, and ampersand characters are supported in trip names).  Mcal I<Tripit> entries
that contain the word "family" before the dash or after a semicolon do not appear in I<edbcal>.

For Matthew, Laura, Luke, Peter, Nathan, and Catherine's Google Calendar entries, if C<mcal.disabled> does not exist in their home directories, all entries are imported.  If C<mcal.disabled>
exists in their home directories, only entries whose summaries start with C<*> are imported.

=head1 ENVIRONMENT

=over

=item DEBUG

When defined, enables debug output.

=item TERM

When defined, controls the output colors.  I<dumb> disables color output.

=item WIDTH

When defined, specifies the output width.  Zero disables word wrapping.

=back

=head1 OTHER COMMANDS

=over

=item I<addmcal> adds single mcal entry

=item I<edbcal> prints the edb calandar entries

=item I<editmcal> adds, changes, and removes mcal entries

=item I<findmcal> searches old mcal entries

=item I<mcal_add_days> mass adds mcal entries

=item I<mcalprint> prints mcal

=item I<today> displays today and tomorrow's mcal entries and birthdays

=back

=cut

=for comment
generate man using:
pod2man /usr/lbin/mcal > /usr/local/man/man1/mcal.1
=cut
