"""
This module contains asynchronous tasks for Roundabout using Celery.

Roundabout - git hosting for everyone <https://roundabout-host.com>
Copyright (C) 2023-2025 Roundabout developers <root@roundabout-host.com>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""

import common
import time
import os
import config
import email_send
import shutil
from celery import shared_task
from app import db, _
from misc_utils import *
from smtplib import SMTP
from celery.utils.log import get_task_logger
from sqlalchemy.orm import make_transient
from datetime import datetime
import httpx
import bs4 as bs


def _get_foreign_commit_owner(server, repo_route, sha):
    response = httpx.get(f"http://{server}/data-api/commit{repo_route}/{sha}", follow_redirects=True)   # repo_route is supposed to be /<owner>/<repo>, so the slash is already there
    if response.status_code == 200:
        soup = bs.BeautifulSoup(response.text, "lxml-xml")
        return soup.find("commit")["owner"] + "@" + server


def _parse_commit(directory, repo_route, sha, pusher, owner_name=None):
    from models import User, Repo, Commit
    commit_info = common.git_command(directory, b"", "show", "-s", "--format='%H%n%at%n%cn <%ce>%n%B'", sha).strip().decode()

    sha, time, identity, body = commit_info.split("\n", 3)

    if owner_name is None:
        owner_name = get_commit_identity(identity, pusher, db.session.get(Repo, repo_route)).username

    return Commit(sha, pusher, db.session.get(Repo, repo_route), time, body, identity, owner_name)


@shared_task(ignore_result=False)
def send_notification(user_notification_id):
    from models import UserNotification, Commit, Post, PullRequest
    user_notification = db.session.get(UserNotification, user_notification_id)
    user = user_notification.user
    notification = user_notification.notification

    if user.email:
        with (SMTP(config.MAIL_SERVER) as mail):
            match notification.data.get("type"):
                case "welcome":
                    message = email_send.make_multipart_message(
                            f"[system] Welcome, {user.username}",
                            config.NOTIFICATION_EMAIL,
                            user.email,
                            "welcome",
                            username=user.username
                    )
                case "commit":
                    commit = db.session.get(Commit, notification.data.get("commit"))
                    line_separator = "\n\n"        # hack so it works in older Pythons
                    newline = "\n"
                    message = email_send.make_multipart_message(
                            f"[commit in {notification.data.get('repo')}] {commit.message.partition(line_separator)[0].replace(newline, ' ')}",
                            config.NOTIFICATION_EMAIL,
                            user.email,
                            "commit",
                            username=user.username,
                            commit=commit,
                            url=("https://" if config.suggest_https else "http://") + config.BASE_DOMAIN + "/repo/" + notification.data.get("repo") + "/commit/" + notification.data.get("commit")
                    )
                case "post":
                    post = db.session.get(Post, notification.data.get("post"))
                    message = email_send.make_multipart_message(
                            f"[post in {notification.data.get('repo')}] {post.subject}",
                            config.NOTIFICATION_EMAIL,
                            user.email,
                            "forum",
                            username=user.username,
                            post=post,
                            url=("https://" if config.suggest_https else "http://") + config.BASE_DOMAIN + "/repo/" + notification.data.get("repo") + "/post/" + notification.data.get("post")
                    )
                case "pr":
                    pr = db.session.get(PullRequest, notification.data.get("pr"))
                    message = email_send.make_multipart_message(
                        f"[PR in {notification.data.get('repo')}] {pr.head_route}:{pr.head_branch} -> {pr.base_route}:{pr.base_branch}",
                        config.NOTIFICATION_EMAIL,
                        user.email,
                        "pr",
                        username=user.username,
                        pr=pr,
                        url=("https://" if config.suggest_https else "http://") + config.BASE_DOMAIN + notification.data.get("base") + "/prs/"
                    )

            mail.sendmail(config.NOTIFICATION_EMAIL, user.email, message)

    return 0  # notification sent successfully


@shared_task(ignore_result=False)
def merge_heads(head_route, head_branch, base_route, base_branch, pr_id, simulate=True, method="merge", username=None):
    from models import Repo, Commit, PullRequest, User
    server_repo_location = os.path.join(config.REPOS_PATH, base_route.lstrip("/"))
    pull_request = db.session.get(PullRequest, pr_id)
    if not os.path.isdir(server_repo_location):
        raise FileNotFoundError(f"Repo {server_repo_location} not found, cannot merge.")

    # Change to the user's identity.
    common.git_command(server_repo_location, b"", "config", "user.email", db.session.get(User, username).email or f"noreply@{config.BASE_DOMAIN}")
    common.git_command(server_repo_location, b"", "config", "user.name", username)

    if base_route == head_route:
        common.git_command(server_repo_location, b"", "checkout", f"{base_branch}")
        if simulate:
            if method == "merge":
                out, err, merge_exit = common.git_command(server_repo_location, b"", "merge", "--no-commit", "--no-ff", f"heads/{head_branch}",
                                                          return_err=True, return_exit=True)
                # Undo the merge.
                common.git_command(server_repo_location, b"", "merge", "--abort")
            elif method == "fast-forward":
                out, err, merge_exit = common.git_command(server_repo_location, b"", "merge-base", "--is-ancestor", f"heads/{base_branch}", f"heads/{head_branch}",
                                                          return_err=True, return_exit=True)
            elif method == "rebase":
                # To attempt a rebase dry run, switch to a detached head.
                common_ancestor = common.git_command(server_repo_location, b"", "merge-base",
                                                     f"heads/{base_branch}",
                                                     f"heads/{head_branch}").strip().decode()
                common.git_command(server_repo_location, b"", "checkout", "--detach", f"heads/{base_branch}")
                out, err, merge_exit = common.git_command(server_repo_location, b"", "rebase",
                                                          "--onto", f"heads/{head_branch}",
                                                          common_ancestor, return_err=True,
                                                          return_exit=True)
                # Undo the rebase.
                common.git_command(server_repo_location, b"", "rebase", "--abort")
        else:
            if method == "merge":
                out, err, merge_exit = common.git_command(server_repo_location, b"", "merge", "--no-ff", f"heads/{head_branch}",
                                                          return_err=True, return_exit=True)
            elif method == "fast-forward":
                out, err, merge_exit = common.git_command(server_repo_location, b"", "merge", "--ff-only", f"heads/{head_branch}",
                                                            return_err=True, return_exit=True)
            elif method == "rebase":
                old_head_commit = common.git_command(server_repo_location, b"", "rev-parse", f"heads/{head_branch}")
                out, err, merge_exit = common.git_command(server_repo_location, b"", "rebase", f"heads/{head_branch}", return_err=True, return_exit=True)

            # Add the merge commit.
            if method == "merge":
                last_commit = common.git_command(server_repo_location, b"", "log",
                                                 "--pretty=format:\"%H\"", "-n", "1").strip().decode()
                last_commit_info = common.git_command(server_repo_location, b"", "show", "-s",
                                                      "--format='%H%n%at%n%cn <%ce>%n%B'",
                                                      last_commit).strip().decode()

                if not db.session.get(Commit, f"{base_route}/{last_commit}"):
                    sha, time, identity, body = last_commit_info.split("\n", 3)
                    if not db.session.get(Commit, f"/{base_route}/{sha}"):
                        commit = Commit(sha, db.session.get(User, username),
                                        db.session.get(Repo, base_route), time, body, identity,
                                        db.session.get(User, username))
                        db.session.add(commit)
            elif method == "rebase":
                new_commits = common.git_command(server_repo_location, b"", "log", "--pretty=format:\"%H\"",
                                                f"{old_head_commit}..heads/{head_branch}", "--").decode().splitlines()

                # Copy the commit rows from the head repo to the base repo.
                for commit in new_commits:
                    commit_data = _parse_commit(server_repo_location, base_route, commit, db.session.get(User, username))
                    db.session.add(commit_data)

            # Consider the PR merged.
            pull_request.state = 1

            for resolves in pull_request.resolves:
                resolves.post.state = 0

            db.session.commit()

        new_commits = common.git_command(server_repo_location, b"", "log", "--oneline", f"heads/{base_branch}..heads/{head_branch}")

        # Undo the identity change.
        common.git_command(server_repo_location, b"", "config", "--unset", "user.email")
        common.git_command(server_repo_location, b"", "config", "--unset", "user.name")

        return "merge_simulator" if simulate else "merge", out, err, head_route, head_branch, base_route, base_branch, merge_exit, new_commits

    # Otherwise, we need to fetch the head repo.
    head_user = head_route.rpartition("/")[0].removeprefix("/")
    head_username = head_user.partition("@")[0] if "@" in head_user else head_user
    head_name = head_route.rpartition("/")[2]
    head_domain = head_user.partition("@")[2] if "@" in head_user else config.BASE_DOMAIN + (f":{config.port}" if config.port not in {80, 443} else "")
    remote_url = ("https://" if config.suggest_https else "http://") + head_domain + "/git/" + head_username + "/" + head_name

    out, err = b"", b""
    part_out, part_err = common.git_command(server_repo_location, b"", "remote", "add", "NEW", remote_url, return_err=True)
    out += part_out
    err += part_err
    part_out, part_err = common.git_command(server_repo_location, b"", "remote", "update", return_err=True)
    out += part_out
    err += part_err
    part_out, part_err = common.git_command(server_repo_location, b"", "fetch", "NEW", f"refs/heads/{head_branch}:refs/remotes/NEW/{head_branch}", return_err=True)
    out += part_out
    err += part_err
    part_out, part_err = common.git_command(server_repo_location, b"", "checkout", f"{base_branch}", return_err=True)
    out += part_out
    err += part_err

    if simulate:
        if method == "merge":
            part_out, part_err, merge_exit = common.git_command(server_repo_location, b"", "merge", "--allow-unrelated-histories",
                                                                "--no-commit", "--no-ff", f"NEW/{head_branch}", return_err=True, return_exit=True)
            # Undo the merge.
            common.git_command(server_repo_location, b"", "merge", "--abort")
        elif method == "fast-forward":
            part_out, part_err, merge_exit = common.git_command(server_repo_location, b"", "merge-base", "--is-ancestor", f"heads/{base_branch}", f"NEW/{head_branch}",
                                                                return_err=True, return_exit=True)
        elif method == "rebase":
            # To attempt a rebase dry run, switch to a detached head.
            common_ancestor = common.git_command(server_repo_location, b"", "merge-base",
                                                 f"heads/{base_branch}",
                                                 f"NEW/{head_branch}").strip().decode()
            common.git_command(server_repo_location, b"", "checkout", "--detach", f"heads/{base_branch}")
            part_out, part_err, merge_exit = common.git_command(server_repo_location, b"", "rebase",
                                                                "--onto", f"NEW/{head_branch}",
                                                                common_ancestor, return_err=True,
                                                                return_exit=True)
            # Undo the rebase.
            common.git_command(server_repo_location, b"", "rebase", "--abort")
    else:
        if method == "merge":
            new_commits = common.git_command(server_repo_location, b"", "log", "--pretty=format:\"%H\"", f"heads/{base_branch}..NEW/{head_branch}", "--").decode().splitlines()
            part_out, part_err, merge_exit = common.git_command(server_repo_location, b"", "merge", "--allow-unrelated-histories",
                                                                "--no-ff", f"NEW/{head_branch}", return_err=True, return_exit=True)
        elif method == "fast-forward":
            new_commits = common.git_command(server_repo_location, b"", "log", "--pretty=format:\"%H\"", f"heads/{base_branch}..NEW/{head_branch}", "--").decode().splitlines()
            part_out, part_err, merge_exit = common.git_command(server_repo_location, b"", "merge", "--ff-only", f"NEW/{head_branch}", return_err=True, return_exit=True)
        elif method == "rebase":
            old_head_commit = common.git_command(server_repo_location, b"", "rev-parse", f"heads/{head_branch}")
            part_out, part_err, merge_exit = common.git_command(server_repo_location, b"", "rebase", f"NEW/{head_branch}", return_err=True, return_exit=True)
            new_commits = common.git_command(server_repo_location, b"", "log", "--pretty=format:\"%H\"", f"{old_head_commit}..heads/{head_branch}", "--").decode().splitlines()

        # Copy the commit rows from the head repo to the base repo.
        if "@" not in head_user:
            # Same server, no API call needed.
            for commit in new_commits:
                if method != "rebase":
                    commit_data = Commit.query.filter_by(repo_name=head_route, sha=commit).first()
                    db.session.expunge(commit_data)
                    make_transient(commit_data)

                    commit_data.repo_name = base_route
                    commit_data.identifier = f"{base_route}/{commit_data.sha}"
                    commit_data.receive_date = datetime.now()
                    commit_data.pusher = db.session.get(User, username)
                else:
                    commit_data = _parse_commit(server_repo_location, base_route, commit, db.session.get(User, username), head_username)
                db.session.add(commit_data)
        else:
            # Different server, use the API.
            for commit in new_commits:
                if method != "rebase":
                    commit_data = _parse_commit(server_repo_location, base_route, commit, db.session.get(User, username), _get_foreign_commit_owner(head_domain, head_route, commit))
                else:
                    commit_data = _parse_commit(server_repo_location, base_route, commit, db.session.get(User, username), head_username)
                db.session.add(commit_data)

        # Add the merge commit.
        if method == "merge":
            last_commit = common.git_command(server_repo_location, b"", "log", "--pretty=format:\"%H\"", "-n", "1").strip().decode()
            last_commit_info = common.git_command(server_repo_location, b"", "show", "-s", "--format='%H%n%at%n%cn <%ce>%n%B'", last_commit).strip().decode().split("\n")
            err += part_err

            if not db.session.get(Commit, f"{base_route}/{last_commit}"):
                sha, time, identity, body = last_commit_info.split("\n", 3)
                if not db.session.get(Commit, f"/{base_route}/{sha}"):
                    commit = Commit(sha, db.session.get(User, username), db.session.get(Repo, base_route), time, body, identity, db.session.get(User, username))
                    db.session.add(commit)

        # Consider the PR merged.
        pull_request.state = 1

        for resolves in pull_request.resolves:
            resolves.post.state = 0

        db.session.commit()

    out += part_out
    err += part_err
    part_out, part_err = common.git_command(server_repo_location, b"", "remote", "prune", "NEW", return_err=True)
    out += part_out
    err += part_err
    part_out, part_err = common.git_command(server_repo_location, b"", "remote", "rm", "NEW", return_err=True)
    out += part_out
    err += part_err
    part_out, part_err = common.git_command(server_repo_location, b"", "fetch", "--prune", return_err=True)
    out += part_out
    err += part_err

    # Undo the identity change.
    common.git_command(server_repo_location, b"", "config", "--unset", "user.email")
    common.git_command(server_repo_location, b"", "config", "--unset", "user.name")

    return "merge_simulator" if simulate else "merge", out, err, head_route, head_branch, base_route, base_branch, merge_exit, new_commits


@shared_task(ignore_result=False)
def copy_site(route):
    from models import Repo
    repo = db.session.get(Repo, route)
    server_repo_location = os.path.join(config.REPOS_PATH, route.lstrip("/"))
    subdomain = repo.owner.username
    subpath = repo.name if repo.has_site != 2 else ""
    site_location = os.path.join(config.SITE_PATH, subdomain, subpath)
    # Get the branch to be used for the site; if it somehow doesn't exist, use the default branch.
    branch = repo.site_branch or repo.default_branch
    # Make a shallow clone of the repo; this prevents getting the full git database when it's not needed.
    if os.path.isdir(site_location):
        # Delete the old site.
        shutil.rmtree(site_location)

    common.git_command(config.SITE_PATH, b"", "clone", "--depth=1", f"--branch={branch}", os.path.join(os.getcwd(), server_repo_location), os.path.join(subdomain, subpath))


@shared_task(ignore_result=False)
def delete_site(route):
    from models import Repo
    repo = db.session.get(Repo, route)
    subdomain = repo.owner.username
    subpath = repo.name if repo.has_site != 2 else "."
    site_location = os.path.join(config.SITE_PATH, subdomain, subpath)
    if os.path.isdir(site_location):
        shutil.rmtree(site_location)

    # Redo the primary site.
    primary_site = Repo.query.filter_by(owner=repo.owner, has_site=2).first()
    if primary_site:
        copy_site(primary_site.route)


@shared_task(ignore_result=False)
def request_email_change(username, email):
    from models import User, EmailChangeRequest
    user = db.session.get(User, username)

    request = EmailChangeRequest(user, email)

    db.session.add(request)
    db.session.commit()

    message = email_send.make_multipart_message(
        "Email change request for {username}".format(username=username),
        config.NOTIFICATION_EMAIL,
        email,
        "email-change",
        username=username,
        code=request.code,
        new_email=email,
        url=("https://" if config.suggest_https else "http://") + config.BASE_DOMAIN + "/settings/confirm-email/" + request.code
    )

    with (SMTP(config.MAIL_SERVER) as mail):
        mail.sendmail(config.NOTIFICATION_EMAIL, email, message)
