#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later

"""
# Generates the weekly report containing information on:
# - Pull Requests created,
# - Pull Requests revised,
# - Issues closed,
# - Issues confirmed,
# - Commits,

Example usage:

    python ./weekly_report.py --username mano-wii
"""
__all__ = (
    "main",
)


import argparse
import datetime
import json
import re
import shutil
import sys

from dataclasses import dataclass, field

from gitea_utils import (
    gitea_json_activities_get,
    gitea_json_pull_request_by_base_and_head_get,
    gitea_json_issue_events_filter,
    gitea_json_issue_get,
    gitea_user_get, git_username_detect,
)

from typing import (
    Any,
)
from collections.abc import (
    Iterable,
)

# Support piping the output to a file or process.
IS_ATTY = sys.stdout.isatty()


if IS_ATTY:
    def print_progress(text: str) -> None:
        # The trailing space clears the previous output.
        term_width = shutil.get_terminal_size(fallback=(80, 20))[0]

        if (space := term_width - len(text)) > 0:
            text = text + (" " * space)
        print(text, end="\r", flush=True)
else:
    def print_progress(text: str) -> None:
        del text


def argparse_create() -> argparse.ArgumentParser:

    def str_as_isodate(value: str) -> datetime.datetime:
        try:
            value_as_date = datetime.datetime.fromisoformat(value)
        except Exception as ex:
            raise argparse.ArgumentTypeError("Must be a valid ISO date (YYYY-MM-DD), failed: {!s}".format(ex))
        return value_as_date

    parser = argparse.ArgumentParser(
        description="Generate Weekly Report",
        epilog="This script is typically used to help write weekly reports",
    )

    parser.add_argument(
        "--username",
        dest="username",
        metavar='USERNAME',
        type=str,
        required=False,
        help="",
    )

    parser.add_argument(
        "--weeks-ago",
        dest="weeks_ago",
        type=int,
        default=1,
        help=(
            "Determine which week the report should be generated for. 0 means the current week. "
            "The default is 1, to create a report for the previous week."
        ),
    )

    parser.add_argument(
        "--date",
        dest="date",
        type=str_as_isodate,
        default=None,
        help="Show only for this day (YYYY-MM-DD), and not for an entire week."
    )

    parser.add_argument(
        "--hash-length",
        dest="hash_length",
        type=int,
        default=10,
        help="Number of characters to abbreviate the hash to (0 to disable).",
    )

    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="increase output verbosity",
    )

    return parser


