package JIRA::REST::Lite;

use 5.016;
use utf8;
use warnings;

use URI;
use REST::Client;

our $VERSION = '0.05';  # Version of your module

sub new {
    my ($class, %args) = &_grok_args;

    my ($path, $api) = ($args{url}->path, '/rest/api/latest');
    if ($path =~ s:(/rest/.*)$::) {
        $api = $1;
        $args{url}->path($path);
    }
    if ($path =~ s:/+$::) {
        $args{url}->path($path);
    }

    unless ($args{anonymous} || $args{pat}) {
        if (! defined $args{username} || ! defined $args{password}) {
            ($args{username}, $args{password}) = ('your_username', 'your_password');
        }
    }

    my $rest = REST::Client->new($args{rest_client_config});
    $rest->setHost($args{url});
    $rest->setFollow(1);

    unless ($args{anonymous} || $args{session}) {
        $rest->addHeader(
            Authorization =>
                $args{pat}
                ? "Bearer $args{pat}"
                : 'Basic ' . encode_base64("$args{username}:$args{password}", '')
        );
    }

    my $jira = bless {
        rest => $rest,
        api  => $api,
    } => $class;

    $jira->{_session} = $jira->POST('/rest/auth/1/session', undef, {
        username => $args{username},
        password => $args{password},
    }) if $args{session};

    return $jira;
}

sub _grok_args {
    my ($class, @args) = @_;

    my @opts = qw/url username password rest_client_config proxy ssl_verify_none anonymous pat session/;

    my %args;
    if (@args == 1 && ref $args[0] && ref $args[0] eq 'HASH') {
        @args{@opts} = delete @{$args[0]}{@opts};
    } else {
        @args{@opts} = @args;
    }

    if (! $args{url}) {
        die __PACKAGE__ . "::new: 'url' argument must be defined.\n";
    } elsif (! ref $args{url}) {
        $args{url} = URI->new($args{url});
    }

    if (!!$args{anonymous} + !!$args{pat} + !!$args{session} > 1) {
        die __PACKAGE__ . "::new: 'anonymous', 'pat', and 'session' are mutually exclusive options.\n";
    }

    return ($class, %args);
}

sub new_session {
    my ($class, @args) = @_;

    if (@args == 1 && ref $args[0] && ref $args[0] eq 'HASH') {
        $args[0]{session} = 1;
    } else {
        $args[8] = 1;
    }

    return $class->new(@args);
}

sub DESTROY {
    my $self = shift;
    $self->DELETE('/rest/auth/1/session') if exists $self->{_session};
    return;
}

sub GET {
    my ($self, $path, $query) = @_;

    $self->{rest}->GET($self->_build_path($path, $query));

    return $self->_content();
}

sub DELETE {
    my ($self, $path, $query) = @_;

    $self->{rest}->DELETE($self->_build_path($path, $query));

    return $self->_content();
}

sub PUT {
    my ($self, $path, $query, $value, $headers) = @_;

    $path = $self->_build_path($path, $query);

    $headers                   ||= {};
    $headers->{'Content-Type'} //= 'application/json;charset=UTF-8';

    $self->{rest}->PUT($path, $self->{json}->encode($value), $headers);

    return $self->_content();
}

sub POST {
    my ($self, $path, $query, $value, $headers) = @_;

    $path = $self->_build_path($path, $query);

    $headers                   ||= {};
    $headers->{'Content-Type'} //= 'application/json;charset=UTF-8';

    $self->{rest}->POST($path, $self->{json}->encode($value), $headers);

    return $self->_content();
}

sub rest_client {
    my ($self) = @_;
    return $self->{rest};
}

sub set_search_iterator {
    my ($self, $params) = @_;

    my %params = ( %$params );  # Rebuild the hash to own it

    $params{startAt} = 0;

    $self->{iter} = {
        params  => \%params,    # Params hash to be used in the next call
        offset  => 0,           # Offset of the next issue to be fetched
        results => {            # Results of the last call (this one is fake)
            startAt => 0,
            total   => -1,
            issues  => [],
        },
    };

    return;
}

sub next_issue {
    my ($self) = @_;

    my $iter = $self->{iter}
        or die $self->_error("You must call set_search_iterator before calling next_issue");

    if ($iter->{offset} == $iter->{results}{total}) {
        # This is the end of the search results
        $self->{iter} = undef;
        return;
    } elsif ($iter->{offset} == $iter->{results}{startAt} + @{$iter->{results}{issues}}) {
        # Time to get the next bunch of issues
        $iter->{params}{startAt} = $iter->{offset};
        $iter->{results}         = $self->POST('/search', undef, $iter->{params});
    }

    return $iter->{results}{issues}[$iter->{offset}++ - $iter->{results}{startAt}];
}

sub attach_files {
    my ($self, $issueIdOrKey, @files) = @_;

    # We need to violate the REST::Client class encapsulation to implement
    # the HTTP POST method necessary to invoke the /issue/key/attachments
    # REST endpoint because it has to use the form-data Content-Type.

    my $rest = $self->{rest};

    # FIXME: How to attach all files at once?
    foreach my $file (@files) {
        my $response = $rest->getUseragent()->post(
            $rest->getHost . "/rest/api/latest/issue/$issueIdOrKey/attachments",
            %{$rest->{_headers}},
            'X-Atlassian-Token' => 'nocheck',
            'Content-Type'      => 'form-data',
            'Content'           => [ file => [$file, encode_utf8( $file )] ],
        );

        $response->is_success
            or die $self->_error("attach_files($file): " . $response->status_line);
    }

    return;
}

