#!/usr/bin/env perl

use strict;
use warnings;
use utf8;
use feature qw(say);

use Getopt::Long qw(GetOptions);
use HTTP::Cookies;
use JSON::PP qw(decode_json);
use LWP::UserAgent;
use Scalar::Util qw(looks_like_number);
use Time::HiRes qw(sleep);

binmode STDOUT, ':encoding(UTF-8)';
binmode STDERR, ':encoding(UTF-8)';

my %stores = (
    'Australia'             => 'https://www.kobo.com/au/en/ebook/',
    'Austria'               => 'https://www.kobo.com/at/de/ebook/',
    'Belgium (Dutch)'       => 'https://www.kobo.com/be/nl/ebook/',
    'Belgium (French)'      => 'https://www.kobo.com/be/fr/ebook/',
    'Brazil'                => 'https://www.kobo.com/br/pt/ebook/',
    'Canada (English)'      => 'https://www.kobo.com/ca/en/ebook/',
    'Canada (French)'       => 'https://www.kobo.com/ca/fr/ebook/',
    'Cyprus'                => 'https://www.kobo.com/cy/en/ebook/',
    'Czech Republic'        => 'https://www.kobo.com/cz/cs/ebook/',
    'Denmark'               => 'https://www.kobo.com/dk/da/ebook/',
    'Eire'                  => 'https://www.kobo.com/ie/en/ebook/',
    'Estonia'               => 'https://www.kobo.com/ee/en/ebook/',
    'Finland'               => 'https://www.kobo.com/fi/fi/ebook/',
    'France'                => 'https://www.kobo.com/fr/fr/ebook/',
    'Germany'               => 'https://www.kobo.com/de/de/ebook/',
    'Greece'                => 'https://www.kobo.com/gr/en/ebook/',
    'Hong Kong (Chinese)'   => 'https://www.kobo.com/hk/zh/ebook/',
    'Hong Kong (English)'   => 'https://www.kobo.com/hk/en/ebook/',
    'India'                 => 'https://www.kobo.com/in/en/ebook/',
    'Italy'                 => 'https://www.kobo.com/it/it/ebook/',
    'Japan'                 => 'https://www.kobo.com/jp/ja/ebook/',
    'Lithuania'             => 'https://www.kobo.com/lt/en/ebook/',
    'Luxembourg'            => 'https://www.kobo.com/lu/fr/ebook/',
    'Malaysia'              => 'https://www.kobo.com/my/en/ebook/',
    'Malta'                 => 'https://www.kobo.com/mt/en/ebook/',
    'Mexico'                => 'https://www.kobo.com/mx/es/ebook/',
    'Netherlands'           => 'https://www.kobo.com/nl/nl/ebook/',
    'New Zealand'           => 'https://www.kobo.com/nz/en/ebook/',
    'Norway'                => 'https://www.kobo.com/no/nb/ebook/',
    'Philippines'           => 'https://www.kobo.com/ph/en/ebook/',
    'Poland'                => 'https://www.kobo.com/pl/pl/ebook/',
    'Portugal'              => 'https://www.kobo.com/pt/pt/ebook/',
    'Romania'               => 'https://www.kobo.com/ro/ro/ebook/',
    'Singapore'             => 'https://www.kobo.com/sg/en/ebook/',
    'Slovak Republic'       => 'https://www.kobo.com/sk/en/ebook/',
    'Slovenia'              => 'https://www.kobo.com/si/en/ebook/',
    'South Africa'          => 'https://www.kobo.com/za/en/ebook/',
    'Spain'                 => 'https://www.kobo.com/es/es/ebook/',
    'Sweden'                => 'https://www.kobo.com/se/sv/ebook/',
    'Switzerland (French)'  => 'https://www.kobo.com/ch/fr/ebook/',
    'Taiwan'                => 'https://www.kobo.com/tw/zh/ebook/',
    'Thailand'              => 'https://www.kobo.com/th/en/ebook/',
    'Turkey'                => 'https://www.kobo.com/tr/tr/ebook/',
    'UK'                    => 'https://www.kobo.com/gb/en/ebook/',
    'USA'                   => 'https://www.kobo.com/us/en/ebook/',
    'World-wide'            => 'https://www.kobo.com/ww/en/ebook/',
);

