#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
"""ASF Infrastructure Reporting Dashboard - Jira Statistics Tasks"""
import asyncio

from ..lib import config
from .. import plugins
import aiohttp
import time
import re
import asfpy.pubsub
import datetime

DEFAULT_SCAN_INTERVAL = 900  # Always run a scan every 15 minutes
DEFAULT_DISCOUNT_DELTA = 600  # Calculate weekend discounts in 10 min increments
DEFAULT_RETENTION = 120  # Only return tickets that are still open, or were updated in the last 120 days
DEFAULT_SCAN_DAYS = 90  # Scan last 90 days in a full scan. This should be, at max, 500, usually ~375 issues.
DEFAULT_SLA = {  # Default (fallback) SLA
    "respond": 48,  # 48h to respond
    "resolve": 120,  # 120h to resolve
}

_cache: dict = {}
_stats: dict = {}
_scan_schedule: list = []


class JiraTicket:
    def __init__(self, data):
        self._data = data
        self.assignee = data["fields"]["assignee"]["name"] if data["fields"]["assignee"] else None
        self.status = data["fields"]["status"]["name"]
        self.closed = self.status == "Closed"
        self.reopened = False
        self.key = data["key"]
        self.project = self.key.split("-")[0]
        self.url = config.reporting.jira["ticket_url"].format(**data)
        self.summary = data["fields"]["summary"]
        self.created_at = self.get_time(data["fields"]["created"])
        self.updated_at = self.get_time(data["fields"]["updated"])
        self.priority = data["fields"]["priority"]["name"]
        self.author = data["fields"]["creator"] and data["fields"]["creator"]["name"] or "(nobody)"  # May not exist!
        self.issuetype = data["fields"]["issuetype"]["name"]
        self.sla = config.reporting.jira["slas"].get(self.priority, DEFAULT_SLA)

        # SLA stuff
        self.first_response = 0
        self.response_time = 0
        self.resolve_time = 0
        self.closed_at = 0
        self.sla_met_respond = None  # True/False if responded to at all
        self.sla_met_resolve = None
        self.sla_time_counted = 0
        self.statuses = []
        self.changelog = []
        self.paused = self.issuetype in config.reporting.jira.get("no_slas", [])

        # Scan all changelog entries
        for changelog_entry in data.get("changelog", {}).get("histories", []):
            changelog_author = (
                "author" in changelog_entry and changelog_entry["author"]["name"] or "nobody"
            )  # May have been deleted
            changelog_epoch = self.get_time(changelog_entry["created"])
            self.changelog.append((changelog_author, changelog_epoch))
            for item in changelog_entry.get("items", []):
                field = item["field"]
                if field == "assignee":  # Ticket (re)assigned
                    #  self.set_fr(changelog_epoch)
                    pass  # Should not count as a response
                elif field == "resolution":  # Ticket resolved
                    self.set_fr(changelog_epoch)
                    self.closed_at = changelog_epoch
                elif field == "status":  # Status change
                    if (
                        self.closed_at
                    ):  # if we already logged a close, but there are new status changes, it's been reopened
                        self.reopened = True
                    if not self.statuses:  # First status change, log initial status from this
                        self.statuses.append((item["fromString"].lower(), self.created_at))
                    self.statuses.append((item["toString"].lower(), changelog_epoch))  # Note change to status at time

        # Scan all comments, looking for a response earlier than changelog entries
        for comment in data["fields"].get("comment", {}).get("comments", []):
            comment_author = comment["author"]["name"]
            comment_epoch = self.get_time(comment["created"])
            self.changelog.append((comment_author, comment_epoch))
            if comment_author != self.author:  # Comment by someone other than the ticket author
                self.set_fr(comment_epoch)
                break  # Only need to find the first (earliest) occurrence

        # Calculate time spent in WFI
        times_in_wfi = []

        if not self.statuses:  # No status changes, WFI is assumed to be entire duration
            if self.closed:
                times_in_wfi.append((self.created_at, self.closed_at))  # Ticket is closed, use closed_at
            else:
                times_in_wfi.append((self.created_at, int(time.time())))  # Ticket is open, use $now
        else:
            sla_statuses_lower = [x.lower() for x in config.reporting.jira.get("sla_apply_statuses")]
            previous_ts = 0
            previous_is_sla = False
            for status in self.statuses:
                if previous_ts and previous_is_sla:
                    times_in_wfi.append((previous_ts, status[1]))  # From previous TS to this one
                previous_ts = status[1]
                previous_is_sla = status[0] in sla_statuses_lower

            # Not in WFI mode? pause if not paused
            if not self.closed and self.statuses[-1][0] not in sla_statuses_lower:
                self.paused = True

        for spans in times_in_wfi:
            self.sla_time_counted += self.calc_sla_duration(*spans)
        if self.first_response:
            self.response_time = self.calc_sla_duration(self.created_at, self.first_response)
        if self.closed_at:
            self.resolve_time = self.calc_sla_duration(self.created_at, self.closed_at)

        # If closed or responded to, check if the duration met the SLA guides
        # If not closed or responded to, check if time spent in WFI surpasses SLA guides
        if self.closed:
            self.sla_met_resolve = self.resolve_time <= (self.sla["resolve"] * 3600)
        elif self.sla_time_counted > (self.sla["resolve"] * 3600):
            self.sla_met_resolve = False
        if self.first_response:
            self.sla_met_respond = self.response_time <= (self.sla["respond"] * 3600)
        elif self.sla_time_counted > (self.sla["respond"] * 3600):
            self.sla_met_respond = False

    @property
    def as_dict(self):
        return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}

    @staticmethod
    def calc_sla_duration(from_epoch, to_epoch):
        """Calculates the active SLA time (in seconds) between two durations, discounting weekends"""
        should_discount = config.reporting.jira.get("sla_discount_weekend")
        seconds_spent = to_epoch - from_epoch  # Add seconds between the two transitions
        if should_discount:
            dt_start = datetime.datetime.utcfromtimestamp(from_epoch)
            dt_end = datetime.datetime.utcfromtimestamp(to_epoch)
            total_discount = 0
            dt_temp = dt_start
            while dt_temp < dt_end and total_discount < seconds_spent:
                dt_temp += datetime.timedelta(seconds=DEFAULT_DISCOUNT_DELTA)
                if (
                    dt_temp.weekday() in [5, 6]  # Sat, Sun
                    or (dt_temp.weekday() == 4 and dt_temp.hour > 20)  # Fri after 8pm UTC
                    or (dt_temp.weekday() == 0 and dt_temp.hour < 8)  # Mon before 8am UTC
                ):
                    total_discount += DEFAULT_DISCOUNT_DELTA
            seconds_spent -= min(seconds_spent, total_discount)
        return seconds_spent

    def set_fr(self, epoch):
        if self.first_response:
            self.first_response = min(self.first_response, epoch)
        else:
            self.first_response = epoch

    @staticmethod
    def get_time(string):
        """Converts a jira ISO timestamp to unix epoch"""
        ts = time.strptime(re.sub(r"\..*", "", str(string)), "%Y-%m-%dT%H:%M:%S")
        epoch_local = time.mktime(ts)
        # Account for UTC offset when computer has a local TZ
        utc_offset = (datetime.datetime.fromtimestamp(epoch_local) - datetime.datetime.utcfromtimestamp(epoch_local)).total_seconds()
        return int(epoch_local + utc_offset)


