#!/usr/pkg/bin/python3.12

# Zulip mirror of Codebase HQ activity
# The "zulip_codebase_mirror" script is run continuously, possibly on a work
# computer or preferably on a server.
#
# When restarted, it will attempt to pick up where it left off.
#
# python-dateutil is a dependency for this script.

import logging
import os
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path

import pytz
import requests

try:
    import dateutil.parser
except ImportError as e:
    print(e, file=sys.stderr)
    print("Please install the python-dateutil package.", file=sys.stderr)
    sys.exit(1)

sys.path.insert(0, os.path.dirname(__file__))
import zulip_codebase_config as config

VERSION = "0.9"

if config.ZULIP_API_PATH is not None:
    sys.path.append(config.ZULIP_API_PATH)
from typing import Any, Dict, List, Optional

import zulip

client = zulip.Client(
    email=config.ZULIP_USER,
    site=config.ZULIP_SITE,
    api_key=config.ZULIP_API_KEY,
    client="ZulipCodebase/" + VERSION,
)
user_agent = "Codebase To Zulip Mirroring script (zulip-devel@googlegroups.com)"

# find some form of JSON loader/dumper, with a preference order for speed.
json_implementations = ["ujson", "cjson", "simplejson", "json"]

while len(json_implementations):
    try:
        json = __import__(json_implementations.pop(0))
        break
    except ImportError:
        continue


def make_api_call(path: str) -> Optional[List[Dict[str, Any]]]:
    response = requests.get(
        f"https://api3.codebasehq.com/{path}",
        auth=(config.CODEBASE_API_USERNAME, config.CODEBASE_API_KEY),
        params={"raw": "True"},
        headers={
            "User-Agent": user_agent,
            "Content-Type": "application/json",
            "Accept": "application/json",
        },
    )
    if response.status_code == 200:
        return json.loads(response.text)

    if response.status_code >= 500:
        logging.error(str(response.status_code))
        return None
    if response.status_code == 403:
        logging.error("Bad authorization from Codebase. Please check your credentials")
        sys.exit(-1)
    else:
        logging.warning(
            "Found non-success response status code: %s %s", response.status_code, response.text
        )
        return None


def make_url(path: str) -> str:
    return f"{config.CODEBASE_ROOT_URL}/{path}"


def handle_event(event: Dict[str, Any]) -> None:
    event = event["event"]
    event_type = event["type"]
    actor_name = event["actor_name"]

    raw_props = event.get("raw_properties", {})

    project_link = raw_props.get("project_permalink")

    subject = None
    content = None
    if event_type == "repository_creation":
        stream = config.ZULIP_COMMITS_STREAM_NAME

        project_name = raw_props.get("name")
        project_repo_type = raw_props.get("scm_type")

        url = make_url(f"projects/{project_link}")
        scm = f"of type {project_repo_type}" if project_repo_type else ""

        subject = f"Repository {project_name} Created"
        content = f"{actor_name} created a new repository {scm} [{project_name}]({url})"
    elif event_type == "push":
        stream = config.ZULIP_COMMITS_STREAM_NAME

        num_commits = raw_props.get("commits_count")
        branch = raw_props.get("ref_name")
        project = raw_props.get("project_name")
        repo_link = raw_props.get("repository_permalink")
        deleted_ref = raw_props.get("deleted_ref")
        new_ref = raw_props.get("new_ref")

        subject = f"Push to {branch} on {project}"

        if deleted_ref:
            content = f"{actor_name} deleted branch {branch} from {project}"
        else:
            if new_ref:
                branch = f"new branch {branch}"
            content = "{} pushed {} commit(s) to {} in project {}:\n\n".format(
                actor_name,
                num_commits,
                branch,
                project,
            )
            for commit in raw_props.get("commits"):
                ref = commit.get("ref")
                url = make_url(f"projects/{project_link}/repositories/{repo_link}/commit/{ref}")
                message = commit.get("message")
                content += f"* [{ref}]({url}): {message}\n"
    elif event_type == "ticketing_ticket":
        stream = config.ZULIP_TICKETS_STREAM_NAME

        num = raw_props.get("number")
        name = raw_props.get("subject")
        assignee = raw_props.get("assignee")
        priority = raw_props.get("priority")
        url = make_url(f"projects/{project_link}/tickets/{num}")

        if assignee is None:
            assignee = "no one"
        subject = f"#{num}: {name}"
        content = f"""{actor_name} created a new ticket [#{num}]({url}) priority **{priority}** assigned to {assignee}:\n\n~~~ quote\n {name}"""
    elif event_type == "ticketing_note":
        stream = config.ZULIP_TICKETS_STREAM_NAME

        num = raw_props.get("number")
        name = raw_props.get("subject")
        body = raw_props.get("content")
        changes = raw_props.get("changes")

        url = make_url(f"projects/{project_link}/tickets/{num}")
        subject = f"#{num}: {name}"

        content = ""
        if body is not None and len(body) > 0:
            content = "{} added a comment to ticket [#{}]({}):\n\n~~~ quote\n{}\n\n".format(
                actor_name,
                num,
                url,
                body,
            )

        if "status_id" in changes:
            status_change = changes.get("status_id")
            content += f"Status changed from **{status_change[0]}** to **{status_change[1]}**\n\n"
    elif event_type == "ticketing_milestone":
        stream = config.ZULIP_TICKETS_STREAM_NAME

        name = raw_props.get("name")
        identifier = raw_props.get("identifier")
        url = make_url(f"projects/{project_link}/milestone/{identifier}")

        subject = name
        content = f"{actor_name} created a new milestone [{name}]({url})"
    elif event_type == "comment":
        stream = config.ZULIP_COMMITS_STREAM_NAME

        comment = raw_props.get("content")
        commit = raw_props.get("commit_ref")

        # If there's a commit id, it's a comment to a commit
        if commit:
            repo_link = raw_props.get("repository_permalink")

            url = make_url(f"projects/{project_link}/repositories/{repo_link}/commit/{commit}")

            subject = f"{actor_name} commented on {commit}"
            content = f"{actor_name} commented on [{commit}]({url}):\n\n~~~ quote\n{comment}"
        else:
            # Otherwise, this is a Discussion item, and handle it
            subj = raw_props.get("subject")
            category = raw_props.get("category")
            comment_content = raw_props.get("content")

            subject = f"Discussion: {subj}"

            if category:
                format_str = "%s started a new discussion in %s:\n\n~~~ quote\n%s\n~~~"
                content = format_str % (actor_name, category, comment_content)
            else:
                content = f"{actor_name} posted:\n\n~~~ quote\n{comment_content}\n~~~"

    elif event_type == "deployment":
        stream = config.ZULIP_COMMITS_STREAM_NAME

        start_ref = raw_props.get("start_ref")
        end_ref = raw_props.get("end_ref")
        environment = raw_props.get("environment")
        servers = raw_props.get("servers")
        repo_link = raw_props.get("repository_permalink")

        start_ref_url = make_url(
            f"projects/{project_link}/repositories/{repo_link}/commit/{start_ref}"
        )
        end_ref_url = make_url(f"projects/{project_link}/repositories/{repo_link}/commit/{end_ref}")
        between_url = make_url(
            f"projects/{project_link}/repositories/{repo_link}/compare/{start_ref}...{end_ref}"
        )

        subject = f"Deployment to {environment}"

        content = "{} deployed [{}]({}) [through]({}) [{}]({}) to the **{}** environment.".format(
            actor_name,
            start_ref,
            start_ref_url,
            between_url,
            end_ref,
            end_ref_url,
            environment,
        )
        if servers is not None:
            content += "\n\nServers deployed to: %s" % (
                ", ".join(f"`{server}`" for server in servers)
            )

    elif event_type == "named_tree":
        # Docs say named_tree type used for new/deleting branches and tags,
        # but experimental testing showed that they were all sent as 'push' events
        pass
    elif event_type == "wiki_page":
        logging.warning("Wiki page notifications not yet implemented")
    elif event_type in ("sprint_creation", "sprint_ended"):
        logging.warning("Sprint notifications not yet implemented")
    else:
        logging.info("Unknown event type %s, ignoring!", event_type)

    if subject and content:
        if len(subject) > 60:
            subject = subject[:57].rstrip() + "..."

        res = client.send_message(
            {"type": "stream", "to": stream, "subject": subject, "content": content}
        )
        if res["result"] == "success":
            logging.info("Successfully sent Zulip with id: %s", res["id"])
        else:
            logging.warning("Failed to send Zulip: %s %s", res["result"], res["msg"])


