# -*- coding: utf-8 -*-
import datetime
import werkzeug
import logging
import functools

import werkzeug.utils
import werkzeug.urls

from collections import defaultdict, OrderedDict
from werkzeug.exceptions import NotFound, Forbidden

from odoo.addons.website.controllers.main import QueryURL

from odoo.http import Controller, Response, request, route as o_route
from odoo.osv import expression

_logger = logging.getLogger(__name__)


def route(routes, **kw):
    def decorator(f):
        @o_route(routes, **kw)
        @functools.wraps(f)
        def response_wrap(*args, **kwargs):
            projects = request.env['runbot.project'].search([('hidden', '=', False)])
            more = request.httprequest.cookies.get('more', False) == '1'
            filter_mode = request.httprequest.cookies.get('filter_mode', 'all')
            keep_search = request.httprequest.cookies.get('keep_search', False) == '1'
            cookie_search = request.httprequest.cookies.get('search', '')
            refresh = kwargs.get('refresh', False)
            nb_build_errors = request.env['runbot.build.error'].search_count([])
            nb_assigned_errors = request.env['runbot.build.error'].search_count([('responsible', '=', request.env.user.id)])
            nb_team_errors = request.env['runbot.build.error'].search_count([('responsible', '=', False), ('team_id', 'in', request.env.user.runbot_team_ids.ids)])
            kwargs['more'] = more
            kwargs['projects'] = projects

            response = f(*args, **kwargs)
            if isinstance(response, Response):
                if keep_search and cookie_search and 'search' not in kwargs:
                    search = cookie_search
                else:
                    search = kwargs.get('search', '')
                has_pr = kwargs.get('has_pr', None)
                if keep_search and cookie_search != search:
                    response.set_cookie('search', search)

                project = response.qcontext.get('project') or projects and projects[0]

                response.qcontext['theme'] = kwargs.get('theme', request.httprequest.cookies.get('theme', 'legacy'))
                response.qcontext['projects'] = projects
                response.qcontext['more'] = more
                response.qcontext['keep_search'] = keep_search
                response.qcontext['search'] = search
                response.qcontext['current_path'] = request.httprequest.full_path
                response.qcontext['refresh'] = refresh
                response.qcontext['filter_mode'] = filter_mode
                response.qcontext['default_category'] = request.env['ir.model.data']._xmlid_to_res_id('runbot.default_category')
                slug = request.env['ir.http']._slug
                response.qcontext['qu'] = QueryURL('/runbot/%s' % (slug(project) if project else ''), search=search, refresh=refresh, has_pr=has_pr)
                if 'title' not in response.qcontext:
                    response.qcontext['title'] = 'Runbot %s' % project.name or ''
                response.qcontext['nb_build_errors'] = nb_build_errors
                response.qcontext['nb_assigned_errors'] = nb_assigned_errors
                response.qcontext['nb_team_errors'] = nb_team_errors
                if 'page_info_state' not in response.qcontext:
                    response.qcontext['page_info_state'] = 'ok'
            return response
        return response_wrap
    return decorator


