import time
import logging
import datetime
import subprocess

from odoo import models, fields, api
from ..common import dt2time, s2human_long, pseudo_markdown, markdown_escape

_logger = logging.getLogger(__name__)


class Batch(models.Model):
    _name = 'runbot.batch'
    _description = "Bundle batch"

    last_update = fields.Datetime('Last ref update')
    bundle_id = fields.Many2one('runbot.bundle', required=True, index=True, ondelete='cascade')
    commit_link_ids = fields.Many2many('runbot.commit.link')
    commit_ids = fields.Many2many('runbot.commit', compute='_compute_commit_ids')
    slot_ids = fields.One2many('runbot.batch.slot', 'batch_id')
    all_build_ids = fields.Many2many('runbot.build', compute='_compute_all_build_ids', help="Recursive builds")
    state = fields.Selection([('preparing', 'Preparing'), ('ready', 'Ready'), ('done', 'Done'), ('skipped', 'Skipped')])
    hidden = fields.Boolean('Hidden', default=False)
    age = fields.Integer(compute='_compute_age', string='Build age')
    category_id = fields.Many2one('runbot.category', index=True, default=lambda self: self.env.ref('runbot.default_category', raise_if_not_found=False))
    log_ids = fields.One2many('runbot.batch.log', 'batch_id')
    has_warning = fields.Boolean("Has warning")
    base_reference_batch_id = fields.Many2one('runbot.batch')
    reference_batch_ids = fields.Many2many(
        'runbot.batch',
        string="Reference batches",
        relation='batch__reference_batch_ids_rel',
        column1='batch_id',
        column2='referenced_batch_id',
    )

    @api.depends('slot_ids.build_id')
    def _compute_all_build_ids(self):
        all_builds = self.env['runbot.build'].search([('id', 'child_of', self.slot_ids.build_id.ids)])
        for batch in self:
            batch.all_build_ids = all_builds.filtered_domain([('id', 'child_of', batch.slot_ids.build_id.ids)])

    @api.depends('commit_link_ids')
    def _compute_commit_ids(self):
        for batch in self:
            batch.commit_ids = batch.commit_link_ids.commit_id

    @api.depends('create_date')
    def _compute_age(self):
        """Return the time between job start and now"""
        for batch in self:
            if batch.create_date:
                batch.age = int(time.time() - dt2time(batch.create_date))
            else:
                batch.buildage_age = 0

    def _get_formated_age(self):
        return s2human_long(self.age)

    def _get_global_result(self):
        """Returns the worst result from the related builds and logs"""
        if not self:
            return 'ok'
        self.ensure_one()
        batch_result = 'warn' if any(log.level != 'INFO' for log in self.log_ids) else 'ok'
        if self.state == 'skipped':
            batch_result = 'skipped'
        return self.env['runbot.build']._get_worst_result([
            batch_result, *self.slot_ids.build_id.mapped('global_result'),
        ])

    def _url(self):
        self.ensure_one()
        return "/runbot/batch/%s" % self.id

    def _new_commit(self, branch, match_type='new'):
        # if not the same hash for repo:
        commit = branch.head
        self.last_update = fields.Datetime.now()
        for commit_link in self.commit_link_ids:
            # case 1: a commit already exists for the repo (pr+branch, or fast push)
            if commit_link.commit_id.repo_id == commit.repo_id:
                if commit_link.commit_id.id != commit.id:
                    self._log('New head on branch %s during throttle phase: Replacing commit %s with %s', branch.name, commit_link.commit_id.name, commit.name)
                    commit_link.write({'commit_id': commit.id, 'branch_id': branch.id})
                elif not commit_link.branch_id.is_pr and branch.is_pr:
                    commit_link.branch_id = branch  # Try to have a pr instead of branch on commit if possible ?
                break
        else:
            self.write({'commit_link_ids': [(0, 0, {
                'commit_id': commit.id,
                'match_type': match_type,
                'branch_id': branch.id
            })]})

    def _skip(self):
        for batch in self:
            if batch.bundle_id.is_base or batch.state == 'done':
                continue
            batch.state = 'skipped'  # done?
            batch._log('Skipping batch')
            for slot in batch.slot_ids:
                slot.skipped = True
                build = slot.build_id
                if build.global_state in ('running', 'done'):
                    continue
                testing_slots = build.params_id.slot_ids.filtered(lambda s: not s.skipped)
                if not testing_slots:
                    if build.global_state == 'pending':
                        build._skip('Newer build found')
                    elif build.global_state in ('waiting', 'testing'):
                        if not build.killable:
                            build.killable = True
                elif slot.link_type == 'created':
                    batches = testing_slots.mapped('batch_id')
                    _logger.info('Cannot skip build %s build is still in use in batches %s', build.id, batches.ids)
                    bundles = batches.mapped('bundle_id') - batch.bundle_id
                    if bundles:
                        batch._log('Cannot kill or skip build %s, build is used in another bundle: %s', build.id, bundles.mapped('name'))

    def _process(self):
        processed = self.browse()
        for batch in self:
            process_delay = batch.bundle_id.project_id.process_delay
            if batch.state == 'preparing' and (
                process_delay == 0 or
                batch.last_update <= fields.Datetime.now() - datetime.timedelta(seconds=process_delay)
            ):
                batch._prepare()
                processed |= batch
            if batch.state == 'ready':
                self._start_builds()
                if all(slot.build_id.global_state in (False, 'running', 'done') for slot in batch.slot_ids):
                    _logger.info('Batch %s is done', self.id)
                    batch._log('Batch done')
                    batch.state = 'done'
                    processed |= batch
        return processed

    def _create_build(self, params):
        """
        Create a build with given params_id if it does not already exists.
        In the case that a very same build already exists that build is returned
        """
        domain = [('params_id', '=', params.id), ('parent_id', '=', False)]
        if self.bundle_id.host_id:
            domain += [('host', '=', self.bundle_id.host_id.name), ('keep_host', '=', True)]
        build = self.env['runbot.build'].search(domain, limit=1, order='id desc')
        link_type = 'matched'
        killed_states = ('skipped', 'killed', 'manually_killed')
        if build and build.local_result not in killed_states and build.global_result not in killed_states:
            if build.killable:
                build.killable = False
        else:
            description = params.trigger_id.description if params.trigger_id.description else False
            link_type = 'created'

            build_type = 'normal'
            if self.category_id != self.env.ref('runbot.default_category'):
                build_type = 'scheduled'
            elif self.bundle_id.priority:
                build_type = 'priority'

            build = self.env['runbot.build'].create({
                'params_id': params.id,
                'description': description,
                'build_type': build_type,
                'no_auto_run': self.bundle_id.no_auto_run,
            })
            if self.bundle_id.host_id:
                build.host = self.bundle_id.host_id.name
                build.keep_host = True

            build._github_status()
        return link_type, build

    def _prepare(self, auto_rebase=False, use_base_commits=False):
        _logger.info('Preparing batch %s', self.id)
        if use_base_commits:
            self._warning('This batch will use base commits instead of bundle commits')
        if not self.bundle_id.base_id:
            # in some case the base can be detected lately. If a bundle has no base, recompute the base before preparing
            self.bundle_id._compute_base_id()
        for level, message in self.bundle_id._consistency_warning():
            if level == "warning":
                self._warning("Bundle warning: %s" % message)

        self.state = 'ready'

        bundle = self.bundle_id
        project = bundle.project_id
        if not bundle.version_id:
            _logger.error('No version found on bundle %s in project %s', bundle.name, project.name)

        dockerfile_id = bundle.dockerfile_id or bundle.base_id.dockerfile_id or bundle.project_id.dockerfile_id or bundle.version_id.dockerfile_id
        if not dockerfile_id:
            _logger.error('No dockerfile found !')

        triggers = self.env['runbot.trigger'].search([  # could be optimised for multiple batches. Ormcached method?
            ('project_id', '=', project.id),
            ('category_id', '=', self.category_id.id)
        ]).filtered(
            lambda t: not t.version_domain or \
            self.bundle_id.version_id.filtered_domain(t._get_version_domain())
        )

        pushed_repo = self.commit_link_ids.mapped('commit_id.repo_id')
        dependency_repos = triggers.mapped('dependency_ids')
        all_repos = triggers.mapped('repo_ids') | dependency_repos
        missing_repos = all_repos - pushed_repo
        foreign_projects = dependency_repos.mapped('project_id') - project

        ######################################
        # Find missing commits
        ######################################
        def _fill_missing(branch_commits, match_type):
            used_branches = []
            if branch_commits:
                for branch, commit in branch_commits.items():  # branch first in case pr is closed.
                    nonlocal missing_repos
                    if commit.repo_id in missing_repos:
                        if not branch.alive:
                            self._log("Skipping dead branch %s" % branch.name)
                            continue
                        values = {
                            'commit_id': commit.id,
                            'match_type': match_type,
                            'branch_id': branch.id,
                        }
                        if match_type.startswith('base'):
                            values['base_commit_id'] = commit.id
                            values['merge_base_commit_id'] = commit.id
                        self.write({'commit_link_ids': [(0, 0, values)]})
                        missing_repos -= commit.repo_id
                        used_branches.append(branch)
            return used_branches

        # CHECK branch heads consistency
        branch_per_repo = {}
        for branch in bundle.branch_ids.sorted(lambda b: (b.head.id, b.is_pr), reverse=True):
            if branch.alive:
                commit = branch.head
                repo = commit.repo_id
                if repo not in branch_per_repo:
                    branch_per_repo[repo] = branch
                elif branch_per_repo[repo].head != branch.head and branch.alive:
                    obranch = branch_per_repo[repo]
                    self._log("Branch %s and branch %s in repo %s don't have the same head: %s ≠ %s", branch.dname, obranch.dname, repo.name, branch.head.name, obranch.head.name)

        # 1.1 FIND missing commit in bundle heads
        if missing_repos:
            _fill_missing({branch: branch.head for branch in bundle.branch_ids.sorted(lambda b: (b.head.id, b.is_pr), reverse=True)}, 'head')

        # 1.2 FIND merge_base info for those commits
        #  use last not preparing batch to define previous repos_heads instead of branches heads:
        #  Will allow to have a diff info on base bundle, compare with previous bundle
        last_base_batch = self.env['runbot.batch'].search([('bundle_id', '=', bundle.base_id.id), ('state', '!=', 'preparing'), ('category_id', '=', self.category_id.id), ('id', '!=', self.id)], order='id desc', limit=1)
        if last_base_batch:
            base_head_per_repo = {commit.repo_id.id: commit for commit in last_base_batch.commit_ids}
            self._update_commits_infos(base_head_per_repo)  # set base_commit, diff infos, ...

        # 2. FIND missing commit in a compatible base bundle
        if bundle.is_base or auto_rebase:
            self.reference_batch_ids = self.env['runbot.batch'].browse(result[1] for result in self.env['runbot.batch']._read_group(
                domain=[
                    ('state', '=', 'done'),
                    ('bundle_id.project_id', '=', bundle.project_id.id),
                    '|',
                    ('bundle_id.to_upgrade', '=', True),
                    ('bundle_id.to_upgrade_from', '=', True),
                    ('bundle_id.is_base', '=', True),
                    ('category_id', '=', self.category_id.id),  # not 100% correct since it should match upgrade_dumps_trigger_id,
                                                                # but all trigger should have upgrade_dumps_trigger_id in the same category
                ],
                groupby=['bundle_id'],
                aggregates=['id:max'],
            ))
        if not bundle.is_base:
            merge_base_commits = self.commit_link_ids.mapped('merge_base_commit_id')
            if self.base_reference_batch_id:
                self._log('Using a defined reference batch [%s](%s)', self.base_reference_batch_id.id, self.base_reference_batch_id._url())
            elif auto_rebase or not bundle.branch_ids:
                self.base_reference_batch_id = last_base_batch
            else:
                self.base_reference_batch_id = False
                link_commit = self.env['runbot.commit.link'].search([
                    ('commit_id', 'in', merge_base_commits.ids),
                    ('match_type', 'in', ('new', 'head'))
                ])
                batches = self.env['runbot.batch'].search([
                    ('bundle_id', '=', bundle.base_id.id),
                    ('commit_link_ids', 'in', link_commit.ids),
                    ('state', '!=', 'preparing'),
                    ('category_id', '=', self.category_id.id)
                ]).sorted(lambda b: (len(b.commit_ids & merge_base_commits), b.id), reverse=True)
                if batches:
                    self.base_reference_batch_id = batches[0]

            if missing_repos and bundle.always_use_foreign and foreign_projects:
                self._log('Starting by filling foreign repo')
                foreign_bundles = bundle.search([('name', '=', bundle.name), ('project_id', 'in', foreign_projects.ids)])
                used_branches = _fill_missing({branch: branch.head for branch in foreign_bundles.mapped('branch_ids').sorted('is_pr', reverse=True)}, 'head')
                if used_branches:
                    commits = ', '.join([f'[{branch.remote_id.repo_id.name}]({branch.bundle_id.name})' for branch in used_branches])
                    self._log(f'Found {len(used_branches)} commits in foreign repo: {commits}')

            batch = self.base_reference_batch_id
            if batch:
                if missing_repos:
                    self._log('Using batch [%s](%s) to define missing commits', batch.id, batch._url())
                    _fill_missing({link.branch_id: link.commit_id for link in batch.commit_link_ids}, 'base_match')
                # check if all mergebase match reference batch
                batch_exiting_commit = batch.commit_ids.filtered(lambda c: c.repo_id in merge_base_commits.repo_id)
                not_matching = (batch_exiting_commit - merge_base_commits)
                if not_matching and not auto_rebase:
                    message = 'Only %s out of %s merge base matched. You may want to rebase your branches to ensure compatibility' % (len(merge_base_commits)-len(not_matching), len(merge_base_commits))
                    suggestions = [('Tip: rebase %s to %s' % (commit.repo_id.name, commit.name)) for commit in not_matching]
                    self._warning('%s\n%s' % (message, '\n'.join(suggestions)))
            else:
                self._log('No reference batch found to fill missing commits')

        # 3.1 FIND missing commit in base heads
        if missing_repos:
            if not bundle.is_base:
                self._log('Not all commit found in bundle branches and base batch. Fallback on base branches heads.')
            _fill_missing({branch: branch.head for branch in self.bundle_id.base_id.branch_ids}, 'base_head')

        # 3.2 FIND missing commit in master base heads
        if missing_repos:  # this is to get an upgrade branch.
            if not bundle.is_base:
                self._log('Not all commit found in current version. Fallback on master branches heads.')
            master_bundle = self.env['runbot.version']._get('master').with_context(project_id=self.bundle_id.project_id.id).base_bundle_id
            _fill_missing({branch: branch.head for branch in master_bundle.branch_ids}, 'base_head')

        # 4. FIND missing commit in foreign project
        if missing_repos:
            if foreign_projects:
                foreign_branches = self.env['runbot.branch']
                all_versions = missing_repos.single_version | bundle.version_id
                foreign_bundles = bundle.search([('version_id', 'in', all_versions.ids), ('is_base', '=', True), ('project_id', 'in', missing_repos.project_id.ids)])
                all_foreign_branches = foreign_bundles.mapped('branch_ids')
                for repo in missing_repos:
                    version = repo.single_version or bundle.version_id
                    foreign_branches |= all_foreign_branches.filtered(lambda b: b.remote_id.repo_id == repo and b.bundle_id.version_id == version)
                _fill_missing({branch: branch.head for branch in foreign_branches}, 'base_head')

        # 5. FIND missing commit in foreign project
        if missing_repos and foreign_projects and missing_repos.mapped('single_version'):
            base_foreign_bundles = bundle.search([('is_base', '=', True), ('version_id', 'in', missing_repos.mapped('single_version').ids), ('project_id', 'in', foreign_projects.ids)])
            _fill_missing({branch: branch.head for branch in base_foreign_bundles.mapped('branch_ids')}, 'base_head')

        # CHECK missing commit
        if missing_repos:
            _logger.warning('Missing repo %s for batch %s', missing_repos.mapped('name'), self.id)

        ######################################
        #  Generate build params
        ######################################
        if auto_rebase:
            for commit_link in self.commit_link_ids:
                commit_link.commit_id = commit_link.commit_id._rebase_on(commit_link.base_commit_id)
        commit_link_by_repos = {commit_link.commit_id.repo_id.id: commit_link for commit_link in self.commit_link_ids}
        base_commit_link_by_repos = {commit_link.commit_id.repo_id.id: commit_link for commit_link in self.base_reference_batch_id.commit_link_ids}
        if use_base_commits:
            commit_link_by_repos = base_commit_link_by_repos
        version_id = self.bundle_id.version_id.id
        project_id = self.bundle_id.project_id.id
        trigger_customs = {}
        for trigger_custom in self.bundle_id.all_trigger_custom_ids:
            trigger_customs[trigger_custom.trigger_id] = trigger_custom
        for trigger in triggers:
            trigger_custom = trigger_customs.get(trigger, self.env['runbot.bundle.trigger.custom'])
            trigger_repos = trigger.repo_ids | trigger.dependency_ids
            if trigger_repos & missing_repos:
                self._warning('Missing commit for repo %s for trigger %s', (trigger_repos & missing_repos).mapped('name'), trigger.name)
                continue
            # in any case, search for an existing build
            config = trigger_custom.config_id or trigger.config_id
            extra_params = trigger_custom.extra_params or ''
            config_data = dict(trigger.config_data or {}) | dict(trigger_custom.config_data or {})
            trigger_commit_link_by_repos = commit_link_by_repos
            if trigger_custom.use_base_commits and self.base_reference_batch_id:
                self._warning(f'This batch will use base commits instead of bundle commits for trigger {trigger.name}')
                trigger_commit_link_by_repos = base_commit_link_by_repos
            commits_links = [trigger_commit_link_by_repos[repo.id].id for repo in trigger_repos]
            params_value = {
                'version_id':  version_id,
                'extra_params': extra_params,
                'config_id': config.id,
                'project_id': project_id,
                'trigger_id': trigger.id,  # for future reference and access rights
                'config_data': config_data,
                'commit_link_ids': [(6, 0, commits_links)],
                'modules': bundle.modules,
                'dockerfile_id': dockerfile_id,
                'create_batch_id': self.id,
                'used_custom_trigger': bool(trigger_custom),
            }

            params_value['builds_reference_ids'] = trigger._reference_builds(self)

            params = self.env['runbot.build.params'].create(params_value)

            self.env['runbot.batch.slot'].create({
                'batch_id': self.id,
                'trigger_id': trigger.id,
                'params_id': params.id,
                'link_type': 'created',
            })

        ######################################
        # SKIP older batches
        ######################################
        default_category = self.env.ref('runbot.default_category')
        if not bundle.sticky and self.category_id == default_category:
            skippable = self.env['runbot.batch'].search([
                ('bundle_id', '=', bundle.id),
                ('state', 'not in', ('done', 'skipped')),
                ('id', '<', self.id),
                ('category_id', '=', default_category.id)
            ])
            skippable._skip()

    def _start_builds(self):
        self.ensure_one()
        bundle = self.bundle_id
        bundle_repos = bundle.branch_ids.filtered('alive').mapped('remote_id.repo_id')
        success_trigger = self.slot_ids.filtered(lambda s: s.build_id.global_state in ('running', 'done') and s.build_id.global_result == "ok").trigger_id
        trigger_customs = {}
        for trigger_custom in self.bundle_id.all_trigger_custom_ids:
            trigger_customs[trigger_custom.trigger_id] = trigger_custom
        for slot in self.slot_ids:
            if slot.build_id:
                continue
            trigger = slot.trigger_id
            if trigger.starts_after_ids - success_trigger:  # some required triggers are missing
                continue

            trigger_custom = trigger_customs.get(trigger, self.env['runbot.bundle.trigger.custom'])
            force_trigger = trigger_custom and trigger_custom.start_mode == 'force'
            skip_trigger = (trigger_custom and trigger_custom.start_mode == 'disabled') or trigger.manual
            should_start = ((trigger.repo_ids & bundle_repos) or bundle.build_all or bundle.sticky)
            if force_trigger or (should_start and not skip_trigger):
                slot.link_type, slot.build_id = self._create_build(slot.params_id)


    def _update_commits_infos(self, base_head_per_repo):
        for link_commit in self.commit_link_ids:
            commit = link_commit.commit_id
            base_head = base_head_per_repo.get(commit.repo_id.id)
            if not base_head:
                self._warning('No base head found for repo %s', commit.repo_id.name)
                continue
            link_commit.base_commit_id = base_head
            merge_base_sha = False
            try:
                link_commit.base_ahead = link_commit.base_behind = 0
                link_commit.file_changed = link_commit.diff_add = link_commit.diff_remove = 0
                link_commit.merge_base_commit_id = commit.id
                if commit.name == base_head.name:
                    continue
                merge_base_sha = commit.repo_id._git(['merge-base', commit.name, base_head.name]).strip()
                merge_base_commit = self.env['runbot.commit']._get(merge_base_sha, commit.repo_id.id)
                link_commit.merge_base_commit_id = merge_base_commit.id

                ahead, behind = commit.repo_id._git(['rev-list', '--left-right', '--count', '%s...%s' % (commit.name, base_head.name)]).strip().split('\t')

                link_commit.base_ahead = int(ahead)
                link_commit.base_behind = int(behind)

                if merge_base_sha == commit.name:
                    continue

                # diff. Iter on --numstat, easier to parse than --shortstat summary
                diff = commit.repo_id._git(['diff', '--numstat', merge_base_sha, commit.name]).strip()
                if diff:
                    for line in diff.split('\n'):
                        link_commit.file_changed += 1
                        add, remove, _ = line.split(None, 2)
                        try:
                            link_commit.diff_add += int(add)
                            link_commit.diff_remove += int(remove)
                        except ValueError:  # binary files
                            pass
            except subprocess.CalledProcessError:
                self._warning('Commit info failed between %s and %s', commit.name, base_head.name)

    def _warning(self, message, *args):
        self.has_warning = True
        _logger.warning('batch %s: ' + message, self.id, *args)
        self._log(message, *args, level='WARNING')

    def _log(self, message, *args, level='INFO'):
        args = tuple([markdown_escape(arg) for arg in args])
        message = message % args if args else message
        self.env['runbot.batch.log'].create({
            'batch_id': self.id,
            'message': message,
            'level': level,
        })


