# Copyright 2014-2021 SUSE LLC
# SPDX-License-Identifier: GPL-2.0-or-later

=head1 NAME

OpenQA::WebAPI::Controller::Step - Handles test step rendering in openQA.

=head1 SYNOPSIS

    use OpenQA::WebAPI::Controller::Step;

    # Example usage inside a Mojolicious route
    my $controller = OpenQA::WebAPI::Controller::Step->new;
    $controller->view();

=head1 DESCRIPTION

This module is a Mojolicious controller responsible for:
- Fetching and displaying test steps: screenshots, text logs and audio.
- Handling needle matching for openQA tests.
- Managing Git-based needle storage and retrieval.
- Providing AJAX responses for web-based test analysis.

=cut

package OpenQA::WebAPI::Controller::Step;
use Mojo::Base 'Mojolicious::Controller', -signatures;

use Cwd 'realpath';
use Encode 'decode_utf8';
use Mojo::File 'path';
use Mojo::URL;
use Mojo::Util 'decode';
use OpenQA::Needles qw(needle_temp_dir locate_needle);
use OpenQA::Utils qw(ensure_timestamp_appended find_bug_number needledir testcasedir
  run_cmd_with_log run_cmd_with_log_return_error config_autocommit_enabled);
use OpenQA::Jobs::Constants;
use File::Basename;
use File::Which 'which';
use POSIX 'strftime';
use Mojo::JSON 'decode_json';
use Feature::Compat::Try;

use constant AVG_SIMILARITY_THRESHOLD => 70;

sub _init ($self) {
    return 0 unless my $job = $self->app->schema->resultset('Jobs')->find($self->param('testid'));
    my %attrs = (rows => 1, order_by => {-desc => 'id'});
    my $module = $job->modules->find({name => $self->param('moduleid')}, \%attrs) or return 0;

    $self->stash(job => $job);
    $self->stash(testname => $job->name);
    $self->stash(distri => $job->DISTRI);
    $self->stash(version => $job->VERSION);
    $self->stash(build => $job->BUILD);
    $self->stash(module => $module);

    return 1;
}

sub check_tabmode ($self) {
    my $job = $self->stash('job');
    my $module = $self->stash('module');
    my $details = $module->results->{details};
    my $testindex = $self->param('stepid');
    return if ($testindex > @$details);

    my $tabmode = 'screenshot';    # default
    my $module_detail = $details->[$testindex - 1];
    if ($module_detail->{audio}) {
        $tabmode = 'audio';
    }
    elsif ($module_detail->{text}) {
        $self->stash('textresult', $module_detail->{text_data});
        $tabmode = 'text';
    }
    $self->stash('imglist', $details);
    $self->stash('module_detail', $module_detail);
    $self->stash('tabmode', $tabmode);

    return 1;
}

# Helper function to generate the needle url, with an optional version
sub needle_url ($self, $distri, $name, $version, $jsonfile) {
    my $url = $self->url_for('needle_file', distri => $distri, name => $name);
    my %query;
    $query{jsonfile} = $jsonfile if $jsonfile;
    $query{version} = $version if $version;
    return keys %query ? $url->query(%query) : $url;
}

# Call to viewimg or viewaudio
sub view ($self) {
    # Redirect users with the old preview link
    if (!$self->req->is_xhr) {
        my $anchor = '#step/' . $self->param('moduleid') . '/' . $self->param('stepid');
        my $target_url = $self->url_for('test', testid => $self->param('testid'));
        return $self->redirect_to($target_url . $anchor);
    }

    return $self->reply->not_found unless $self->_init && $self->check_tabmode();

    my $tabmode = $self->stash('tabmode');
    return $self->render('step/viewaudio') if $tabmode eq 'audio';
    return $self->render('step/viewtext') if $tabmode eq 'text';
    $self->viewimg;
}

