#!/usr/bin/python3
#
# /// script
# dependencies = [
#   "click",
#   "jinja2",
#   "jira",
#   "ruamel.yaml",
# ]
# ///

"""
Sync a Jira sprint with GitHub sprint items.
"""

import os
import re
import sys
from typing import IO, Any, Optional, cast

import click
import jinja2
from jira import JIRA
from jira.resources import Issue
from ruamel.yaml import YAML

from common import Item  # isort: skip

JIRA_SERVER = "https://redhat.atlassian.net/"
JIRA_PROJECT = "TMT"
JIRA_BOARD_ID = 1411


class SyncItem:
    """
    Represents a sync decision for a single item.
    """

    TEMPLATE = jinja2.Template(
        source="""\
  {{ key      }}   {{ summary }}
  {{ decision }}   {{ url }}
{% if duplicates %}\
             duplicates found: {{ duplicates | join(", ") }}
{% endif %}"""
    )

    COLORS = {
        "keep": "blue",
        "add ": "green",
        "drop": "red",
        "lost": "red",
        "miss": "yellow",
    }

    def __init__(
        self,
        key: str,
        decision: str,
        summary: str,
        url: Optional[str] = None,
        duplicates: Optional[list[str]] = None,
    ) -> None:
        self.key = key
        self.decision = decision
        self.summary = re.sub(r'^\[teemtee/[^\]]*\]\s*', '', summary)
        self.url = url
        self.duplicates = duplicates

    def show(self) -> None:
        """
        Render the item, color decisions
        """

        click.echo(
            self.TEMPLATE.render(
                key=f"{self.key:<8}",
                decision=click.style(f"{self.decision:<8}", fg=self.COLORS.get(self.decision)),
                summary=self.summary,
                url=self.url or "Upstream issue not found.",
                duplicates=self.duplicates,
            )
        )


def connect_to_jira() -> JIRA:
    """
    Connect to Jira using environment variables for authentication.

    :returns: an authenticated :py:class:`JIRA` client.
    """

    server = os.environ.get("JIRA_SERVER", JIRA_SERVER)
    email = os.environ.get("JIRA_EMAIL")
    token = os.environ.get("JIRA_TOKEN")

    if not email:
        click.echo("Error: JIRA_EMAIL environment variable is not set.", err=True)
        sys.exit(1)

    if not token:
        click.echo("Error: JIRA_TOKEN environment variable is not set.", err=True)
        sys.exit(1)

    return JIRA(server=server, basic_auth=(email, token))


def fetch_jira_items(jira: JIRA, sprint_name: str) -> list[Issue]:
    """
    Fetch all items from the given sprint.

    :param jira: authenticated Jira client.
    :param sprint_name: name of the sprint.
    :returns: list of Jira items
    """

    query = f'sprint = "{sprint_name}" ORDER BY key ASC'
    items = jira.search_issues(query, maxResults=False, json_result=False)
    assert isinstance(items, list)
    click.echo(f"Fetched {len(items)} items.\n")

    return items


def load_github_items(source: IO[str]) -> list[Item]:
    """
    Load GitHub sprint items in sprint-overview --yaml format.

    :param source: a file-like object to read YAML from.
    :returns: a list of sprint items
    """

    data = YAML().load(source)

    if not isinstance(data, list):
        click.echo(f"Invalid input data:\n{data}", err=True)
        sys.exit(1)

    return [Item(**item) for item in cast(list[dict[str, Any]], data)]


def get_upstream_url(jira: JIRA, issue: Issue) -> Optional[str]:
    """
    Get the "Upstream issue" remote link URL for a Jira issue.

    :param jira: authenticated Jira client.
    :param issue: a Jira issue object.
    :returns: the upstream URL, or ``None`` if not found.
    """

    for link in jira.remote_links(issue):
        if link.object.title == "Upstream issue":
            return link.object.url

    return None


def find_sprint_id(jira: JIRA, sprint_name: str) -> int:
    """
    Find the sprint ID by name on the TMT board.

    :param jira: authenticated Jira client.
    :param sprint_name: name of the sprint.
    :returns: the sprint ID, or gives an error if not found.
    """

    for sprint in jira.sprints(JIRA_BOARD_ID):
        if sprint.name == sprint_name:
            return sprint.id

    click.echo(f"Sprint '{sprint_name}' not found.", err=True)
    sys.exit(1)