def report_personal_weekly_get(
        username: str,
        start: datetime.datetime,
        num_days: int,
        *,
        hash_length: int,
        verbose: bool = True,
) -> None:

    data_cache: dict[str, dict[str, Any]] = {}

    def gitea_json_issue_get_cached(issue_fullname: str) -> dict[str, Any]:
        if issue_fullname not in data_cache:
            issue = gitea_json_issue_get(issue_fullname)
            data_cache[issue_fullname] = issue

        return data_cache[issue_fullname]

    pulls_closed: set[str] = set()
    pulls_commented: set[str] = set()
    pulls_created: set[str] = set()

    issues_closed: set[str] = set()
    issues_commented: set[str] = set()
    issues_created: set[str] = set()

    pulls_reviewed: set[str] = set()

    issues_confirmed: list[str] = []
    issues_needing_user_info: list[str] = []
    issues_needing_developer_info: list[str] = []
    issues_fixed: list[str] = []
    issues_duplicated: list[str] = []
    issues_archived: list[str] = []

    @dataclass
    class Branch:
        # Name of the repository owning the branch (which can differ from the repository targeted by this branch!)
        repository_full_name: str
        commits: list[str]

    @dataclass
    class PullRequest:
        title_str: str

    @dataclass
    class Repository:
        name: str
        # Branches targeting this repository. Branch name is key.
        branches: dict[str, Branch] = field(default_factory=dict)
        # Pull requests targeting this repository. Key is repository of the branch and the branch name.
        prs: dict[tuple[str, str], PullRequest] = field(default_factory=dict)

    # Repositories containing any commit activity, identified by full name (e.g. "blender/blender").
    repositories: dict[str, Repository] = {}

    user_data: dict[str, Any] = gitea_user_get(username)

    for i in range(num_days):
        date_curr = start + datetime.timedelta(days=i)
        date_curr_str = date_curr.strftime("%Y-%m-%d")
        print_progress(f"Requesting activity of {date_curr_str}")
        for activity in gitea_json_activities_get(username, date_curr_str):
            op_type = activity["op_type"]
            if op_type == "close_issue":
                fullname = activity["repo"]["full_name"] + "/issues/" + activity["content"].split('|')[0]
                issues_closed.add(fullname)
            elif op_type == "comment_issue":
                fullname = activity["repo"]["full_name"] + "/issues/" + activity["content"].split('|')[0]
                issues_commented.add(fullname)
            elif op_type == "create_issue":
                fullname = activity["repo"]["full_name"] + "/issues/" + activity["content"].split('|')[0]
                issues_created.add(fullname)
            elif op_type == "merge_pull_request":
                fullname = activity["repo"]["full_name"] + "/pulls/" + activity["content"].split('|')[0]
                pulls_closed.add(fullname)
            elif op_type == "comment_pull":
                fullname = activity["repo"]["full_name"] + "/pulls/" + activity["content"].split('|')[0]
                pulls_commented.add(fullname)
            elif op_type == "create_pull_request":
                fullname = activity["repo"]["full_name"] + "/pulls/" + activity["content"].split('|')[0]
                pulls_created.add(fullname)
            elif op_type in {"approve_pull_request", "reject_pull_request"}:
                fullname = activity["repo"]["full_name"] + "/pulls/" + activity["content"].split('|')[0]
                pulls_reviewed.add(fullname)
            elif op_type == "commit_repo":
                if (
                        activity["content"] and
                        activity["repo"]["name"] != ".profile"
                ):
                    content_json = json.loads(activity["content"])
                    assert isinstance(content_json, dict)
                    repo = activity["repo"]
                    repo_fullname = repo["full_name"]
                    content_json_commits: list[dict[str, Any]] = content_json["Commits"]
                    for commit_json in content_json_commits:
                        # Skip commits that were not made by this user. Using email doesn't seem to
                        # be possible unfortunately.
                        if commit_json["AuthorName"] != user_data["full_name"]:
                            continue

                        title = commit_json["Message"].split('\n', 1)[0]

                        if title.startswith("Merge branch "):
                            continue

                        hash_value = commit_json["Sha1"]
                        if hash_length > 0:
                            hash_value = hash_value[:hash_length]

                        branch_name = activity["ref_name"].removeprefix("refs/heads/")
                        is_release_branch = re.match(r"^blender-v(?:\d+\.\d+)(?:\.\d+)?-release$", branch_name)

                        pr = None

                        # The PR workflow means branches and PRs are owned by a user's repository instead of the
                        # repository they are made for. For weekly reports it makes more sense to keep all branches and
                        # PRs related to a single repository together, regardless of who happens to own them.
                        #
                        # So the following adds branches and PRs to a "target" repository, not the owning one.
                        target_repo_json = repo.get("parent", repo)
                        target_repo_fullname = target_repo_json["full_name"] if target_repo_json else repo_fullname

                        # Substitute occurrences of "#\d+" with "repo#\d+"
                        title = re.sub(r"#(\d+)", rf"{target_repo_fullname}#\1", title)

                        if target_repo_fullname not in repositories:
                            repositories[target_repo_fullname] = Repository(target_repo_fullname)
                        target_repo = repositories[target_repo_fullname]

                        if branch_name not in target_repo.branches:
                            target_repo.branches[branch_name] = Branch(repo_fullname, [])
                            # If we see this branch for the first time, try to find a PR for it. Only catches PRs made
                            # against the default branch of the target repository.
                            if not is_release_branch and target_repo_json:
                                pr = gitea_json_pull_request_by_base_and_head_get(
                                    target_repo_fullname,
                                    target_repo_json["default_branch"],
                                    f"{repo_fullname}:{branch_name}",
                                )
                        branch = target_repo.branches[branch_name]

                        if pr:
                            pr_title = pr["title"]
                            pr_id = pr["number"]
                            target_repo.prs[(repo_fullname, branch_name)
                                            ] = PullRequest(f"{pr_title} ({target_repo_fullname}!{pr_id})")

                        branch.commits.append(f"{title} ({repo_fullname}@{hash_value})")

    date_end = date_curr
    len_total = len(issues_closed) + len(issues_commented) + len(pulls_commented)
    process = 0
    for issue in issues_commented:
        print_progress("[{:d}%] Checking issue {:s}".format(int(100 * (process / len_total)), issue))
        process += 1

        issue_events = gitea_json_issue_events_filter(
            issue,
            date_start=start,
            date_end=date_end,
            username=username,
            labels={
                "Status/Confirmed",
                "Status/Needs Information from User",
                "Status/Needs Info from Developers"
            }
        )

        for event in issue_events:
            label_name = event["label"]["name"]
            if label_name == "Status/Confirmed":
                issues_confirmed.append(issue)
            elif label_name == "Status/Needs Information from User":
                issues_needing_user_info.append(issue)
            elif label_name == "Status/Needs Info from Developers":
                issues_needing_developer_info.append(issue)

    for issue in issues_closed:
        print_progress("[{:d}%] Checking issue {:s}".format(int(100 * (process / len_total)), issue))
        process += 1

        issue_events = gitea_json_issue_events_filter(
            issue,
            date_start=start,
            date_end=date_end,
            username=username,
            event_type={"close", "commit_ref"},
            labels={"Status/Duplicate"},
        )

        for event in issue_events:
            event_type = event["type"]
            if event_type == "commit_ref":
                issues_fixed.append(issue)
            elif event_type == "label":
                issues_duplicated.append(issue)
            else:
                issues_archived.append(issue)

    for pull in pulls_commented:
        print_progress("[{:d}%] Checking pull {:s}".format(int(100 * (process / len_total)), pull))
        process += 1

        pull_events = gitea_json_issue_events_filter(
            pull.replace("pulls", "issues"),
            date_start=start,
            date_end=date_end,
            username=username,
            event_type={"comment"},
        )

        if pull_events:
            pull_data = gitea_json_issue_get_cached(pull)
            if pull_data["user"]["login"] != username:
                pulls_reviewed.add(pull)

    # Print triaging stats

    issues_involved = issues_closed | issues_commented | issues_created

    # Clear any progress.
    print_progress("")

    print("**Involved in {:d} reports:**".format(len(issues_involved)))
    print("* Confirmed: {:d}".format(len(issues_confirmed)))
    print("* Closed as Resolved: {:d}".format(len(issues_fixed)))
    print("* Closed as Archived: {:d}".format(len(issues_archived)))
    print("* Closed as Duplicate: {:d}".format(len(issues_duplicated)))
    print("* Needs Info from User: {:d}".format(len(issues_needing_user_info)))
    print("* Needs Info from Developers: {:d}".format(len(issues_needing_developer_info)))
    print("* Actions total: {:d}".format(len(issues_closed) + len(issues_commented) + len(issues_created)))
    print()

    # Print review stats
    def print_pulls(pulls: Iterable[str]) -> None:
        display_list = []

        for pull in pulls:
            pull_data = gitea_json_issue_get_cached(pull)
            owner, repo, _, number = pull.split('/')

            display_list.append({
                "title": pull_data["title"],
                "formatted_ref": f"{owner}/{repo}!{number}"
            })

        # Sort the list by the "title" key. Use .lower() to ensure case insensitiveness.
        display_list.sort(key=lambda x: x["title"].lower())

        for item in display_list:
            print(f"* {item['title']} ({item['formatted_ref']})")

    print("**Review: {:d}**".format(len(pulls_reviewed)))
    print_pulls(pulls_reviewed)
    print()

    # Print created diffs
    print("**Created Pull Requests: {:d}**".format(len(pulls_created)))
    print_pulls(pulls_created)
    print()

    nice_repo_names = {
        "blender/blender-developer-docs": "Developer Documentation",
        "blender/blender-manual": "Blender Manual",
    }

    def print_repo(repo: Repository, indent_level: int = 0) -> None:
        # Print main branch commits immediately, no need to add extra section.
        main_branch = repo.branches.get("main")
        if main_branch:
            for commit in main_branch.commits:
                print("{:s}* {:s}".format("  " * indent_level, commit))

        for branch_name, branch in repo.branches.items():
            # Main branch already printed above.
            if branch_name == "main":
                continue

            pr = repo.prs.get((branch.repository_full_name, branch_name))
            if pr:
                print("{:s}* {:s}".format("  " * indent_level, pr.title_str))
            else:
                print("{:s}* {:s}:{:s}".format("  " * indent_level, branch.repository_full_name, branch_name))

            for commit in branch.commits:
                print("  {:s}* {:s}".format("  " * indent_level, commit))

    # Print commits
    print("**Commits:**")
    # Print main branch commits from blender/blender first.
    blender_repo = repositories.get("blender/blender")
    if blender_repo:
        print_repo(blender_repo)

    for repo in repositories.values():
        # Blender repo already handled above.
        if repo.name == "blender/blender":
            continue

        # For some repositories we know a nicer name to display (e.g. "blender/blender-manual" -> "Blender Manual")
        nice_repo_name = nice_repo_names.get(repo.name, repo.name)
        print(f"* {nice_repo_name}:")
        print_repo(repo, indent_level=1)

    if verbose:
        # Debug

        def print_links(issues: Iterable[str]) -> None:
            for fullname in issues:
                print(f"https://projects.blender.org/{fullname}")

        print("Debug:")
        print(f"Activities from {start.isoformat()} to {date_end.isoformat()}:")
        print()
        print("Pull Requests Created:")
        print_links(pulls_created)
        print("Pull Requests Reviewed:")
        print_links(pulls_reviewed)
        print("Issues Confirmed:")
        print_links(issues_confirmed)
        print("Issues Closed as Resolved:")
        print_links(issues_fixed)
        print("Issues Closed as Archived:")
        print_links(issues_closed)
        print("Issues Closed as Duplicate:")
        print_links(issues_duplicated)
        print("Issues Needing Info from User:")
        print_links(issues_needing_user_info)
        print("Issues Needing Info from Developers:")
        print_links(issues_needing_developer_info)