class BatchLog(models.Model):
    _name = 'runbot.batch.log'
    _description = 'Batch log'

    batch_id = fields.Many2one('runbot.batch', index=True)
    message = fields.Text('Message')
    level = fields.Char()


    def _markdown(self):
        """ Apply pseudo markdown parser for message.
        """
        self.ensure_one()
        return pseudo_markdown(self.message)


fa_link_types = {'created': 'hashtag', 'matched': 'link', 'rebuild': 'refresh'}

class BatchSlot(models.Model):
    _name = 'runbot.batch.slot'
    _description = 'Link between a bundle batch and a build'
    _order = 'trigger_id,id'

    batch_id = fields.Many2one('runbot.batch', index=True)
    trigger_id = fields.Many2one('runbot.trigger', index=True)
    build_id = fields.Many2one('runbot.build', index=True)
    all_build_ids = fields.Many2many('runbot.build', compute='_compute_all_build_ids')
    params_id = fields.Many2one('runbot.build.params', index=True, required=True)
    link_type = fields.Selection([('created', 'Build created'), ('matched', 'Existing build matched'), ('rebuild', 'Rebuild')], required=True)  # rebuild type?
    active = fields.Boolean('Attached', default=True)
    skipped = fields.Boolean('Skipped', default=False)
    # rebuild, what to do: since build can be in multiple batch:
    # - replace for all batch?
    # - only available on batch and replace for batch only?
    # - create a new bundle batch will new linked build?

    @api.depends('build_id')
    def _compute_all_build_ids(self):
        all_builds = self.env['runbot.build'].search([('id', 'child_of', self.build_id.ids)])
        for slot in self:
            slot.all_build_ids = all_builds.filtered_domain([('id', 'child_of', slot.build_id.ids)])

    def _fa_link_type(self):
        return fa_link_types.get(self.link_type, 'exclamation-triangle')

    def _create_missing_build(self):
        """Create a build when the slot does not have one"""
        self.ensure_one()
        if self.build_id:
            return self.build_id
        self.batch_id._log(f'Trigger {self.trigger_id.name} was started by {self.env.user.name}')
        self.link_type, self.build_id = self.batch_id._create_build(self.params_id)
        return self.build_id
