#!/usr/bin/perl -w
# -*- perl -*-

#
# Author: Slaven Rezic
#
# Copyright (C) 2021,2023,2024 Slaven Rezic. All rights reserved.
# This program is free software; you can redistribute it and/or
# modify it under the same terms as Perl itself.
#
# Mail: slaven@rezic.de
# WWW:  https://github.com/eserte/bbbike/
#

use strict;
use FindBin;
use lib ("$FindBin::RealBin/..",
	 "$FindBin::RealBin/../lib",
	);

use DateTime::Format::ISO8601;
use Getopt::Long;
use Image::ExifTool ();

use GPS::GpsmanData;

our $VERSION = '0.03';

sub usage (;$) {
    my $msg = shift;
    print STDERR $msg, "\n" if $msg;
    die <<EOF;
usage: $0 [-o ...] [--vehicle ...] [--brand ...] [--max-time-gap seconds] [--fallback-device-clock] [--silent] [--debug] imagefiles ...
EOF
}

my $outfile;
my $vehicle;
my $brand;
my $max_time_gap = 30;
my $fallback_device_clock;
my $silent;

my $debug;
sub debug ($) { warn "DEBUG: $_[0]\n" if $debug }

Getopt::Long::Configure('auto_version', 'auto_help');
GetOptions(
	   'out-file|o=s' => \$outfile,
	   'vehicle=s'    => \$vehicle,
	   'brand=s'      => \$brand,
	   'max-time-gap=i'   => \$max_time_gap,
	   'fallback-device-clock!' => \$fallback_device_clock,
	   's|silent'     => \$silent,
	   'debug'        => \$debug,
	  )
    or die usage;
my @image_files = @ARGV;
@image_files
    or usage "Please specify image files";

my $exiftool = Image::ExifTool->new;
$exiftool->Options(CoordFormat => '%+.6f');
$exiftool->Options(DateFormat => '%Y-%m-%dT%H:%M:%SZ');
#$exiftool->Options(FastScan => 2); # not enabled: no speed improvements; and with FastScan=>3 GPS tags are not extracted anymore

my $device;
my $name;