# the main run loop for this mirror script
def run_mirror() -> None:
    # we should have the right (write) permissions on the resume file, as seen
    # in check_permissions, but it may still be empty or corrupted
    def default_since() -> datetime:
        return datetime.now(tz=pytz.utc) - timedelta(hours=config.CODEBASE_INITIAL_HISTORY_HOURS)

    try:
        with open(config.RESUME_FILE) as f:
            timestamp = f.read()
        if timestamp == "":
            since = default_since()
        else:
            since = datetime.fromtimestamp(float(timestamp), tz=pytz.utc)
    except (ValueError, OSError):
        logging.warning("Could not open resume file", exc_info=True)
        since = default_since()

    try:
        sleep_interval = 1
        while True:
            events = make_api_call("activity")
            if events is not None:
                sleep_interval = 1
                for event in events[::-1]:
                    timestamp = event.get("event", {}).get("timestamp", "")
                    event_date = dateutil.parser.parse(timestamp)
                    if event_date > since:
                        handle_event(event)
                        since = event_date
            elif sleep_interval < 22:
                # back off a bit
                sleep_interval += 4
            time.sleep(sleep_interval)

    except KeyboardInterrupt:
        Path(config.RESUME_FILE).write_text(since.strftime("%s"))
        logging.info("Shutting down Codebase mirror")


# void function that checks the permissions of the files this script needs.
def check_permissions() -> None:
    # check that the log file can be written
    if config.LOG_FILE:
        try:
            with open(config.LOG_FILE, "w"):
                pass
        except OSError as e:
            sys.stderr.write("Could not open up log for writing:")
            sys.stderr.write(str(e))
    # check that the resume file can be written (this creates if it doesn't exist)
    try:
        with open(config.RESUME_FILE, "a+"):
            pass
    except OSError as e:
        sys.stderr.write(f"Could not open up the file {config.RESUME_FILE} for reading and writing")
        sys.stderr.write(str(e))


if __name__ == "__main__":
    assert isinstance(config.RESUME_FILE, str), "RESUME_FILE path not given; refusing to continue"
    check_permissions()
    if config.LOG_FILE:
        logging.basicConfig(filename=config.LOG_FILE, level=logging.WARNING)
    else:
        logging.basicConfig(level=logging.WARNING)
    run_mirror()
