package Hydra::Controller::API;

use utf8;
use strict;
use warnings;
use base 'Hydra::Base::Controller::REST';
use Hydra::Helper::Nix;
use Hydra::Helper::CatalystUtils;
use Hydra::Controller::Project;
use JSON::MaybeXS;
use DateTime;
use Digest::SHA qw(sha256_hex);
use Text::Diff;
use IPC::Run qw(run);
use Digest::SHA qw(hmac_sha256_hex);
use String::Compare::ConstantTime qw(equals);
use IPC::Run3;


sub api : Chained('/') PathPart('api') CaptureArgs(0) {
    my ($self, $c) = @_;
    $c->response->content_type('application/json');
}


sub buildToHash {
    my ($build) = @_;
    my $result = {
        id => $build->id,
        project => $build->jobset->get_column("project"),
        jobset => $build->jobset->get_column("name"),
        job => $build->get_column("job"),
        system => $build->system,
        nixname => $build->nixname,
        finished => $build->finished,
        timestamp => $build->timestamp
    };

    if($build->finished) {
        $result->{'buildstatus'} = $build->get_column("buildstatus");
    } else {
        $result->{'priority'} = $build->get_column("priority");
    }

    return $result;
};


sub latestbuilds : Chained('api') PathPart('latestbuilds') Args(0) {
    my ($self, $c) = @_;
    my $nr = $c->request->params->{nr};
    error($c, "Parameter not defined!") if !defined $nr;

    my $project = $c->request->params->{project};
    my $jobset = $c->request->params->{jobset};
    my $job = $c->request->params->{job};
    my $system = $c->request->params->{system};

    my $filter = {finished => 1};
    $filter->{"jobset.project"} = $project if ! $project eq "";
    $filter->{"jobset.name"} = $jobset if ! $jobset eq "";
    $filter->{job} = $job if !$job eq "";
    $filter->{system} = $system if !$system eq "";

    my @latest = $c->model('DB::Builds')->search(
        $filter,
        {
            rows => $nr,
            order_by => ["id DESC"],
            join => [ "jobset" ]
        });

    my @list;
    push @list, buildToHash($_) foreach @latest;

    $c->stash->{'plain'} = {
        data => scalar (encode_json(\@list))
    };
    $c->forward('Hydra::View::Plain');
}


sub jobsetToHash {
    my ($jobset) = @_;
    return {
        project => $jobset->get_column('project'),
        name => $jobset->name,
        nrscheduled => $jobset->get_column("nrscheduled"),
        nrsucceeded => $jobset->get_column("nrsucceeded"),
        nrfailed => $jobset->get_column("nrfailed"),
        nrtotal => $jobset->get_column("nrtotal"),
        lastcheckedtime => $jobset->lastcheckedtime,
        starttime => $jobset->starttime,
        checkinterval => $jobset->checkinterval,
        triggertime => $jobset->triggertime,
        fetcherrormsg => $jobset->fetcherrormsg,
        errortime => $jobset->errortime,
        haserrormsg => defined($jobset->errormsg) && $jobset->errormsg ne "" ? JSON::MaybeXS::true : JSON::MaybeXS::false
    };
}


sub jobsets : Chained('api') PathPart('jobsets') Args(0) {
    my ($self, $c) = @_;

    my $projectName = $c->request->params->{project};
    error($c, "Parameter 'project' not defined!") if !defined $projectName;

    my $project = $c->model('DB::Projects')->find($projectName)
        or notFound($c, "Project $projectName doesn't exist.");

    my @jobsets = jobsetOverview($c, $project);

    my @list;
    push @list, jobsetToHash($_) foreach @jobsets;

    $c->stash->{'plain'} = {
        data => scalar (encode_json(\@list))
    };
    $c->forward('Hydra::View::Plain');
}


sub queue : Chained('api') PathPart('queue') Args(0) {
    my ($self, $c) = @_;

    my $nr = $c->request->params->{nr};
    error($c, "Parameter not defined!") if !defined $nr;

    my @builds = $c->model('DB::Builds')->search({finished => 0}, {rows => $nr, order_by => ["priority DESC", "id"]});

    my @list;
    push @list, buildToHash($_) foreach @builds;

    $c->stash->{'plain'} = {
        data => scalar (encode_json(\@list))
    };
    $c->forward('Hydra::View::Plain');
}


sub nrqueue : Chained('api') PathPart('nrqueue') Args(0) {
    my ($self, $c) = @_;
    my $nrQueuedBuilds = $c->model('DB::Builds')->search({finished => 0})->count();
    $c->stash->{'plain'} = {
        data => "$nrQueuedBuilds"
    };
    $c->forward('Hydra::View::Plain');
}