class Runbot(Controller):

    def _pending(self):
        ICP = request.env['ir.config_parameter'].sudo().get_param
        warn = int(ICP('runbot.pending.warning', 5))
        crit = int(ICP('runbot.pending.critical', 12))
        pending_count = request.env['runbot.build'].search_count([('local_state', '=', 'pending'), ('build_type', '!=', 'scheduled'), ('host', '=', False)])
        scheduled_count = request.env['runbot.build'].search_count([('local_state', '=', 'pending'), ('build_type', '=', 'scheduled')])
        pending_assigned_count = request.env['runbot.build'].search_count([('local_state', '=', 'pending'), ('build_type', '!=', 'scheduled'), ('host', '!=', False)])
        level = ['info', 'warning', 'danger'][int(pending_count > warn) + int(pending_count > crit)]
        return pending_count, level, scheduled_count, pending_assigned_count

    @o_route([
        '/runbot/submit'
    ], type='http', auth="public", methods=['GET', 'POST'], csrf=False)
    def submit(self, more=False, redirect='/', keep_search=False, category=False, filter_mode=False, update_triggers=False, **kwargs):
        assert redirect.startswith('/')
        response = werkzeug.utils.redirect(redirect)
        response.set_cookie('more', '1' if more else '0')
        if update_triggers:
            enabled_triggers = []
            project_id = int(update_triggers)
            for key in kwargs.keys():
                if key.startswith('trigger_'):
                    enabled_triggers.append(key.replace('trigger_', ''))

            key = 'trigger_display_%s' % project_id
            if len(request.env['runbot.trigger'].search([('project_id', '=', project_id)])) == len(enabled_triggers):
                response.delete_cookie(key)
            else:
                response.set_cookie(key, '-'.join(enabled_triggers))
        return response

    @route(['/',
            '/runbot',
            '/runbot/<model("runbot.project"):project>',
            '/runbot/<model("runbot.project"):project>/search/<search>'], website=True, auth='public', type='http')
    def bundles(self, project=None, search='', projects=False, refresh=False, for_next_freeze=False, limit=40, has_pr=None, **kwargs):
        search = search if len(search) < 60 else search[:60]
        env = request.env
        categories = env['runbot.category'].search([])
        if not project and projects:
            project = projects[0]

        pending_count, level, scheduled_count, pending_assigned_count = self._pending()
        context = {
            'categories': categories,
            'search': search,
            'message': request.env['ir.config_parameter'].sudo().get_param('runbot.runbot_message'),
            'pending_count': pending_count,
            'pending_assigned_count': pending_assigned_count,
            'pending_level': level,
            'scheduled_count': scheduled_count,
            'hosts_data': request.env['runbot.host'].search([('assigned_only', '=', False)]),
        }
        if project:
            domain = [('last_batch', '!=', False), ('project_id', '=', project.id)]
            if not search:
                domain.append(('no_build', '=', False))

            if has_pr is not None:
                domain.append(('has_pr', '=', bool(has_pr)))

            filter_mode = request.httprequest.cookies.get('filter_mode', False)
            if filter_mode == 'sticky':
                domain.append(('sticky', '=', True))
            elif filter_mode == 'nosticky':
                domain.append(('sticky', '=', False))

            if for_next_freeze:
                domain.append(('for_next_freeze', '=', True))

            if search:
                search_domains = []
                pr_numbers = []
                for search_elem in search.split("|"):
                    if search_elem.isnumeric():
                        pr_numbers.append(int(search_elem))
                    operator = '=ilike' if '%' in search_elem else 'ilike'
                    search_domains.append([('name', operator, search_elem)])
                if pr_numbers:
                    res = request.env['runbot.branch'].search([('name', 'in', pr_numbers)])
                    if res:
                        search_domains.append([('id', 'in', res.mapped('bundle_id').ids)])
                search_domain = expression.OR(search_domains)
                domain = expression.AND([domain, search_domain])

            e = expression.expression(domain, request.env['runbot.bundle'])
            query = e.query
            query.order = """
             (case when "runbot_bundle".sticky then 1 when "runbot_bundle".sticky is null then 2 else 2 end),
                    case when "runbot_bundle".sticky then "runbot_bundle".version_number end collate "C" desc,
                    "runbot_bundle".last_batch desc
            """
            query.limit = min(int(limit), 200)
            bundles = env['runbot.bundle'].browse(query)

            category_id = int(request.httprequest.cookies.get('category') or 0) or request.env['ir.model.data']._xmlid_to_res_id('runbot.default_category')

            trigger_display = request.httprequest.cookies.get('trigger_display_%s' % project.id, None)
            if trigger_display is not None:
                trigger_display = [int(td) for td in trigger_display.split('-') if td]
            bundles = bundles.with_context(category_id=category_id)

            triggers = env['runbot.trigger'].search([('project_id', '=', project.id)])
            context.update({
                'active_category_id': category_id,
                'bundles': bundles,
                'project': project,
                'triggers': triggers,
                'trigger_display': trigger_display,
                'has_pr': has_pr,
                'search': search,
            })

        context.update({'message': request.env['ir.config_parameter'].sudo().get_param('runbot.runbot_message')})
        # request.is_frontend = False  # remove inherit branding
        return request.render('runbot.bundles', context)

    @route([
        '/runbot/bundle/<model("runbot.bundle"):bundle>',
        '/runbot/bundle/<model("runbot.bundle"):bundle>/page/<int:page>',
        '/runbot/bundle/<string:bundle>',
        ], website=True, auth='public', type='http', sitemap=False)
    def bundle(self, bundle=None, page=1, limit=50, **kwargs):
        if isinstance(bundle, str):
            bundle = request.env['runbot.bundle'].search([('name', '=', bundle)], limit=1, order='id')
            if not bundle:
                raise NotFound
            slug = request.env['ir.http']._slug
            return werkzeug.utils.redirect(f'/runbot/bundle/{slug(bundle)}')
        domain = [('bundle_id', '=', bundle.id), ('hidden', '=', False)]
        batch_count = request.env['runbot.batch'].search_count(domain)
        pager = request.website.pager(
            url='/runbot/bundle/%s' % bundle.id,
            total=batch_count,
            page=page,
            step=50,
        )
        batchs = request.env['runbot.batch'].search(domain, limit=limit, offset=pager.get('offset', 0), order='id desc')

        context = {
            'bundle': bundle,
            'batchs': batchs,
            'pager': pager,
            'project': bundle.project_id,
            'title': 'Bundle %s' % bundle.name,
            'page_info_state': bundle.last_batch._get_global_result(),
        }

        return request.render('runbot.bundle', context)

    @o_route([
        '/runbot/bundle/<model("runbot.bundle"):bundle>/force',
        '/runbot/bundle/<model("runbot.bundle"):bundle>/force/<int:auto_rebase>',
    ], type='http', auth="user", methods=['GET', 'POST'], csrf=False)
    def force_bundle(self, bundle, auto_rebase=False, use_base_commits=False,**_post):
        if not request.env.user.has_group('runbot.group_runbot_advanced_user') and ':' not in bundle.name:
            raise Forbidden("Only users with a specific group can do that. Please contact runbot administrators")
        _logger.info('user %s forcing bundle %s', request.env.user.name, bundle.name)  # user must be able to read bundle
        batch = bundle.sudo()._force() or bundle.sudo().last_batch
        batch._log('Batch forced by %s', request.env.user.name)
        batch._prepare(auto_rebase=bool(auto_rebase), use_base_commits=bool(use_base_commits))
        batch._process()
        return werkzeug.utils.redirect('/runbot/batch/%s' % batch.id)

    @route(['/runbot/batch/<int:batch_id>'], website=True, auth='public', type='http', sitemap=False)
    def batch(self, batch_id=None, **kwargs):
        batch = request.env['runbot.batch'].browse(batch_id)
        context = {
            'batch': batch,
            'project': batch.bundle_id.project_id,
            'title': 'Batch %s (%s)' % (batch.id, batch.bundle_id.name),
            'page_info_state': batch._get_global_result(),
        }
        return request.render('runbot.batch', context)

    @o_route(['/runbot/batch/slot/<model("runbot.batch.slot"):slot>/build'], auth='user', type='http')
    def slot_create_build(self, slot=None, **kwargs):
        build = slot.sudo()._create_missing_build()
        return werkzeug.utils.redirect('/runbot/build/%s' % build.id)

    @route([
        '/runbot/commit/<model("runbot.commit"):commit>',
        '/runbot/commit/<string(minlength=6, maxlength=40):commit_hash>'
    ], website=True, auth='public', type='http', sitemap=False)
    def commit(self, commit=None, commit_hash=None, **kwargs):
        if commit_hash:
            commit = request.env['runbot.commit'].search([('name', '=like', f'{commit_hash}%')], limit=1)
            if not commit.exists():
                raise NotFound()
            slug = request.env['ir.http']._slug
            return request.redirect(f"/runbot/commit/{slug(commit)}")
        status_list = request.env['runbot.commit.status'].search([('commit_id', '=', commit.id)], order='id desc')
        last_status_by_context = dict()
        for status in status_list:
            if status.context in last_status_by_context:
                continue
            last_status_by_context[status.context] = status
        context = {
            'commit': commit,
            'project': commit.repo_id.project_id,
            'reflogs': request.env['runbot.ref.log'].search([('commit_id', '=', commit.id)]),
            'status_list': status_list,
            'last_status_by_context': last_status_by_context,
            'title': 'Commit %s' % commit.name[:8]
        }
        return request.render('runbot.commit', context)

    @o_route(['/runbot/commit/resend/<int:status_id>'], website=True, auth='user', type='http')
    def resend_status(self, status_id=None, **kwargs):
        CommitStatus = request.env['runbot.commit.status']
        status = CommitStatus.browse(status_id)
        if not status.exists():
            raise NotFound()
        last_status = CommitStatus.search([('commit_id', '=', status.commit_id.id), ('context', '=', status.context)], order='id desc', limit=1)
        if status != last_status:
            raise Forbidden("Only the last status can be resent")
        if not last_status.sent_date or (datetime.datetime.now() - last_status.sent_date).seconds > 60:  # ensure at least 60sec between two resend
            new_status = status.sudo().copy()
            new_status.description = 'Status resent by %s' % request.env.user.name
            new_status._send()
            _logger.info('github status %s resent by %s', status_id, request.env.user.name)
        return werkzeug.utils.redirect('/runbot/commit/%s' % status.commit_id.id)

    @o_route([
        '/runbot/build/<int:build_id>/<operation>',
    ], type='http', auth="user", methods=['POST'], csrf=False)
    def build_operations(self, build_id, operation, **post):
        build = request.env['runbot.build'].sudo().browse(build_id)
        if operation == 'rebuild':
            build = build._rebuild()
        elif operation == 'kill':
            build._ask_kill()
        elif operation == 'wakeup':
            build._wake_up()

        return str(build.id)

    @route([
        '/runbot/build/<int:build_id>',
        '/runbot/batch/<int:from_batch>/build/<int:build_id>'
    ], type='http', auth="public", website=True, sitemap=False)
    def build(self, build_id, search=None, from_batch=None, **post):
        """Events/Logs"""

        if from_batch:
            from_batch = request.env['runbot.batch'].browse(int(from_batch))
            if build_id not in from_batch.with_context(active_test=False).slot_ids.build_id.ids:
                # the url may have been forged replacing the build id, redirect to hide the batch
                return werkzeug.utils.redirect('/runbot/build/%s' % build_id)

            from_batch = from_batch.with_context(batch=from_batch)
        Build = request.env['runbot.build'].with_context(batch=from_batch)

        build = Build.browse([build_id])[0]
        if not build.exists():
            return request.not_found()
        siblings = (build.parent_id.children_ids if build.parent_id else from_batch.slot_ids.build_id if from_batch else build).sorted('id')
        context = {
            'build': build,
            'from_batch': from_batch,
            'project': build.params_id.trigger_id.project_id,
            'title': 'Build %s' % build.id,
            'siblings': siblings,
            'page_info_state': build.global_result,
            # following logic is not the most efficient but good enough
            'prev_ko': next((b for b in reversed(siblings) if b.id < build.id and b.global_result != 'ok'), Build),
            'prev_bu': next((b for b in reversed(siblings) if b.id < build.id), Build),
            'next_bu': next((b for b in siblings if b.id > build.id), Build),
            'next_ko': next((b for b in siblings if b.id > build.id and b.global_result != 'ok'), Build),
        }
        return request.render("runbot.build", context)

    @route([
    '/runbot/build/search',
    ], website=True, auth='public', type='http', sitemap=False)
    def builds(self, **kwargs):
        domain = []
        for key in ('config_id', 'version_id', 'project_id', 'trigger_id', 'create_batch_id.bundle_id', 'create_batch_id'): # allowed params
            value = kwargs.get(key)
            if value:
                domain.append((f'params_id.{key}', '=', int(value)))

        for key in ('global_state', 'local_state', 'global_result', 'local_result'):
            value = kwargs.get(key)
            if value:
                domain.append((f'{key}', '=', value))

        for key in ('description',):
            if key in kwargs:
                domain.append((f'{key}', 'ilike', kwargs.get(key)))

        context = {
            'builds': request.env['runbot.build'].search(domain, limit=100),
        }

        return request.render('runbot.build_search', context)

    @route([
        '/runbot/branch/<model("runbot.branch"):branch>',
        ], website=True, auth='public', type='http', sitemap=False)
    def branch(self, branch=None, **kwargs):
        pr_branch = branch.bundle_id.branch_ids.filtered(lambda rec: not rec.is_pr and rec.id != branch.id and rec.remote_id.repo_id == branch.remote_id.repo_id)[:1]
        branch_pr = branch.bundle_id.branch_ids.filtered(lambda rec: rec.is_pr and rec.id != branch.id and rec.remote_id.repo_id == branch.remote_id.repo_id)[:1]
        context = {
            'branch': branch,
            'project': branch.remote_id.repo_id.project_id,
            'title': 'Branch %s' % branch.name,
            'pr_branch': pr_branch,
            'branch_pr': branch_pr
            }

        return request.render('runbot.branch', context)

    @route([
        '/runbot/glances',
        '/runbot/glances/<int:project_id>'
        ], type='http', auth='public', website=True, sitemap=False)
    def glances(self, project_id=None, **kwargs):
        project_ids = [project_id] if project_id else request.env['runbot.project'].search([]).ids # search for access rights
        bundles = request.env['runbot.bundle'].search([('sticky', '=', True), ('project_id', 'in', project_ids)])
        pending_count, level, scheduled_count, pending_assigned_count = self._pending()
        qctx = {
            'pending_count': pending_count,
            'pending_assigned_count': pending_assigned_count,
            'pending_level': level,
            'bundles': bundles,
            'title': 'Glances'
        }
        return request.render("runbot.glances", qctx)

    @route(['/runbot/monitoring',
            '/runbot/monitoring/<int:category_id>',
            '/runbot/monitoring/<int:category_id>/<int:view_id>'], type='http', auth='user', website=True, sitemap=False)
    def monitoring(self, category_id=None, view_id=None, **kwargs):
        pending_count, level, scheduled_count, pending_assigned_count = self._pending()
        hosts_data = request.env['runbot.host'].search([])
        if category_id:
            category = request.env['runbot.category'].browse(category_id)
            assert category.exists()
        else:
            category = request.env.ref('runbot.nightly_category')
            category_id = category.id
        bundles = request.env['runbot.bundle'].search([('sticky', '=', True)])  # NOTE we dont filter on project
        qctx = {
            'category': category,
            'pending_count': pending_count,
            'pending_assigned_count': pending_assigned_count,
            'pending_level': level,
            'scheduled_count': scheduled_count,
            'bundles': bundles,
            'hosts_data': hosts_data,
            'auto_tags': request.env['runbot.build.error']._disabling_tags(),
            'build_errors': request.env['runbot.build.error'].search([('random', '=', True)]),
            'kwargs': kwargs,
            'title': 'monitoring'
        }
        return request.render(view_id if view_id else "runbot.monitoring", qctx)

    @route(['/runbot/errors/assign/<int:build_error_id>'
            ], type='http', auth='user', methods=['POST'], csrf=False, sitemap=False)
    def build_errors_assign(self, build_error_id=None, **kwargs):
        build_error = request.env['runbot.build.error'].browse(build_error_id)
        if build_error.responsible:
            return build_error.responsible.name
        if request.env.user._is_internal():
            build_error.sudo().responsible = request.env.user
            return request.env.user.name
        return 'Error'

    @route(['/runbot/errors',
            '/runbot/errors/page/<int:page>'
            ], type='http', auth='user', website=True, sitemap=False)
    def build_errors(self, sort=None, page=1, limit=20, **kwargs):
        sort_order_choices = {
            'last_seen_date desc': 'Last seen date: Newer First',
            'last_seen_date asc': 'Last seen date: Older First',
            'build_count desc': 'Number seen: High to Low',
            'build_count asc': 'Number seen: Low to High',
            'responsible asc': 'Assignee: A - Z',
            'responsible desc': 'Assignee: Z - A',
            'module_name asc': 'Module name: A - Z',
            'module_name desc': 'Module name: Z -A'
        }

        sort_order = sort if sort in sort_order_choices else 'last_seen_date desc'

        current_user_errors = request.env['runbot.build.error'].search([
            ('responsible', '=', request.env.user.id),
        ], order='last_seen_date desc, build_count desc')
        current_team_errors = request.env['runbot.build.error'].search([
            ('responsible', '=', False),
            ('team_id', 'in', request.env.user.runbot_team_ids.ids)
        ], order='last_seen_date desc, build_count desc')
        domain = [('responsible', '!=', request.env.user.id), ('build_count', '>', 1)]
        build_errors_count = request.env['runbot.build.error'].search_count(domain)
        url_args = {}
        url_args['sort'] = sort
        pager = request.website.pager(url='/runbot/errors/', url_args=url_args, total=build_errors_count, page=page, step=limit)

        build_errors = request.env['runbot.build.error'].search(domain, order=sort_order, limit=limit, offset=pager.get('offset', 0))

        qctx = {
            'current_user_errors': current_user_errors,
            'current_team_errors': current_team_errors,
            'build_errors': build_errors,
            'title': 'Build Errors',
            'sort_order_choices': sort_order_choices,
            'page': page,
            'pager': pager,
        }
        return request.render('runbot.build_error', qctx)

    @route(['/runbot/teams', '/runbot/teams/<model("runbot.team"):team>',], type='http', auth='user', website=True, sitemap=False)
    def team_dashboards(self, team=None, hide_empty=False, **kwargs):
        teams = request.env['runbot.team'].search([]) if not team else None
        domain = [('id', 'in', team.assignment_ids.ids)] if team else []

        # Sort & Filter
        sortby = kwargs.get('sortby', 'count')
        searchbar_sortings = {
            'date': {'label': 'Recently Seen', 'order': 'last_seen_date desc'},
            'count': {'label': 'Nb Seen', 'order': 'build_count desc'},
        }
        order = searchbar_sortings[sortby]['order']
        searchbar_filters = {
            'all': {'label': 'All', 'domain': []},
            'unassigned': {'label': 'Unassigned', 'domain': [('responsible', '=', False)]},
            'not_one': {'label': 'Seen more than once', 'domain': [('build_count', '>', 1)]},
        }

        for trigger in team.assignment_ids.trigger_ids if team else []:
            k = f'trigger_{trigger.name.lower().replace(" ", "_")}'
            searchbar_filters.update(
                {k: {'label': f'Trigger {trigger.name}', 'domain': [('trigger_ids', '=', trigger.id)]}}
            )

        filterby = kwargs.get('filterby', 'not_one')
        if filterby not in searchbar_filters:
            filterby = 'not_one'
        domain = expression.AND([domain, searchbar_filters[filterby]['domain']])

        qctx = {
            'team': team,
            'teams': teams,
            'assignment_ids': request.env['runbot.build.error'].search(domain, order=order),
            'hide_empty': bool(hide_empty),
            'searchbar_sortings': searchbar_sortings,
            'sortby': sortby,
            'searchbar_filters': OrderedDict(sorted(searchbar_filters.items())),
            'filterby': filterby,
            'default_url': request.httprequest.path,
        }
        return request.render('runbot.team', qctx)

    @route(['/runbot/dashboards/<model("runbot.dashboard"):dashboard>',], type='http', auth='user', website=True, sitemap=False)
    def dashboards(self, dashboard=None, hide_empty=False, **kwargs):
        qctx = {
            'dashboard': dashboard,
            'hide_empty': bool(hide_empty),
        }
        return request.render('runbot.dashboard_page', qctx)

    @route(['/runbot/build/stats/<int:build_id>'], type='http', auth="public", website=True, sitemap=False)
    def build_stats(self, build_id, search=None, **post):
        """Build statistics"""

        Build = request.env['runbot.build']

        build = Build.browse([build_id])[0]
        if not build.exists():
            return request.not_found()

        build_stats = defaultdict(dict)
        for stat in build.stat_ids:
            for module, value in sorted(stat.values.items(), key=lambda item: item[1], reverse=True):
                build_stats[stat.category][module] = value

        context = {
            'build': build,
            'build_stats': build_stats,
            'project': build.params_id.trigger_id.project_id,
            'title': 'Build %s statistics' % build.id
        }
        return request.render("runbot.build_stats", context)


    @route(['/runbot/stats/'], type='json', auth="public", website=False, sitemap=False)
    def stats_json(self, bundle_id=False, trigger_id=False, key_category='', center_build_id=False, ok_only=False, limit=100, search=None, **post):
        """ Json stats """
        trigger_id = trigger_id and int(trigger_id)
        bundle_id = bundle_id and int(bundle_id)
        center_build_id = center_build_id and int(center_build_id)
        limit = min(int(limit), 1000)

        trigger = request.env['runbot.trigger'].browse(trigger_id)
        bundle = request.env['runbot.bundle'].browse(bundle_id)
        if not trigger_id or not bundle_id or not trigger.exists() or not bundle.exists():
            return request.not_found()

        builds_domain = [
            ('global_state', 'in', ('running', 'done')),
            ('slot_ids.batch_id.bundle_id', '=', bundle_id),
            ('params_id.trigger_id', '=', trigger.id),
        ]
        if ok_only:
            builds_domain += ('global_result', '=', 'ok')
        builds = request.env['runbot.build'].with_context(active_test=False)
        if center_build_id:
            builds = builds.search(
                expression.AND([builds_domain, [('id', '>=', center_build_id)]]),
                order='id', limit=limit/2)
            builds_domain = expression.AND([builds_domain, [('id', '<=', center_build_id)]])
            limit -= len(builds)

        builds |= builds.search(builds_domain, order='id desc', limit=limit)
        if not builds:
            return {}

        builds = builds.search([('id', 'child_of', builds.ids)])

        parents = {b.id: b.top_parent.id for b in builds.with_context(prefetch_fields=False)}
        request.env.cr.execute("SELECT build_id, values FROM runbot_build_stat WHERE build_id IN %s AND category = %s", [tuple(builds.ids), key_category])  # read manually is way faster than using orm
        res = {}
        for (build_id, values) in request.env.cr.fetchall():
            if values:
                res.setdefault(parents[build_id], {}).update(values)
            # we need to update here to manage the post install case: we want to combine stats from all post_install childrens.
        return res

    @route(['/runbot/stats/<model("runbot.bundle"):bundle>/<model("runbot.trigger"):trigger>'], type='http', auth="public", website=True, sitemap=False)
    def modules_stats(self, bundle, trigger, search=None, **post):
        """Modules statistics"""
        categories = set()

        def list_config_categories(config):
            nonlocal categories
            for config_step in config.step_ids:
                regex_ids = config_step.build_stat_regex_ids
                if not regex_ids:
                    regex_ids = regex_ids.search([('generic', '=', True)])
                categories |= set(regex_ids.mapped('name'))
                for config in config_step.create_config_ids:
                    list_config_categories(config)

        list_config_categories(trigger.config_id)

        categories = sorted(categories)

        context = {
            'stats_categories': categories,
            'bundle': bundle,
            'trigger': trigger,
        }

        return request.render("runbot.modules_stats", context)

    @route(['/runbot/load_info'], type='http', auth="user", website=True, sitemap=False)
    def load_infos(self, host: str | None = None, **post):
        build_by_bundle = {}

        domain = [('local_state', 'in', ('pending', 'testing'))]
        if host:
            domain.append(('host', '=', host))

        for build in request.env['runbot.build'].search(domain, order='id'):
            build_by_bundle.setdefault(build.params_id.create_batch_id.bundle_id, []).append(build)

        build_by_bundle = list(build_by_bundle.items())
        build_by_bundle.sort(key=lambda x: -len(x[1]))
        pending_count, level, scheduled_count, pending_assigned_count = self._pending()
        context = {
            'build_by_bundle': build_by_bundle,
            'pending_count': pending_count,
            'pending_assigned_count': pending_assigned_count,
            'pending_level': level,
            'scheduled_count': scheduled_count,
            'hosts_data': request.env['runbot.host'].search([('assigned_only', '=', False)]),
        }

        return request.render("runbot.load_info", context)

    @route([
        '/runbot/run/<build_id>',
        '/runbot/run/<build_id>/<db_suffix>',
    ], type='http', auth="public", website=True, sitemap=False)
    def access_running(self, build_id, db_suffix=None, **kwargs):
        build = request.env['runbot.build'].browse(int(build_id)).exists()
        run_url = build._get_run_url(db_suffix)
        _logger.info('Redirecting to %s', run_url)
        return werkzeug.utils.redirect(run_url)

    @route(['/runbot/parse_log/<model("ir.logging"):ir_log>'], type='http', auth='user', sitemap=False)
    def parse_log(self, ir_log, **kwargs):
        request.env['runbot.build.error']._parse_logs(ir_log)
        return werkzeug.utils.redirect('/runbot/build/%s' % ir_log.build_id.id)

    @route(['/runbot/bundle/toggle_no_build/<int:bundle_id>/<int:value>'], type='http', auth='user', sitemap=False)
    def toggle_no_build(self, bundle_id, value, **kwargs):
        if not request.env.user.has_group('base.group_user'):
            return 'Forbidden'
        bundle = request.env['runbot.bundle'].browse(bundle_id).exists()
        if bundle.sticky or bundle.is_base:
            return 'Forbidden'
        if bundle.project_id.tmp_prefix and bundle.name.startswith(bundle.project_id.tmp_prefix):
            return 'Forbidden'
        bundle.sudo().no_build = bool(value)
        _logger.info('Bundle %s no_build set to %s by %s', bundle.name, bool(value), request.env.user.name)
        return werkzeug.utils.redirect(f'/runbot/bundle/{bundle_id}')

    @route(['/runbot/trigger/report/<model("runbot.trigger"):trigger_id>'], type='http', auth='user', website=True, sitemap=False)
    def report_view(self, trigger_id=None, **kwargs):
        return request.render("runbot.trigger_report", {
            'trigger': trigger_id,
            'project': trigger_id.project_id,
        })

    @route([
        '/runbot/dockerfile',
        '/runbot/dockerfile/<int:dockerfile_id>',
        '/runbot/dockerfile/tag/<string:docker_tag>',
        '/runbot/dockerfile/version/<string:version>',
    ], type='http', auth='public', sitemap=False)
    def dockerfile_content(self, dockerfile_id=None, version=None, docker_tag=None, **kwargs):
        dockerfile_sudo = request.env['runbot.dockerfile'].sudo()
        if 'id' in kwargs:
            dockerfile_id = int(kwargs['id'])
        if dockerfile_id:  # keep 'id' for historical reasons
            dockerfile = dockerfile_sudo.browse(dockerfile_id).exists()
        elif docker_tag:
            dockerfile = dockerfile_sudo.search([('image_tag', '=', docker_tag)])
        elif version:
            dockerfile = dockerfile_sudo.search([('version_ids.name', '=', version)])
        else:
            raise NotFound

        if dockerfile.public_visibility:
            return Response(response=dockerfile.layer_ids.render_layers({
                'USERUID': '${USERUID}',
                'USERGID': '${USERGID}',
                'USERNAME': '${USERNAME}',
            }), status=200, mimetype='text/plain')

        if dockerfile:
            _logger.error('Trying to access a non public docker image')
        raise NotFound