def main() -> None:
    # ----------
    # Parse Args
    args = argparse_create().parse_args()
    username = args.username
    if not username:
        username = git_username_detect()
        if not username:
            return

    if args.date:
        num_days = 1  # Show only one day.
        start_date = args.date
        start_date_str = start_date.strftime('%B ') + str(start_date.day)

        print(f"## {start_date_str}\n")
    else:
        num_days = 7  # Show an entire week.
        # end_date = datetime.datetime(2020, 3, 14)
        end_date = datetime.datetime.now() - datetime.timedelta(weeks=(args.weeks_ago - 1))
        weekday = end_date.weekday()

        # Assuming I am lazy and making this at last moment or even later in worst case
        if weekday < 2:
            time_delta = 7 + weekday
            start_date = end_date - datetime.timedelta(days=time_delta, hours=end_date.hour)
            end_date -= datetime.timedelta(days=weekday, hours=end_date.hour)
        else:
            time_delta = weekday
            start_date = end_date - datetime.timedelta(days=time_delta, hours=end_date.hour)

        sunday = start_date + datetime.timedelta(days=6)

        # week = start_date.isocalendar()[1]
        start_date_str = start_date.strftime('%B ') + str(start_date.day)
        end_date_str = str(sunday.day) if start_date.month == sunday.month else sunday.strftime('%B ') + str(sunday.day)

        print(f"## {start_date_str} - {end_date_str}\n")

    report_personal_weekly_get(
        username,
        start_date,
        num_days,
        hash_length=args.hash_length,
        verbose=args.verbose,
    )


if __name__ == "__main__":
    main()

    # Wait for input to close window.
    if IS_ATTY:
        input()