sub _get_needles_ref_and_url ($self, $job) {
    # Return tuple of git ref hash and url of needles or undef on error.
    return undef unless $self->app->config->{'scm git'}->{checkout_needles_sha} eq 'yes';
    my $json_path = path($job->result_dir, 'vars.json');
    my $vars = defined $job->result_dir && -e $json_path ? decode_json($json_path->slurp) : undef;
    my $needles_ref = ($vars // {})->{NEEDLES_GIT_HASH};
    my $needles_url = ($vars // {})->{NEEDLES_GIT_URL};
    return undef unless $needles_ref;
    return ($needles_ref, $needles_url);
}

# Needle editor
sub edit ($self) {
    return $self->reply->not_found unless $self->_init && $self->check_tabmode();

    my $module_detail = $self->stash('module_detail');
    my $job = $self->stash('job');
    my $imgname = $module_detail->{screenshot};
    my $distri = $job->DISTRI;
    my $dversion = $job->VERSION || '';
    my $needle_dir = $job->needle_dir;
    my $app = $self->app;
    my $needles_rs = $app->schema->resultset('Needles');

    # Each object in @needles will contain the name, both the url and the local path
    # of the image and 2 lists of areas: 'area' and 'matches'.
    # The former refers to the original definitions and the later shows the position
    # found (best try) in the actual screenshot.
    # The first element of the needles array is the screenshot itself, with an empty
    # 'areas' (there is no needle associated to the screenshot) and with all matching
    # areas in 'matches'.
    my @needles;

    my @error_messages;
    my $tags = $module_detail->{tags} // [];
    my $screenshot;
    my %basic_needle_data = (
        tags => $tags,
        distri => $distri,
        version => $dversion,
        image_name => $imgname,
    );

    if (my $needle_name = $module_detail->{needle}) {
        # First position: the screenshot with all the matching areas (in result)
        $screenshot = $self->_new_screenshot($tags, $imgname, $module_detail->{area});

        # Second position: the only needle (with the same matches)
        my $needle_info
          = $self->_extended_needle_info($needle_dir, $needle_name, \%basic_needle_data, $module_detail->{json},
            0, \@error_messages);
        if ($needle_info) {
            $needle_info->{matches} = $screenshot->{matches};
            push @needles, $needle_info;
        }
    }
    if (my $module_detail_needles = $module_detail->{needles}) {
        # First position: the screenshot
        $screenshot = $self->_new_screenshot($tags, $imgname);

        # Afterwards: all the candidate needles
        # $needle contains information from result, in which 'areas' refers to the best matches.
        # We also use $area for transforming the match information into a real area
        for my $needle (@$module_detail_needles) {
            my $needle_info = $self->_extended_needle_info(
                $needle_dir, $needle->{name}, \%basic_needle_data,
                $needle->{json}, $needle->{error}, \@error_messages
            ) || next;
            my $matches = $needle_info->{matches};
            for my $match (@{$needle->{area}}) {
                my %area = (
                    xpos => int $match->{x},
                    width => int $match->{w},
                    ypos => int $match->{y},
                    height => int $match->{h},
                    type => 'match',
                );
                $area{margin} = int $match->{margin} if defined $match->{margin};
                $area{match} = int $match->{match} if defined $match->{match};
                $area{click_point} = $match->{click_point} if defined $match->{click_point};
                push @$matches, \%area;
            }
            push @needles, $needle_info;
        }
    }

    # handle case when failing with not a single candidate needle
    $screenshot //= $self->_new_screenshot($tags, $imgname);

    # sort needles: the highest matches first
    @needles = sort { $b->{avg_similarity} <=> $a->{avg_similarity} || $a->{name} cmp $b->{name} } @needles;

    # check whether new needles with matching tags have already been created since the job has been started
    if (@$tags) {
        my $new_needles = $needles_rs->new_needles_since($job->t_started, $tags, 5);
        while (my $new_needle = $new_needles->next) {
            my $new_needle_tags = $new_needle->tags;
            my $joined_tags = $new_needle_tags ? join(', ', @$new_needle_tags) : 'none';
            # show warning for new needle with matching tags
            push @error_messages,
              sprintf 'A new needle with matching tags has been created since the job started: %s (tags: %s)',
              $new_needle->filename, $joined_tags;
            # get needle info to show the needle also in selection
            my $needle_info
              = $self->_extended_needle_info($needle_dir, $new_needle->name, \%basic_needle_data, $new_needle->path,
                undef, \@error_messages)
              || next;
            $needle_info->{title} = 'new: ' . $needle_info->{title};
            push @needles, $needle_info;
        }
    }

    # set default values
    #  - area: matches from best candidate
    #  - tags: tags from the screenshot
    my $default_needle = {};
    my $first_needle = $needles[0];
    if ($first_needle && ($first_needle->{avg_similarity} || 0) > AVG_SIMILARITY_THRESHOLD) {
        $first_needle->{selected} = 1;
        $default_needle->{tags} = $first_needle->{tags};
        $default_needle->{area} = $first_needle->{matches};
        $default_needle->{properties} = $first_needle->{properties};
        $screenshot->{suggested_name} = $first_needle->{suggested_name};
    }
    else {
        $screenshot->{selected} = 1;
        $default_needle->{tags} = $screenshot->{tags};
        $default_needle->{area} = [];
        $default_needle->{properties} = [];
        my $name = $self->param('moduleid');
        if (@{$screenshot->{tags}}) {
            # concat the module name and the tag unless the tag already starts
            # with the module name
            my $ftag = $screenshot->{tags}->[0];
            $name = ($ftag =~ m/^$name/) ? $ftag : "$name-$ftag";
        }
        $screenshot->{suggested_name} = ensure_timestamp_appended($name);
    }

    # clear tags, area and matches of screenshot and prepend it to needles
    $screenshot->{tags} = $screenshot->{area} = $screenshot->{matches} = [];
    unshift @needles, $screenshot;

    $self->stash(
        {
            needles => \@needles,
            tags => $tags,
            default_needle => $default_needle,
            error_messages => \@error_messages,
            autocommit_enabled => config_autocommit_enabled($app->config),
        });
    $self->render('step/edit');
}

sub _new_click_point ($click_point) { $click_point ? (click_point => $click_point) : () }

sub _new_match ($area) {
    return {
        xpos => int $area->{x},
        ypos => int $area->{y},
        width => int $area->{w},
        height => int $area->{h},
        type => 'match',
        _new_click_point($area->{click_point}),
    };
}

sub _new_screenshot ($self, $tags, $image_name, $matches = undef) {
    return {
        name => 'screenshot',
        title => 'Screenshot',
        imagename => $image_name,
        imagedir => '',
        imageurl => $self->url_for('test_img', filename => $image_name)->to_string(),
        area => [],
        matches => $matches ? [map { _new_match($_) } @$matches] : [],
        properties => [],
        json => '',
        tags => $tags,
    };
}

sub _basic_needle_info (
    $self, $name, $distri, $version, $file_name, $needles_dir,
    $needle_ref = undef,
    $needle_url = undef
  )
{
    $file_name //= "$name.json";
    $file_name = locate_needle($file_name, $needles_dir, $needle_ref, $needle_url) if !-f $file_name;
    return (undef, 'File not found') unless defined $file_name;

    my $needle;
    try { $needle = decode_json(Mojo::File->new($file_name)->slurp) }
    catch ($e) { return (undef, $e) }

    my $png_fname = basename($file_name, '.json') . '.png';
    my $pngfile = File::Spec->catpath('', $needles_dir, $png_fname);

    $needle->{needledir} = $needles_dir;
    $needle->{image} = $pngfile;
    $needle->{json} = "$file_name";
    $needle->{name} = $name;
    $needle->{distri} = $distri;
    $needle->{version} = $version;

    # Skip code to support compatibility if HASH-workaround properties already present
    return ($needle, undef) unless $needle->{properties};

    # Transform string-workaround-properties into HASH-workaround-properties
    $needle->{properties}
      = [map { ref($_) eq 'HASH' ? $_ : {name => $_, value => find_bug_number($name)} } @{$needle->{properties}}];

    return ($needle, undef);
}

sub _extended_needle_info (
    $self, $needle_dir, $needle_name, $basic_needle_data, $file_name,
    $error, $error_messages, $needle_ref = undef, $needle_url = undef
  )
{
    my $overall_list_of_tags = $basic_needle_data->{tags};
    my $distri = $basic_needle_data->{distri};
    my $version = $basic_needle_data->{version};
    my ($needle_info, $err)
      = $self->_basic_needle_info($needle_name, $distri, $version, $file_name, $needle_dir, $needle_ref, $needle_url);
    unless (defined $needle_info) {
        push @$error_messages, "Could not parse needle $needle_name for $distri $version: $err";
        return undef;
    }

    $needle_info->{title} = $needle_name;
    $needle_info->{suggested_name} = ensure_timestamp_appended($needle_name);
    $needle_info->{imageurl}
      = $self->needle_url($distri, $needle_name . '.png', $version, $needle_info->{json})->to_string();
    $needle_info->{imagename} = basename($needle_info->{image});
    $needle_info->{imagedir} = dirname($needle_info->{image});
    $needle_info->{imagedistri} = $distri;
    $needle_info->{imageversion} = $version;
    $needle_info->{tags} //= [];
    $needle_info->{matches} //= [];
    $needle_info->{properties} //= [];
    $needle_info->{json} //= '';

    $error //= $needle_info->{error};
    if (defined $error) {
        $needle_info->{avg_similarity} = map_error_to_avg($error);
        $needle_info->{title} = $needle_info->{avg_similarity} . '%: ' . $needle_name;
    }
    for my $tag (@{$needle_info->{tags}}) {
        push @$overall_list_of_tags, $tag unless grep { /^$tag$/ } @$overall_list_of_tags;
    }
    return $needle_info;
}

sub src ($self) {
    return $self->reply->not_found unless $self->_init;

    my $job = $self->stash('job');
    my $module = $self->stash('module');

    if (my $casedir = $job->settings->single({key => 'CASEDIR'})) {
        my $casedir_url = Mojo::URL->new($casedir->value);
        # if CASEDIR points to a remote location let's assume it is a git repo
        # that we can reference like gitlab/github
        if ($casedir_url->scheme) {
            my $refspec = $casedir_url->fragment;
            # try to read vars.json from resultdir and replace branch by actual git hash if possible
            try {
                my $vars_json = Mojo::File->new($job->result_dir(), 'vars.json')->slurp;
                my $vars = decode_json($vars_json);
                $refspec = $vars->{TEST_GIT_HASH};
            }
            catch ($e) { }
            $refspec ||= 'HEAD';
            my $module_path = '/blob/' . $refspec . '/' . $module->script;
            # github treats '.git' as optional extension which needs to be stripped
            $casedir_url->path($casedir_url->path =~ s/\.git//r . $module_path);
            $casedir_url->fragment('');
            return $self->redirect_to($casedir_url);
        }
    }
    my $testcasedir = testcasedir($job->DISTRI, $job->VERSION);
    my $scriptpath = "$testcasedir/" . $module->script;
    return $self->reply->not_found unless $scriptpath && -e $scriptpath;
    my $script;
    try { $script = path($scriptpath)->slurp }
    catch ($e) { return $self->reply->not_found }    # uncoverable statement
    $self->render(script => decode_utf8($script), scriptpath => $scriptpath);
}

sub save_needle_ajax ($self) {
    return $self->reply->not_found unless $self->_init;

    # validate parameter
    my $app = $self->app;
    my $validation = $self->validation;
    $validation->required('json');
    $validation->required('imagename')->like(qr/^[^.\/][^\/]{3,}\.png$/);
    $validation->required('needlename')->like(qr/^[^.\/][^\/]{3,}$/);
    $validation->optional('imagedistri')->like(qr/^[^.\/]*$/);
    $validation->optional('imageversion')->like(qr/^[^\/]*$/);
    $validation->optional('commit_message');
    return $self->reply->validation_error({format => 'json'}) if $validation->has_error;

    # read parameter
    my $job = $self->find_job_or_render_not_found($self->param('testid')) or return;
    my $job_id = $job->id;
    my $needledir = needledir($job->DISTRI, $job->VERSION);
    my $needlename = $validation->param('needlename');

    $self->gru->enqueue_and_keep_track(
        task_name => 'save_needle',
        task_description => 'saving needles',
        task_args => {
            job_id => $job_id,
            user_id => $self->current_user->id,
            needle_json => $validation->param('json'),
            overwrite => $self->param('overwrite'),
            imagedir => $self->param('imagedir') // '',
            imagedistri => $validation->param('imagedistri'),
            imagename => $validation->param('imagename'),
            imageversion => $validation->param('imageversion'),
            needledir => $needledir,
            needlename => $needlename,
            commit_message => $validation->param('commit_message'),
        }
    )->then(
        sub ($result) {
            # handle request for overwrite
            if ($result->{requires_overwrite}) {
                my $initial_request = $self->req->params->to_hash;
                $initial_request->{requires_overwrite} = 1;
                return $self->render(json => $initial_request);
            }

            # trigger needle scan and emit event on success
            if (my $json_data = $result->{json_data}) {
                $app->gru->enqueue('scan_needles');
                $app->emit_event(
                    openqa_needle_modify => {
                        needle => "$needledir/$needlename.png",
                        tags => $json_data->{tags},
                        update => 0,
                    });
            }

            # add the URL to restart if that should be proposed to the user
            $result->{restart} = $self->url_for('apiv1_restart', jobid => $job_id) if ($result->{propose_restart});

            $self->render(json => $result);
        })->catch(sub (@args) { $self->reply->gru_result(@args) });    # uncoverable statement
}

sub map_error_to_avg ($error) { int((1 - sqrt($error // 0)) * 100 + 0.5) }

sub _calc_match ($area) {
    return {
        xpos => int $area->{x},
        ypos => int $area->{y},
        width => int $area->{w},
        height => int $area->{h},
        type => $area->{result},
        similarity => int($area->{similarity} + 0.5),
        _new_click_point($area->{click_point}),
    };
}

sub _calc_matches ($needle, $areas) {
    push @{$needle->{matches}}, map { _calc_match($_) } @$areas;
    $needle->{avg_similarity} //= map_error_to_avg($needle->{error});
    return $needle;
}

sub viewimg ($self) {
    my $module_detail = $self->stash('module_detail');
    my $job = $self->stash('job');
    return $self->reply->not_found unless $job;
    my $distri = $job->DISTRI;
    my $dversion = $job->VERSION || '';
    my $needle_dir = $job->needle_dir;
    my ($needle_ref, $needle_url) = $self->_get_needles_ref_and_url($job);
    my $real_needle_dir = realpath($needle_dir) // $needle_dir;
    my $needles_rs = $self->app->schema->resultset('Needles');

    # initialize hash to store needle lists by tags
    my %needles_by_tag;
    for my $tag (@{$module_detail->{tags}}) {
        $needles_by_tag{$tag} = [];
    }

    my $append_needle_info = sub ($tags, $needle_info) {
        # add timestamps and URLs from database
        $self->populate_hash_with_needle_timestamps_and_urls(
            $needles_rs->find_needle($real_needle_dir, "$needle_info->{name}.json"), $needle_info);

        # handle case when the needle has (for some reason) no tags
        if (!$tags) {
            push @{$needles_by_tag{'tags unknown'} //= []}, $needle_info;
            return undef;
        }

        # ensure we have a label assigned
        $needle_info->{label} //= $needle_info->{avg_similarity} . '%: ' . $needle_info->{name};

        # add the needle info to tags ...
        for my $tag (@$tags) {
            # ... but only to tags the test was actually looking for
            if (my $needles = $needles_by_tag{$tag}) {
                push @$needles, $needle_info;
            }
        }
    };

    # load primary needle match
    my $primary_match;
    if (my $needle = $module_detail->{needle}) {
        my ($needleinfo) = $self->_basic_needle_info($needle, $distri, $dversion, $module_detail->{json},
            $needle_dir, $needle_ref, $needle_url);
        if ($needleinfo) {
            my $info = {
                name => $needle,
                needledir => $needleinfo->{needledir},
                image => $self->needle_url($distri, $needle . '.png', $dversion, $needleinfo->{json}),
                areas => $needleinfo->{area},
                error => $module_detail->{error},
                matches => [],
                primary_match => 1,
                selected => 1,
            };
            $primary_match = _calc_matches($info, $module_detail->{area});
            $append_needle_info->($needleinfo->{tags} => $info);
        }
    }

    # load other needle matches
    if ($module_detail->{needles}) {
        for my $needle (@{$module_detail->{needles}}) {
            my $needlename = $needle->{name};
            my ($needleinfo)
              = $self->_basic_needle_info($needlename, $distri, $dversion, $needle->{json}, $needle_dir, $needle_ref,
                $needle_url);
            next unless $needleinfo;
            my $info = {
                name => $needlename,
                needledir => $needleinfo->{needledir},
                image => $self->needle_url($distri, "$needlename.png", $dversion, $needleinfo->{json}),
                error => $needle->{error},
                areas => $needleinfo->{area},
                matches => [],
            };
            $append_needle_info->($needleinfo->{tags} => _calc_matches($info, $needle->{area}));
        }
    }

    # sort needles by average similarity
    my $has_selection = defined $primary_match;
    for my $tag (keys %needles_by_tag) {
        my @sorted_needles = sort { $b->{avg_similarity} <=> $a->{avg_similarity} || $a->{name} cmp $b->{name} }
          @{$needles_by_tag{$tag}};
        $needles_by_tag{$tag} = \@sorted_needles;

        # preselect a rather good needle
        # note: the same needle can be shown under different tags, hence the selected flag might be occur twice
        #       (even though we check for $has_selection here!)
        my $best_match = $sorted_needles[0];
        if (!$has_selection && $best_match && $best_match->{avg_similarity} > AVG_SIMILARITY_THRESHOLD) {
            $has_selection = $best_match->{selected} = 1;
            $primary_match = $best_match;
        }
    }

    # render error message if there's nothing to show
    my $screenshot = $module_detail->{screenshot};
    if (!$screenshot && !keys %needles_by_tag) {
        $self->stash(textresult => 'Seems like os-autoinst has produced a result which openQA can not display.');
        return $self->render('step/viewtext');
    }
    my %stash = (
        screenshot => $screenshot,
        default_label => $primary_match ? $primary_match->{label} : 'Screenshot',
        needles_by_tag => \%needles_by_tag,
        tag_count => scalar %needles_by_tag,
        video_file_name => undef,
        frametime => 0,
    );
    my $videos = $job->video_file_paths;
    my $frametime = $module_detail->{frametime};
    if ($videos->size) {
        $stash{video_file_name} = $videos->first->basename;
        $stash{frametime} = ref $frametime eq 'ARRAY' ? $frametime : 0;
    }
    $self->stash(\%stash);
    return $self->render('step/viewimg');
}

1;