def process_cache(issues):
    if issues:
        _stats.clear()  # Clear stats cache if we have data, so as to remove deleted tickets
    for issue in issues:
        key = issue["key"]
        _cache[key] = issue
        ticket = JiraTicket(issue)
        _stats[key] = ticket
    return len(issues)


def get_issues():
    deadline = time.time() - (DEFAULT_RETENTION * 86400)
    return [x.as_dict for x in _stats.values() if x.closed is False or x.updated_at >= deadline]


async def jira_scan_full(days=DEFAULT_SCAN_DAYS):
    """Performs a full scan of Jira activity in the past [days] days"""
    jira_scan_url = config.reporting.jira["api_url"] + "search"
    jira_project = config.reporting.jira["project"]
    jira_token = config.reporting.jira["token"]

    params = {
        "fields": "key,created,summary,status,assignee,priority,comment,creator,updated,issuetype",
        "expand": "changelog",
        "maxResults": "1000",
        "jql": f"""project={jira_project} and (updated>=-{days}d or status!=closed)""",
    }

    async with aiohttp.ClientSession(headers={"Authorization": f"Bearer: {jira_token}"}) as hc:
        async with hc.get(jira_scan_url, params=params) as req:
            if req.status == 200:
                jira_json = await req.json()
                processed = process_cache(jira_json.get("issues", []))
                return processed


async def scan_loop():
    while True:
        if _scan_schedule:  # Things are scheduled for a scan
            now = time.time()
            print("Starting Jira scan")
            processed = await jira_scan_full()
            print(f"Processed {processed} tickets in {int(time.time()-now)} seconds")
            _scan_schedule.pop()  # pop an item, freeing up space to allocate a new scan
        await asyncio.sleep(60)  # Always wait 60 secs between scan checks


async def poll_loop():
    """Schedules a scan every DEFAULT_SCAN_INTERVAL seconds, plus when a pubsub event happens.
    No more than two events can be scheduled at any given time (iow if a scan is running, and
    we get a pubsub event, we can add one more scan to be done in the future."""
    loop = asyncio.get_running_loop()

    def maybe_timeout(duration):
        "Use asyncio.timeout() for Py3.11; stub out for lower versions."
        if hasattr(asyncio, 'timeout'):
            return asyncio.timeout(duration)
        import contextlib
        class StubTimeout:
            def reschedule(self, t):
                pass
        @contextlib.asynccontextmanager
        async def gen_stub():
            yield StubTimeout()
        return gen_stub()

    pubsub_url = config.reporting.jira.get("pubsub_url")
    while True:
        _scan_schedule.append(time.time())  # Schedule a scan
        if pubsub_url:
            try:
                async with maybe_timeout(60) as to:
                    async for payload in asfpy.pubsub.listen(pubsub_url):
                        to.reschedule(loop.time() + 60)  # Got a response, pubsub works, reschedule timeout
                        if "stillalive" not in payload:  # Not a ping
                            if len(_scan_schedule) < 2:
                                _scan_schedule.append(time.time())  # add scan to schedule
            except TimeoutError:
                print("PubSub connection timed out, re-establishing")
                continue
        else:
            await asyncio.sleep(DEFAULT_SCAN_INTERVAL)


plugins.root.register(poll_loop, scan_loop, slug="jira", title="Jira Tickets", icon="bi-bug-fill", private=True)