my $delay        = 1.5;
my $retries      = 3;
my $timeout      = 30;
my $show_failed  = 0;
my $help         = 0;

GetOptions(
    'delay=f'       => \$delay,
    'retries=i'     => \$retries,
    'timeout=i'     => \$timeout,
    'show-failed!'  => \$show_failed,
    'help|h'        => \$help,
) or usage(2);

usage(0) if $help;

my $input = shift @ARGV;
usage(2) unless defined $input && length $input;

$delay   = 0 if $delay < 0;
$retries = 0 if $retries < 0;
$timeout = 5 if $timeout < 5;

my $book = clean_book_slug($input);
die "Could not determine the Kobo book slug from: $input\n" unless length $book;
die "Invalid Kobo book slug: $book\n" unless $book =~ /\A[[:alnum:]_%+.,'()!-]+\z/;

my $jar = HTTP::Cookies->new;
my $ua  = LWP::UserAgent->new(
    timeout       => $timeout,
    max_redirect  => 8,
    cookie_jar    => $jar,
    protocols_allowed => [qw(http https)],
    ssl_opts      => { verify_hostname => 1 },
);

# A normal desktop browser identity. The old script used libwww-perl's default
# identity and launched every request at once, which commonly triggers 403s.
$ua->agent(
    'Mozilla/5.0 (X11; Linux x86_64) '
  . 'AppleWebKit/537.36 (KHTML, like Gecko) '
  . 'Chrome/124.0 Safari/537.36'
);
$ua->default_header('Accept' =>
    'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8');
$ua->default_header('Accept-Language' => 'en-GB,en;q=0.9');
$ua->default_header('DNT' => '1');
$ua->default_header('Upgrade-Insecure-Requests' => '1');

my @results;
my @failures;
my $index = 0;
my $total = scalar keys %stores;

for my $country (sort keys %stores) {
    ++$index;
    my $url = $stores{$country} . $book;
    print STDERR sprintf("[%02d/%02d] %-22s ", $index, $total, $country);

    my ($response, $attempts) = fetch_with_retries($ua, $url, $retries);

    if (!$response->is_success) {
        my $status = $response->code . ' ' . $response->message;
        say STDERR "failed ($status)";
        push @failures, [$country, $status, $url];
        polite_pause($delay);
        next;
    }

    my $html = $response->decoded_content(charset => 'none');
    my ($price, $currency) = extract_price($html);

    if (!defined $price || !defined $currency) {
        say STDERR 'no price found';
        push @failures, [$country, 'page loaded but price was not found', $url];
        polite_pause($delay);
        next;
    }

    say STDERR sprintf('ok (%s %.2f)', $currency, $price);
    push @results, {
        country  => $country,
        currency => $currency,
        price    => $price + 0,
        url      => $url,
    };

    polite_pause($delay);
}

die "\nNo prices were retrieved. Try a larger delay, for example --delay 3.\n"
    unless @results;

my %rates_to_eur = fetch_rates_to_eur($ua, [map { $_->{currency} } @results]);

for my $row (@results) {
    my $currency = $row->{currency};
    $row->{eur} = $row->{price} * $rates_to_eur{$currency}
        if exists $rates_to_eur{$currency};
}

my $country_width = 0;
for my $row (@results) {
    my $length = length $row->{country};
    $country_width = $length if $length > $country_width;
}

say '';
for my $row (sort_results(@results)) {
    if (defined $row->{eur}) {
        printf "%-*s  €%7.2f   %3s %10.2f\n",
            $country_width, $row->{country}, $row->{eur},
            $row->{currency}, $row->{price};
    } else {
        printf "%-*s  %-9s   %3s %10.2f\n",
            $country_width, $row->{country}, '(no FX)',
            $row->{currency}, $row->{price};
    }
}

my @converted = grep { defined $_->{eur} } @results;
if (@converted) {
    my $minimum = (sort { $a->{eur} <=> $b->{eur} } @converted)[0]{eur};
    my @cheapest = grep { abs($_->{eur} - $minimum) < 0.005 } @converted;

    printf "\nCheapest price: €%.2f\n", $minimum;
    if (@cheapest == 1) {
        printf "Country: %s\n", $cheapest[0]{country};
        printf "Original price: %s %.2f\n",
            $cheapest[0]{currency}, $cheapest[0]{price};
        printf "URL: %s\n", $cheapest[0]{url};
    } else {
        say "Countries tied for cheapest price:";
        for my $row (@cheapest) {
            printf "  %s — %s %.2f — %s\n",
                $row->{country}, $row->{currency}, $row->{price}, $row->{url};
        }
    }
} else {
    say "\nNo exchange rates were available, so no converted cheapest price was calculated.";
}

printf STDERR "\nRetrieved %d of %d stores; %d failed.\n",
    scalar(@results), $total, scalar(@failures);

if ($show_failed && @failures) {
    say STDERR "\nFailed stores:";
    for my $failure (@failures) {
        printf STDERR "  %-22s %s\n", $failure->[0], $failure->[1];
    }
}

exit 0;

sub usage {
    my ($exit_code) = @_;
    print <<'USAGE';
Usage:
  kobocompare-updated [options] KOBO_URL_OR_SLUG

Options:
  --delay SECONDS     Pause between countries (default: 1.5)
  --retries NUMBER    Retries for 403, 429 and server errors (default: 3)
  --timeout SECONDS   Per-request timeout (default: 30)
  --show-failed       Print a final list of failed stores
  --help              Show this help

Examples:
  ./kobocompare-updated wings-the-story-of-a-band-on-the-run
  ./kobocompare-updated --delay 3 'https://www.kobo.com/es/es/ebook/wings-the-story-of-a-band-on-the-run'
USAGE
    exit $exit_code;
}

sub clean_book_slug {
    my ($value) = @_;
    $value =~ s/^\s+|\s+$//g;
    $value =~ s/[?#].*\z//;  # Drop Kobo search/tracking parameters.
    $value =~ s{/+\z}{};
    $value =~ s{.*/}{};
    return $value;
}

sub polite_pause {
    my ($seconds) = @_;
    return if $seconds <= 0;
    sleep($seconds + rand(0.45));
}

sub fetch_with_retries {
    my ($agent, $url, $max_retries) = @_;
    my $response;

    for my $attempt (0 .. $max_retries) {
        $response = $agent->get(
            $url,
            'Referer'         => 'https://www.kobo.com/',
            'Cache-Control'   => 'no-cache',
            'Pragma'          => 'no-cache',
            'Sec-Fetch-Dest'  => 'document',
            'Sec-Fetch-Mode'  => 'navigate',
            'Sec-Fetch-Site'  => 'same-origin',
            'Sec-Fetch-User'  => '?1',
        );

        return ($response, $attempt + 1) if $response->is_success;

        my $code = $response->code || 0;
        last unless $code == 403 || $code == 429 || $code >= 500 || $code == 0;
        last if $attempt == $max_retries;

        my $retry_after = $response->header('Retry-After');
        my $backoff = (defined $retry_after && $retry_after =~ /^\d+(?:\.\d+)?$/)
            ? $retry_after
            : (2 ** $attempt) + 1 + rand(1.0);
        sleep($backoff);
    }

    return ($response, $max_retries + 1);
}

sub extract_price {
    my ($html) = @_;

    my $meta_price    = extract_meta($html, 'og:price');
    my $meta_currency = extract_meta($html, 'og:currency_code');
    if (defined $meta_price && defined $meta_currency) {
        my $price = normalise_price($meta_price);
        return ($price, uc $meta_currency) if defined $price;
    }

    while ($html =~ m{<script\b[^>]*type\s*=\s*(["'])application/ld\+json\1[^>]*>(.*?)</script>}gis) {
        my $json_text = $2;
        $json_text =~ s/^\s+|\s+$//g;
        next unless length $json_text;

        my $data = eval { decode_json($json_text) };
        next if $@ || !defined $data;

        my ($price, $currency) = find_price_in_json($data);
        return ($price, $currency) if defined $price && defined $currency;
    }

    # Last-resort fallback for embedded JSON that is not valid JSON-LD.
    if ($html =~ /["']priceCurrency["']\s*:\s*["']([A-Z]{3})["']/i) {
        my $currency = uc $1;
        if ($html =~ /["'](?:salePrice|price)["']\s*:\s*["']?([0-9][0-9.,]*)/i) {
            my $price = normalise_price($1);
            return ($price, $currency) if defined $price;
        }
    }

    return;
}

sub extract_meta {
    my ($html, $wanted) = @_;

    while ($html =~ m{<meta\b[^>]*>}gi) {
        my $tag = $&;
        my $property = attribute_value($tag, 'property');
        $property = attribute_value($tag, 'name') unless defined $property;
        next unless defined $property && lc($property) eq lc($wanted);

        my $content = attribute_value($tag, 'content');
        return $content if defined $content;
    }

    return;
}

sub attribute_value {
    my ($tag, $name) = @_;
    return $2 if $tag =~ /\b\Q$name\E\s*=\s*(["'])(.*?)\1/is;
    return $1 if $tag =~ /\b\Q$name\E\s*=\s*([^\s>]+)/is;
    return;
}

sub find_price_in_json {
    my ($node) = @_;

    if (ref $node eq 'ARRAY') {
        for my $item (@$node) {
            my ($price, $currency) = find_price_in_json($item);
            return ($price, $currency) if defined $price && defined $currency;
        }
        return;
    }

    return unless ref $node eq 'HASH';

    my $raw_price = exists $node->{price} ? $node->{price}
                  : exists $node->{lowPrice} ? $node->{lowPrice}
                  : undef;
    my $currency  = $node->{priceCurrency};

    if (defined $raw_price && defined $currency) {
        my $price = normalise_price($raw_price);
        return ($price, uc $currency) if defined $price;
    }

    for my $key (keys %$node) {
        my ($price, $found_currency) = find_price_in_json($node->{$key});
        return ($price, $found_currency)
            if defined $price && defined $found_currency;
    }

    return;
}

sub normalise_price {
    my ($raw) = @_;
    return unless defined $raw;

    my $value = "$raw";
    $value =~ s/[\s\x{00A0}]//g;
    $value =~ s/[^0-9,.-]//g;
    return unless $value =~ /\d/;

    if ($value =~ /,/ && $value =~ /\./) {
        my $last_comma = rindex($value, ',');
        my $last_dot   = rindex($value, '.');
        if ($last_comma > $last_dot) {
            $value =~ s/\.//g;
            $value =~ tr/,/./;
        } else {
            $value =~ s/,//g;
        }
    } elsif ($value =~ /,/) {
        $value =~ tr/,/./;
    }

    return unless looks_like_number($value);
    return $value + 0;
}

sub fetch_rates_to_eur {
    my ($agent, $currencies) = @_;

    my %wanted = map { uc($_) => 1 } grep { defined && /^[A-Za-z]{3}$/ } @$currencies;
    my %to_eur = (EUR => 1);
    delete $wanted{EUR};
    return %to_eur unless keys %wanted;

    my $quotes = join ',', sort keys %wanted;
    my $url = "https://api.frankfurter.dev/v2/rates?base=EUR&quotes=$quotes";
    my $response = $agent->get($url, 'Accept' => 'application/json');

    if (!$response->is_success) {
        warn "Could not retrieve current exchange rates: "
            . $response->status_line . "\n";
        return %to_eur;
    }

    my $data = eval { decode_json($response->decoded_content) };
    if ($@ || ref $data ne 'ARRAY') {
        warn "Could not parse the exchange-rate response.\n";
        return %to_eur;
    }

    # API rows are expressed as: 1 EUR = RATE quote-currency units.
    # Therefore 1 quote-currency unit = 1 / RATE EUR.
    for my $row (@$data) {
        next unless ref $row eq 'HASH';
        my $quote = uc($row->{quote} // '');
        my $rate  = $row->{rate};
        next unless $quote =~ /^[A-Z]{3}$/;
        next unless defined $rate && looks_like_number($rate) && $rate > 0;
        $to_eur{$quote} = 1 / $rate;
    }

    return %to_eur;
}

sub sort_results {
    my @rows = @_;
    return sort {
        defined($a->{eur}) && defined($b->{eur}) ? $a->{eur} <=> $b->{eur}
      : defined($a->{eur})                       ? -1
      : defined($b->{eur})                       ? 1
      : $a->{country} cmp $b->{country}
    } @rows;
}