def find_jira_issues(
    jira: JIRA,
    github_item: Item,
) -> list[Issue]:
    """
    Find Jira issues that have a remote link matching the given GitHub item.

    First searches by remote link URL, then falls back to title search.

    :param jira: authenticated Jira client.
    :param github_item: the GitHub :py:class:`Item` to search for.
    :returns: a list of matching Jira issues sorted by key (lowest first).
    """

    def sort_by_key(results: list[Issue]) -> list[Issue]:
        return sorted(results, key=lambda result: int(result.key.split("-")[-1]))

    # Search by remote link URL (this works only if already indexed, and
    # rebuilding the index can take several days when restarted again)
    query = f'project = "{JIRA_PROJECT}" AND remoteLinkUrl in ("{github_item.url}")'
    results = jira.search_issues(query, maxResults=False, json_result=False)
    assert isinstance(results, list)
    if results:
        return sort_by_key(results)

    # Fall back to title search and verify via remote links
    title = github_item.title.replace('\\', '\\\\').replace("'", "\\'").replace('"', '')
    query = f'project = "{JIRA_PROJECT}" AND summary ~ "{title}"'
    candidates = jira.search_issues(query, maxResults=False, json_result=False)
    assert isinstance(candidates, list)

    matched = [
        candidate
        for candidate in candidates
        if get_upstream_url(jira, candidate) == github_item.url
    ]
    return sort_by_key(matched)


def sync_sprint(
    jira: JIRA,
    sprint_id: int,
    jira_items: list[Issue],
    github_items: list[Item],
    dry: bool = False,
) -> None:
    """
    Sync Jira sprint with GitHub sprint items.

    Removes Jira issues not present in the GitHub sprint and adds
    missing GitHub items into the Jira sprint.

    :param jira: authenticated Jira client.
    :param sprint_id: ID of the sprint.
    :param jira_items: list of Jira issues currently in the sprint.
    :param github_items: list of GitHub project sprint items.
    :param dry: if ``True``, only show what would be done without
        making any changes.
    """

    github_items_by_url = {item.url: item for item in github_items}
    urls_to_process = set(github_items_by_url.keys())

    items_to_drop: list[str] = []
    items_to_add: list[str] = []

    # Step 1: Check existing Jira items — keep or remove
    for jira_item in jira_items:
        url = get_upstream_url(jira, jira_item)

        if not url:
            SyncItem(jira_item.key, "lost", jira_item.fields.summary).show()
        elif url in urls_to_process:
            urls_to_process.remove(url)
            SyncItem(jira_item.key, "keep", jira_item.fields.summary, url).show()
        else:
            SyncItem(jira_item.key, "drop", jira_item.fields.summary, url).show()
            items_to_drop.append(jira_item.key)

    # Step 2: Find missing GitHub items
    for url in sorted(urls_to_process):
        github_item = github_items_by_url[url]
        jira_issues = find_jira_issues(jira, github_item)

        if jira_issues:
            jira_item = jira_issues[0]
            duplicates = [issue.key for issue in jira_issues[1:]] or None
            SyncItem(jira_item.key, "add ", jira_item.fields.summary, url, duplicates).show()
            items_to_add.append(jira_item.key)
        else:
            SyncItem("???", "miss", github_item.title, url).show()

    # Step 3: Apply changes
    if not dry:
        if items_to_drop:
            jira.move_to_backlog(items_to_drop)
        if items_to_add:
            jira.add_issues_to_sprint(sprint_id, items_to_add)


@click.command()
@click.option(
    "--sprint",
    metavar="NAME",
    required=True,
    help="Name of the sprint, e.g. 'TMT Sprint 11'.",
)
@click.option(
    "--dry",
    is_flag=True,
    default=False,
    help="Only show what would be done without making any changes.",
)
@click.argument("yaml_file", default="-", type=click.File())
def main(sprint: str, dry: bool, yaml_file: IO[str]) -> None:
    """
    Sync a Jira sprint items with GitHub sprint items.

    Reads GitHub sprint items in YAML format (from sprint-overview --yaml)
    from YAML_FILE or stdin and synchronizes them with the Jira sprint.
    Removed items are dropped, new items are added, no status changes
    are performed.

    Example usage:

    \b
        ./sprint-overview --sprint 'Sprint 11' --yaml | ./sprint-sync --sprint 'Sprint 11'

    Authentication is configured via environment variables:

    \b
        JIRA_SERVER ... Jira server URL, https://redhat.atlassian.net/ by default
        JIRA_EMAIL .... User email for authentication
        JIRA_TOKEN .... Personal access token
    """

    click.echo(f"Syncing sprint: {sprint}{' (dry mode)' if dry else ''}")

    # Sprint names in Jira are prefixed with 'TMT'
    sprint_name = f"TMT {sprint}"

    jira = connect_to_jira()

    sprint_id = find_sprint_id(jira, sprint_name)
    github_items = load_github_items(yaml_file)
    jira_items = fetch_jira_items(jira, sprint_name)
    sync_sprint(jira, sprint_id, jira_items, github_items, dry=dry)


if __name__ == "__main__":
    main()