sub nrbuilds : Chained('api') PathPart('nrbuilds') Args(0) {
    my ($self, $c) = @_;
    my $nr = $c->request->params->{nr};
    my $period = $c->request->params->{period};

    error($c, "Parameter not defined!") if !defined $nr || !defined $period;
    my $base;

    my $project = $c->request->params->{project};
    my $jobset = $c->request->params->{jobset};
    my $job = $c->request->params->{job};
    my $system = $c->request->params->{system};

    my $filter = {finished => 1};
    $filter->{"jobset.project"} = $project if ! $project eq "";
    $filter->{"jobset.name"} = $jobset if ! $jobset eq "";
    $filter->{job} = $job if !$job eq "";
    $filter->{system} = $system if !$system eq "";

    $base = 60*60 if($period eq "hour");
    $base = 24*60*60 if($period eq "day");

    my @stats = $c->model('DB::Builds')->search(
        $filter,
        {
            select => [{ count => "*" }],
            as => ["nr"],
            group_by => ["timestamp - timestamp % $base"],
            order_by => "timestamp - timestamp % $base DESC",
            rows => $nr,
            join => [ "jobset" ]
        }
    );
    my @arr;
    push @arr, int($_->get_column("nr")) foreach @stats;
    @arr = reverse(@arr);

    $c->stash->{'plain'} = {
        data => scalar (encode_json(\@arr))
    };
    $c->forward('Hydra::View::Plain');
}


sub scmdiff : Path('/api/scmdiff') Args(0) {
    my ($self, $c) = @_;

    my $uri = $c->request->params->{uri};
    my $type = $c->request->params->{type};
    my $rev1 = $c->request->params->{rev1};
    my $rev2 = $c->request->params->{rev2};

    die("invalid revisions: [$rev1] [$rev2]") if $rev1 !~ m/^[a-zA-Z0-9_.]+$/ || $rev2 !~ m/^[a-zA-Z0-9_.]+$/;

    # FIXME: injection danger.

    my $diff = "";
    if ($type eq "hg") {
        my $clonePath = getSCMCacheDir . "/hg/" . sha256_hex($uri);
        die "repository '$uri' is not in the SCM cache\n" if ! -d $clonePath;
        my $out;
        run(["hg", "log", "-R", $clonePath, "-r", "reverse($rev1::$rev2) and not($rev1)"], \undef, \$out)
            or die "hg log failed";
        $diff .= $out;
        run(["hg", "diff", "-R", $clonePath, "-r", "$rev1::$rev2"], \undef, \$out)
            or die "hg diff failed";
        $diff .= $out;
    } elsif ($type eq "git") {
        my $clonePath = getSCMCacheDir . "/git/" . sha256_hex($uri);
        die if ! -d $clonePath;
        my ($stdout1, $stderr1);
        run3(['git', '-C', $clonePath, 'log', "$rev1..$rev2"], \undef, \$stdout1, \$stderr1);
        $diff .= $stdout1 if $? == 0;

        my ($stdout2, $stderr2);
        run3(['git', '-C', $clonePath, 'diff', "$rev1..$rev2"], \undef, \$stdout2, \$stderr2);
        $diff .= $stdout2 if $? == 0;
    }

    $c->stash->{'plain'} = { data => (scalar $diff) || " " };
    $c->forward('Hydra::View::Plain');
}


sub triggerJobset {
    my ($self, $c, $jobset, $force) = @_;
    print STDERR "triggering jobset ", $jobset->get_column('project') . ":" . $jobset->name, "\n";
    $c->model('DB')->schema->txn_do(sub {
        $jobset->update({ triggertime => time });
        $jobset->update({ forceeval => 1 }) if $force;
    });
    push @{$c->{stash}->{json}->{jobsetsTriggered}}, $jobset->get_column('project') . ":" . $jobset->name;
}