my $tp = !$silent && @image_files >= 10 && eval {
    require Time::Progress;
    my $tp = Time::Progress->new;
    $tp->restart(min => 0, max => $#image_files);
    $tp;
};

my @trkpts;
my $image_file_i = 0;
IMAGE: for my $image_file (@image_files) {
    print STDERR $tp->report("\r  images done %p elapsed: %L, ETA %E", $image_file_i) if $tp && $image_file_i%10==0;
    $image_file_i++;

    # video support
    if ($image_file =~ m{\.(?:mp4|360|mov|qt)$}) {
	$exiftool->Options(ExtractEmbedded => 1);
	$exiftool->Options(Duplicates => 1);
	debug "Detected $image_file as video";
	if ($Image::ExifTool::VERSION < 12) {
	    warn "WARN: Image::ExifTool $Image::ExifTool::VERSION might be too old for properly handling video files!\n";
	}
    } else {
	# normal JPEGs may have thumbnails with gps information attached, this would cause duplicate coordinates and other problems
	$exiftool->Options(ExtractEmbedded => 0);
	$exiftool->Options(Duplicates => 0);
    }

    my $info = $exiftool->ImageInfo($image_file);

    my $frame_i = -1;
 FRAME_LOOP: while() {
	$frame_i++;
	my $framespec = "";
	if ($frame_i > 0) {
	    $framespec = " ($frame_i)";
	    if (!defined $info->{"GPSLongitude$framespec"}) { # further frames have information in "GPSLongitude (1)" etc.
		last FRAME_LOOP;
	    }
	}

	my $lon = $info->{"GPSLongitude$framespec"};
	my $lat = $info->{"GPSLatitude$framespec"};
	if (!defined $lon || !defined $lat) {
	    warn "WARN: Cannot get GPSLongitude and/or GPSLatitude from $image_file$framespec, ignoring file/frame...\n";
	    next FRAME_LOOP;
	}
	$lon += 0; # +0 to get rid of sign
	$lat += 0;

	my $date_time;
	{
	    my $raw_date_time = $info->{"GPSDateTime$framespec"};
	    if (!$raw_date_time) {
		if ($fallback_device_clock) {
		    $raw_date_time = $info->{"DateTimeOriginal$framespec"};
		    if (!$raw_date_time) {
			warn "WARN: Cannot get GPSDateTime nor Date/Time Original from $image_file$framespec, ignoring file/frame...\n";
			next FRAME_LOOP;
		    }
		} else {
		    warn "WARN: Cannot get GPSDateTime from $image_file$framespec, ignoring file/frame (consider to use --fallback-device-clock)...\n";
		    next FRAME_LOOP;
		}
	    }
	    $date_time = eval { DateTime::Format::ISO8601->parse_datetime($raw_date_time) };
	    if (!$date_time) {
		warn "WARN: Cannot parse GPSDateTime '$raw_date_time' in $image_file$framespec as an ISO 8601 date, ignoring file/frame...\n";
		next FRAME_LOOP;
	    }
	}

	my $alt = $info->{"GPSAltitude$framespec"};
	if (!$alt) {
	    warn "WARN: Cannot get GPSAltitude from $image_file$framespec, ignoring file...\n"; # XXX or should it be possible to allow coordinates without altitudes?
	    next FRAME_LOOP;
	}
	if ($alt =~ m{^(\d+(?:\.\d+)?)\s+m$}) { # without above/below sea level
	    $alt = $1;
	} else {
	    if ($alt !~ m{^(\d+(?:\.\d+)?)\s+m\s+(Above|Below)\s+Sea\s+Level$}i) {
		warn "WARN: GPSAltitude exists in $image_file$framespec, but value '$alt' cannot be parsed, ignoring file/frame...\n";
		next FRAME_LOOP;
	    }
	    $alt = $1;
	    if (lc($2) eq 'below') {
		$alt *= -1;
	    }
	}

	if (!$device && $info->{Model}) {
	    $device = $info->{Model};
	}

	if (!$name) {
	    $name = $date_time->strftime("%F %T"); # XXX make name configurable?
	}

	push @trkpts, {
		       longitude => $lon,
		       latitude  => $lat,
		       datetime  => $date_time,
		       altitude  => $alt,
		      };
    }
}

@trkpts = sort { $a->{datetime} cmp $b->{datetime} } @trkpts;
my @chunks;
for my $trkpt_i (0 .. $#trkpts) {
    my $trkpt = $trkpts[$trkpt_i];
    if ($trkpt_i == 0 || ($trkpt_i > 0 && $trkpt->{datetime}->epoch - $trkpts[$trkpt_i-1]->{datetime}->epoch > $max_time_gap)) {
	push @chunks, [$trkpt];
    } else {
	push @{ $chunks[-1] }, $trkpt;
    }
}

my $gpsman = GPS::GpsmanMultiData->new;
for my $chunk_i (0 .. $#chunks) {
    my $trkseg = GPS::GpsmanData->new;
    $trkseg->Type($trkseg->TYPE_TRACK);
    $trkseg->TimeOffset(0);
    if ($chunk_i == 0) {
	$trkseg->IsTrackSegment(0);
	$trkseg->Name($name);
	$trkseg->TrackAttrs({
			     (defined $device  ? ('srt:device'  => $device) : ()),
			     (defined $vehicle ? ('srt:vehicle' => $vehicle) : ()),
			     (defined $brand   ? ('srt:brand'   => $brand) : ()),
			    });
    } else {
	$trkseg->IsTrackSegment(1);
    }
    my @gpsman_trkpts;
    for my $trkpt (@{ $chunks[$chunk_i] }) {
	my $gpsman_trkpt = GPS::Gpsman::Waypoint->new;
	$gpsman_trkpt->Ident('');
	$gpsman_trkpt->Latitude($trkpt->{latitude});
	$gpsman_trkpt->Longitude($trkpt->{longitude});
	$gpsman_trkpt->Altitude($trkpt->{altitude});
	$gpsman_trkpt->unixtime_to_DateTime($trkpt->{datetime}->epoch, $trkseg);
	push @gpsman_trkpts, $gpsman_trkpt;
    }
    $trkseg->Track(\@gpsman_trkpts);
    push @{ $gpsman->{Chunks} }, $trkseg;
}

if (@trkpts < @image_files) {
    warn "WARN: Failed to process " . (@image_files-@trkpts) . "/" . scalar(@image_files) . " file(s).\n";
}

if ($outfile) {
    my $outdir = File::Basename::dirname($outfile);
    if (!$outdir) {
	die "Directory for $outfile does not exist";
    }
    require File::Temp;
    require File::Basename;
    my $tmp = File::Temp->new(UNLINK => 1, SUFFIX => '.gpsman', DIR => $outdir);
    chmod 0644, $tmp;
    print $tmp $gpsman->as_string;
    close $tmp
	or die "Writing to temporary file $tmp failed: $!";
    rename $tmp, $outfile
	or die "Rename $tmp -> $outfile failed: $!";
    $tmp->unlink_on_destroy(0);
} else {
    print $gpsman->as_string;
}

print STDERR "\n" if $tp;

__END__

=head1 NAME

exif2gpsman - extract coordinates from image files and create a GPSMan file out of it

=head1 SYNOPSIS

    exif2gpsman [-o outfile.trk] [--vehicle ...] [--brand ...] [--max-time-gap seconds] [--fallback-device-clock] [--silent] image1 image2 ...

=head1 DESCRIPTION

Extract coordinates from image files and create a GPSMan file out of
it.

=head1 OPTIONS

=over

=item C<--vehicle ...>

Set the C<srt:vehicle> property of the generated file.

=item C<--brand ...>

Set the C<srt:brand> property of the generated file.

=item C<--max-time-gap I<seconds>>

Maximum gap allowed for subsequent points to be in the same track
segment. Defaults to 30s. If the gap is larger, then a new track
segment will be created.

=item C<--fallback-device-clock>

Allow to use the "Date/Time Original" timestamp as a fallback if
"GPSDateTime" is not available (seen on iPhone images).

=item C<-o ...>

Define a output file for the converted GPSMan data. If C<-o> is not
set, then the output is written to stdout.

=item C<--silent>

Turn progress report off. Progress report is only available if
L<Time::Progress> is installed, and only if ten or more images have to
be processed.

=back

=head1 AUTHOR

Slaven Rezic

=head1 SEE ALSO

L<GPS::GpsmanData>, L<Image::ExifTool>.

=cut