sub _error {
    my ($self, $content, $type, $code) = @_;

    $type = 'text/plain' unless $type;
    $code = 500          unless $code;

    my $msg = __PACKAGE__ . " Error[$code";

    if (eval {require HTTP::Status}) {
        if (my $status = HTTP::Status::status_message($code)) {
            $msg .= " - $status";
        }
    }

    $msg .= "]:\n";

    if ($type =~ m:text/plain:i) {
        $msg .= $content;
    } elsif ($type =~ m:application/json:) {
        $msg .= $content;
    } elsif ($type =~ m:text/html:i && eval {require HTML::TreeBuilder}) {
        $msg .= HTML::TreeBuilder->new_from_content($content)->as_text;
    } elsif ($type =~ m:^(text/|application|xml):i) {
        $msg .= "<Content-Type: $type>$content</Content-Type>";
    } else {
        $msg .= "<Content-Type: $type>(binary content not shown)</Content-Type>";
    };
    $msg =~ s/\n*$/\n/s;       # End message with a single newline
    return $msg;
}

sub _content {
    my ($self) = @_;

    my $rest    = $self->{rest};
    my $code    = $rest->responseCode();
    my $type    = $rest->responseHeader('Content-Type');
    my $content = $rest->responseContent();

    $code =~ /^2/
        or die $self->_error($content, $type, $code);

    return unless $content;

    if (! defined $type) {
        die $self->_error("Cannot convert response content with no Content-Type specified.");
    } elsif ($type =~ m:^application/json:i) {
        return $self->{json}->decode($content);
    } elsif ($type =~ m:^text/plain:i) {
        return $content;
    } else {
        die $self->_error("I don't understand content with Content-Type '$type'.");
    }
}

sub _build_path {
    my ($self, $path, $query) = @_;

    # Prefix $path with the default API prefix unless it already specifies
    # one or it's an absolute URL.
    $path = $self->{api} . $path unless $path =~ m@^(?:/rest/|(?i)https?:)@;

    if (defined $query) {
        die $self->_error("The QUERY argument must be a hash reference.")
            unless ref $query && ref $query eq 'HASH';
        return $path . '?' . join('&', map {$_ . '=' . uri_escape($query->{$_})} keys %$query);
    } else {
        return $path;
    }
}

1;

__END__

=head1 NAME

JIRA::REST::Lite - Lightweight wrapper around Jira's REST API

=head1 SYNOPSIS

    use JIRA::REST::Lite;

    my $jira = JIRA::REST::Lite->new({
        url      => 'https://jira.example.net',
        username => 'myuser',
        password => 'mypass',
    });

    my $jira_with_session = JIRA::REST::Lite->new({
        url      => 'https://jira.example.net',
        username => 'myuser',
        password => 'mypass',
        session  => 1,
    });

    my $jira_with_pat = JIRA::REST::Lite->new({
        url => 'https://jira.example.net',
        pat => 'NDc4NDkyNDg3ODE3OstHYSeYC1GnuqRacSqvUbookcZk',
    });

    my $jira_anonymous = JIRA::REST::Lite->new({
        url => 'https://jira.example.net',
        anonymous => 1,
    });

    # File a bug
    my $issue = $jira->POST('/issue', undef, {
        fields => {
            project   => { key => 'PRJ' },
            issuetype => { name => 'Bug' },
            summary   => 'Cannot login',
            description => 'Bla bla bla',
        },
    });

    # Get issue
    $issue = $jira->GET("/issue/TST-101");

    # Iterate on issues
    $jira->set_search_iterator({
        jql        => 'project = "TST" and status = "open"',
        maxResults => 16,
        fields     => [ qw/summary status assignee/ ],
    });

    while (my $issue = $jira->next_issue) {
        print "Found issue $issue->{key}\n";
    }

    # Attach files
    $jira->attach_files('TST-123', '/path/to/doc.txt', 'image.png');

=head1 DESCRIPTION

L<JIRA::REST::Lite> is a lightweight module that provides a simple interface to Jira's REST API. It's designed to be minimal in its dependencies while still offering essential functionalities for interacting with Jira.

=head1 METHODS

=over 4

=item * B<new>

Constructor. Creates a new instance of the JIRA::REST::Lite client.

=item * B<new_session>

Alternative constructor that creates a session-based client.

=item * B<GET>

Performs a GET request to the specified API endpoint.

=item * B<POST>

Performs a POST request to the specified API endpoint.

=item * B<PUT>

Performs a PUT request to the specified API endpoint.

=item * B<DELETE>

Performs a DELETE request to the specified API endpoint.

=item * B<set_search_iterator>

Sets up an iterator for searching Jira issues.

=item * B<next_issue>

Fetches the next issue in the search result.

=item * B<attach_files>

Attaches files to a Jira issue.

=back

=head1 REPOSITORY

L<https://github.com/kawamurashingo/JIRA-REST-Lite>

=head1 AUTHOR

Kawamura Shingo <pannakoota@gmail.com>

=head1 LICENSE

This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.

=cut

