import logging
import re
from typing import List

import requests
import sentry_sdk

from odoo import models, fields, api
from odoo.osv import expression
from odoo.tools import reverse_order

_logger = logging.getLogger(__name__)
class Project(models.Model):
    _name = _description = 'runbot_merge.project'

    name = fields.Char(required=True, index=True)
    repo_ids = fields.One2many(
        'runbot_merge.repository', 'project_id',
        help="Repos included in that project, they'll be staged together. "\
        "*Not* to be used for cross-repo dependencies (that is to be handled by the CI)"
    )
    branch_ids = fields.One2many(
        'runbot_merge.branch', 'project_id',
        context={'active_test': False},
        help="Branches of all project's repos which are managed by the merge bot. Also "\
        "target branches of PR this project handles."
    )
    staging_enabled = fields.Boolean(default=True)
    staging_priority = fields.Selection([
        ('default', "Splits over ready PRs"),
        ('largest', "Largest of split and ready PRs"),
        ('ready', "Ready PRs over split"),
    ], default="default", required=True)
    staging_statuses = fields.Boolean(default=True)
    staging_rpc = fields.Boolean(default=False)

    ci_timeout = fields.Integer(
        default=60, required=True, group_operator=None,
        help="Delay (in minutes) before a staging is considered timed out and failed"
    )

    github_token = fields.Char("Github Token", required=True)
    github_name = fields.Char(store=True, compute="_compute_identity", required=True, copy=True)
    github_email = fields.Char(store=True, compute="_compute_identity", required=True, copy=True)
    github_prefix = fields.Char(
        required=True,
        default="hanson", # mergebot du bot du bot du~
        help="Prefix (~bot name) used when sending commands from PR "
             "comments e.g. [hanson retry] or [hanson r+ priority]",
    )
    fp_github_token = fields.Char()
    fp_github_name = fields.Char(store=True, compute="_compute_git_identity")

    batch_limit = fields.Integer(
        default=8, group_operator=None, help="Maximum number of PRs staged together")

    freeze_id = fields.Many2one('runbot_merge.project.freeze', compute='_compute_freeze')
    freeze_reminder = fields.Text()

    uniquifier = fields.Boolean(
        default=True,
        help="Whether to add a uniquifier commit on repositories without PRs"
             " during staging. The lack of uniquifier can lead to CI conflicts"
             " as github works off of commits, so it's possible for an"
             " unrelated build to trigger a failure if somebody is a dummy and"
             " includes repos they have no commit for."
    )

    @api.depends('github_token')
    def _compute_identity(self):
        s = requests.Session()
        for project in self:
            if not project.github_token or (project.github_name and project.github_email):
                continue

            headers = {'Authorization': f'token {project.github_token}'}
            r0 = s.get('https://api.github.com/user', headers=headers)
            if not r0.ok:
                _logger.warning("Failed to fetch merge bot information for project %s: %s", project.name, r0.text or r0.content)
                continue

            r = r0.json()
            project.github_name = r['name'] or r['login']
            if email := r['email']:
                project.github_email = email
                continue

            if 'user:email' not in set(re.split(r',\s*', r0.headers['x-oauth-scopes'])):
                _logger.warning("Unable to fetch merge bot emails for project %s: scope missing from token", project.name)
            r1 = s.get('https://api.github.com/user/emails', headers=headers)
            if not r1.ok:
                _logger.warning("Failed to fetch merge bot emails for project %s: %s", project.name, r1.text or r1.content)
                continue

            project.github_email = next((
                entry['email']
                for entry in r1.json()
                if entry['primary']
            ), None)

    # technically the email could change at any moment...
    @api.depends('fp_github_token')
    def _compute_git_identity(self):
        s = requests.Session()
        for project in self:
            if project.fp_github_name or not project.fp_github_token:
                continue

            r0 = s.get('https://api.github.com/user', headers={
                'Authorization': 'token %s' % project.fp_github_token
            })
            if not r0.ok:
                _logger.error("Failed to fetch forward bot information for project %s: %s", project.name, r0.text or r0.content)
                continue

            user = r0.json()
            project.fp_github_name = user['name'] or user['login']

    def _check_stagings(self, commit=False):
        # check branches with an active staging
        for branch in self.env['runbot_merge.branch']\
                .with_context(active_test=False)\
                .search([('active_staging_id', '!=', False)]):
            staging = branch.active_staging_id
            try:
                with self.env.cr.savepoint():
                    staging.check_status()
            except Exception:
                _logger.exception("Failed to check staging for branch %r (staging %s)",
                                  branch.name, staging)
            else:
                if commit:
                    self.env.cr.commit()

    def _create_stagings(self, commit=False):
        from .stagings_create import try_staging

        # look up branches which can be staged on and have no active staging
        for branch in self.env['runbot_merge.branch'].search([
            ('active_staging_id', '=', False),
            ('active', '=', True),
            ('staging_enabled', '=', True),
            ('project_id.staging_enabled', '=', True),
        ]):
            try:
                with self.env.cr.savepoint():
                    if not self.env['runbot_merge.patch']._apply_patches(branch):
                        self.env.ref("runbot_merge.staging_cron")._trigger()
                        return

            except Exception:
                _logger.exception("Failed to apply patches to branch %r", branch.name)
            else:
                if commit:
                    self.env.cr.commit()

            try:
                with self.env.cr.savepoint(), \
                    sentry_sdk.start_span(description=f'create staging {branch.name}') as span:
                    span.set_tag('branch', branch.name)
                    try_staging(branch)
            except Exception:
                _logger.exception("Failed to create staging for branch %r", branch.name)
            else:
                if commit:
                    self.env.cr.commit()

    def _find_commands(self, comment: str) -> List[str]:
        """Tries to find all the lines starting (ignoring leading whitespace)
        with either the merge or the forward port bot identifiers.

        For convenience, the identifier *can* be prefixed with an ``@`` or
        ``#``, and suffixed with a ``:``.
        """
        # horizontal whitespace (\s - {\n, \r}), but Python doesn't have \h or \p{Blank}
        h = r'[^\S\r\n]'
        return re.findall(
            fr'^{h}*[@|#]?{self.github_prefix}(?:{h}+|:{h}*)(.*)$',
            comment, re.MULTILINE | re.IGNORECASE)

    def _has_branch(self, name):
        self.env['runbot_merge.branch'].flush_model(['project_id', 'name'])
        self.env.cr.execute("""
        SELECT 1 FROM runbot_merge_branch
        WHERE project_id = %s AND name = %s
        LIMIT 1
        """, (self.id, name))
        return bool(self.env.cr.rowcount)

    def _next_freeze(self):
        prev = self.branch_ids[1:2].name
        if not prev:
            return None

        m = re.search(r'(\d+)(?:\.(\d+))?$', prev)
        if m:
            return "%s.%d" % (m[1], (int(m[2] or 0) + 1))
        else:
            return f'post-{prev}'

    def _compute_freeze(self):
        freezes = {
            f.project_id.id: f.id
            for f in self.env['runbot_merge.project.freeze'].search([('project_id', 'in', self.ids)])
        }
        for project in self:
            project.freeze_id = freezes.get(project.id) or False

    def action_prepare_freeze(self):
        """ Initialises the freeze wizard and returns the corresponding action.
        """
        self.check_access_rights('write')
        self.check_access_rule('write')
        Freeze = self.env['runbot_merge.project.freeze'].sudo()

        w = Freeze.search([('project_id', '=', self.id)]) or Freeze.create({
            'project_id': self.id,
            'branch_name': self._next_freeze(),
            'release_pr_ids': [
                (0, 0, {'repository_id': repo.id})
                for repo in self.repo_ids
                if repo.freeze
            ]
        })
        return w.action_open()

    def _forward_port_ordered(self, domain=()):
        Branches = self.env['runbot_merge.branch']
        return Branches.search(expression.AND([
            [('project_id', '=', self.id)],
            domain or [],
        ]), order=reverse_order(Branches._order))