sub push : Chained('api') PathPart('push') Args(0) {
    my ($self, $c) = @_;

    requirePost($c);

    $c->{stash}->{json}->{jobsetsTriggered} = [];

    my $force = exists $c->request->query_params->{force};
    my @jobsets = split /,/, ($c->request->query_params->{jobsets} // "");
    foreach my $s (@jobsets) {
        my ($p, $j) = parseJobsetName($s);
        my $jobset = $c->model('DB::Jobsets')->find($p, $j);
        requireEvalJobsetPrivileges($c, $jobset->project);
        next unless defined $jobset && ($force || ($jobset->project->enabled && $jobset->enabled));
        triggerJobset($self, $c, $jobset, $force);
    }

    my @repos = split /,/, ($c->request->query_params->{repos} // "");
    foreach my $r (@repos) {
        my @jobsets = $c->model('DB::Jobsets')->search(
            { 'project.enabled' => 1, 'me.enabled' => 1 },
            {
                join => 'project',
                where => \ [ 'exists (select 1 from JobsetInputAlts where project = me.project and jobset = me.name and value = ?)', [ 'value', $r ] ],
                order_by => 'me.id DESC'
            });
        foreach my $jobset (@jobsets) {
            requireEvalJobsetPrivileges($c, $jobset->project);
            triggerJobset($self, $c, $jobset, $force)
        }
    }

    $self->status_ok(
        $c,
        entity => { jobsetsTriggered => $c->stash->{json}->{jobsetsTriggered} }
    );
}

sub verifyWebhookSignature {
    my ($c, $platform, $header_name, $signature_prefix) = @_;

    # Get secrets from config
    my $webhook_config = $c->config->{webhooks} // {};
    my $platform_config = $webhook_config->{$platform} // {};
    my $secrets = $platform_config->{secret};

    # Normalize to array
    $secrets = [] unless defined $secrets;
    $secrets = [$secrets] unless ref($secrets) eq 'ARRAY';

    # Trim whitespace from secrets
    my @secrets = grep { defined && length } map { s/^\s+|\s+$//gr } @$secrets;

    if (@secrets) {
        my $signature = $c->request->header($header_name);

        if (!$signature) {
            $c->log->warn("Webhook authentication failed for $platform: Missing signature from IP " . $c->request->address);
            $c->response->status(401);
            $c->stash->{json} = { error => "Missing webhook signature" };
            $c->forward('View::JSON');
            return 0;
        }

        # Get the raw body content from the buffered PSGI input
        # For JSON requests, Catalyst will have already read and buffered the body
        my $input = $c->request->env->{'psgi.input'};
        $input->seek(0, 0);
        local $/;
        my $payload = <$input>;
        $input->seek(0, 0);  # Reset for any other consumers

        unless (defined $payload && length $payload) {
            $c->log->warn("Webhook authentication failed for $platform: Empty request body from IP " . $c->request->address);
            $c->response->status(400);
            $c->stash->{json} = { error => "Empty request body" };
            $c->forward('View::JSON');
            return 0;
        }

        my $valid = 0;
        for my $secret (@secrets) {
            my $expected = $signature_prefix . hmac_sha256_hex($payload, $secret);
            if (equals($signature, $expected)) {
                $valid = 1;
                last;
            }
        }

        if (!$valid) {
            $c->log->warn("Webhook authentication failed for $platform: Invalid signature from IP " . $c->request->address);
            $c->response->status(401);
            $c->stash->{json} = { error => "Invalid webhook signature" };
            $c->forward('View::JSON');
            return 0;
        }

        return 1;
    } else {
        $c->log->warn("Webhook authentication failed for $platform: Unable to validate signature from IP " . $c->request->address . " because no secrets are configured");
        $c->response->status(401);
        $c->stash->{json} = { error => "Invalid webhook signature" };
        $c->forward('View::JSON');
        return 0;
    }
}

sub push_github : Chained('api') PathPart('push-github') Args(0) {
    my ($self, $c) = @_;

    $c->{stash}->{json}->{jobsetsTriggered} = [];

    return unless verifyWebhookSignature($c, 'github', 'X-Hub-Signature-256', 'sha256=');

    my $in = $c->request->{data};
    my $owner = ($in->{repository}->{owner}->{name} // $in->{repository}->{owner}->{login}) or die;
    my $repo = $in->{repository}->{name} or die;
    print STDERR "got push from GitHub repository $owner/$repo\n";

    triggerJobset($self, $c, $_, 0) foreach $c->model('DB::Jobsets')->search(
        { 'project.enabled' => 1, 'me.enabled' => 1 },
        { join => 'project'
        , where => \ [ 'me.flake like ? or exists (select 1 from JobsetInputAlts where project = me.project and jobset = me.name and value like ?)', [ 'flake', "%github%$owner/$repo%"], [ 'value', "%github.com%$owner/$repo%" ] ]
        });
    $c->response->body("");
}

sub push_gitea : Chained('api') PathPart('push-gitea') Args(0) {
    my ($self, $c) = @_;

    $c->{stash}->{json}->{jobsetsTriggered} = [];

    # Note: Gitea doesn't use sha256= prefix
    return unless verifyWebhookSignature($c, 'gitea', 'X-Gitea-Signature', '');

    my $in = $c->request->{data};
    my $url = $in->{repository}->{clone_url} or die;
    $url =~ s/.git$//;
    print STDERR "got push from Gitea repository $url\n";

    triggerJobset($self, $c, $_, 0) foreach $c->model('DB::Jobsets')->search(
        { 'project.enabled' => 1, 'me.enabled' => 1 },
        { join => 'project'
        , where => \ [ 'me.flake like ? or exists (select 1 from JobsetInputAlts where project = me.project and jobset = me.name and value like ?)', [ 'flake', "%$url%"], [ 'value', "%$url%" ] ]
        });
    $c->response->body("");
}


1;
