import os
import subprocess
import sys
import time

def run_command(command):
    """
    Executes the specified shell command, streaming and collecting its output in real-time.
    If the command exits with a non-zero status, a subprocess.CalledProcessError is raised,
    including the exit code, the executed command, and the full output (as bytes) for debugging purposes.
    """
    process = None
    try:
        process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        output = []

        for line in iter(process.stdout.readline, b''):
            decoded_line = line.decode()
            output.append(decoded_line)
            sys.stdout.write(decoded_line)

        return_code = process.wait()
        if return_code:
            full_output = ''.join(output)
            raise subprocess.CalledProcessError(return_code, command, output=full_output.encode())
    finally:
        if process and process.stdout:
            process.stdout.close()

def git_pull():
    """
    Checks whether the Git repository in the specified directory is up to date and performs a git pull if necessary.
    
    Raises:
        Exception: If retrieving the local or remote git revision fails because the command returns a non-zero exit code.
    """
    print("Checking if the git repository is up to date.")

    # Run 'git rev-parse @' and check its exit code explicitly.
    local_proc = subprocess.run("git rev-parse @", shell=True, capture_output=True)
    if local_proc.returncode != 0:
        error_msg = local_proc.stderr.decode().strip() or "Unknown error while retrieving local revision."
        raise Exception(f"Failed to retrieve local git revision: {error_msg}")
    local = local_proc.stdout.decode().strip()

    # Run 'git rev-parse @{u}' and check its exit code explicitly.
    remote_proc = subprocess.run("git rev-parse @{u}", shell=True, capture_output=True)
    if remote_proc.returncode != 0:
        error_msg = remote_proc.stderr.decode().strip() or "Unknown error while retrieving remote revision."
        raise Exception(f"Failed to retrieve remote git revision: {error_msg}")
    remote = remote_proc.stdout.decode().strip()

    if local != remote:
        print("Repository is not up to date. Performing git pull.")
        run_command("git pull")
        return True

    print("Repository is already up to date.")
    return False

{% raw %}        
def get_image_digests(directory):
    """
    Retrieves the image digests for all images in the specified Docker Compose project.
    """
    compose_project = os.path.basename(directory)
    try:
        images_output = subprocess.check_output(
            f'docker images --format "{{{{.Repository}}}}:{{{{.Tag}}}}@{{{{.Digest}}}}" | grep {compose_project}', 
            shell=True
        ).decode().strip()
        return dict(line.split('@') for line in images_output.splitlines() if line)
    except subprocess.CalledProcessError as e:
        if e.returncode == 1:  # grep no match found
            return {}
        else:
            raise  # Other errors are still raised
{% endraw %}

def is_any_service_up():
    """
    Checks if any Docker services are currently running.
    """
    process = subprocess.Popen("docker-compose ps -q", shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    output, _ = process.communicate()
    service_ids = output.decode().strip().splitlines()
    return bool(service_ids)

def pull_docker_images():
    """
    Pulls the latest Docker images for the project.
    """
    print("Pulling docker images.")
    try:
        run_command("docker-compose pull")
    except subprocess.CalledProcessError as e:
        if "pull access denied" in e.output.decode() or "must be built from source" in e.output.decode():
            print("Need to build the image from source.")
            return True
        else:
            print("Failed to pull images with unexpected error.")
            raise
    return False

def update_docker(directory):
    """
    Checks for updates to Docker images and rebuilds containers if necessary.
    """
    print(f"Checking for updates to Docker images in {directory}.")        
    before_digests = get_image_digests(directory)
    need_to_build = pull_docker_images()
    after_digests = get_image_digests(directory)
    if before_digests != after_digests:
        print("Changes detected in image digests. Rebuilding containers.")
        need_to_build = True
        
    if need_to_build:
        run_command("docker-compose build")
        start_docker(directory)
    else:
        print("Docker images are up to date. No rebuild necessary.")

def update_discourse(directory):
    """
    Updates Discourse by running the rebuild command on the launcher script.
    """
    docker_repository_directory   = os.path.join(directory, "services", "{{applications.discourse.repository}}")
    print(f"Using path {docker_repository_directory } to pull discourse repository.")
    os.chdir(docker_repository_directory )
    if git_pull():
        print("Start Discourse update procedure.")
        update_procedure("docker stop {{applications.discourse.container}}")
        update_procedure("docker rm {{applications.discourse.container}}")
        try:
            update_procedure("docker network connect {{applications.discourse.network}} central-postgres")
        except subprocess.CalledProcessError as e:
            error_message = e.output.decode()
            if "already exists" in error_message or "is already connected" in error_message:
                print("Network connection already exists. Skipping...")
            else:
                raise
        update_procedure("./launcher rebuild {{applications.discourse.container}}")
    else:
        print("Discourse update skipped. No changes in git repository.")

def update_mastodon():
    """
    Runs the database migration for Mastodon to ensure all required tables are up to date.
    """
    print("Starting Mastodon database migration.")
    run_command("docker compose exec -T web bash -c 'RAILS_ENV=production bin/rails db:migrate'")
    print("Mastodon database migration complete.")

def upgrade_listmonk():
    """
    Runs the upgrade for Listmonk
    """
    print("Starting Listmonk upgrade.")
    run_command('echo "y" | docker compose run -T application ./listmonk --upgrade')
    print("Upgrade complete.")

def update_nextcloud():
    """
    Performs the necessary Nextcloud update procedures, including maintenance and app updates.
    """
    print("Start Nextcloud upgrade procedure.")
    update_procedure("docker-compose exec -T -u www-data application /var/www/html/occ upgrade")
    print("Start Nextcloud repairing procedure.")
    update_procedure("docker-compose exec -T -u www-data application /var/www/html/occ maintenance:repair --include-expensive")
    print("Start Nextcloud update procedure.")
    update_procedure("docker-compose exec -T -u www-data application /var/www/html/occ app:update --all")
    print("Start Nextcloud add-missing procedure.")
    update_procedure("docker-compose exec -T -u www-data application /var/www/html/occ db:add-missing-columns")
    update_procedure("docker-compose exec -T -u www-data application /var/www/html/occ db:add-missing-indices")
    update_procedure("docker-compose exec -T -u www-data application /var/www/html/occ db:add-missing-primary-keys")
    print("Deacitvate Maintanance Mode")
    update_procedure("docker-compose exec -T -u www-data application /var/www/html/occ maintenance:mode --off")

def update_procedure(command):
    """
    Attempts to execute a command up to a maximum number of retries.
    """
    max_attempts = 3
    for attempt in range(max_attempts):
        try:
            run_command(command)
            break  # If the command succeeds, exit the loop
        except subprocess.CalledProcessError as e:
            if attempt < max_attempts - 1:  # Check if it's not the last attempt
                print(f"Attempt {attempt + 1} failed, retrying in 60 seconds...")
                time.sleep(60)  # Wait for 60 seconds before retrying
            else:
                print("All attempts to update have failed.")
                raise  # Re-raise the last exception after all attempts fail

def start_docker(directory):
    """
    Starts or restarts Docker services in the specified directory.
    """
    if is_any_service_up():
        print(f"Restarting containers in {directory}.")
        run_command("docker-compose up -d --force-recreate")
    else:
        print(f"Skipped starting. No service is up in {directory}.")

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Please provide the path to the parent directory as a parameter.")
        sys.exit(1)

    parent_directory = sys.argv[1]
    for dir_entry in os.scandir(parent_directory):
        if dir_entry.is_dir():
            dir_path = dir_entry.path
            print(f"Checking for updates in: {dir_path}")
            os.chdir(dir_path)
            
            # Pull git repository if it exist
            # @deprecated: This function should be removed in the future, as soon as all docker applications use the correct folder path
            if os.path.isdir(os.path.join(dir_path, ".git")):
                print("DEPRECATED: Docker .git repositories should be saved under /opt/docker/{instance}/services/{repository_name} ")
                git_pull()
            
            if os.path.basename(dir_path) == "matrix":
                # No autoupdate for matrix is possible atm, 
                # due to the reason that the role has to be executed every time.
                # The update has to be executed in the role
                # @todo implement in future
                pass
            else:
                # Pull and update docker images
                update_docker(dir_path)
                
                # The following instances need additional update and upgrade procedures
                if os.path.basename(dir_path) == "discourse":
                    update_discourse(dir_path)
                elif os.path.basename(dir_path) == "listmonk":
                    upgrade_listmonk()
                elif os.path.basename(dir_path) == "mastodon":
                    update_mastodon()
                elif os.path.basename(dir_path) == "nextcloud":
                    update_nextcloud()
                    
                # @todo implement dedicated procedure for bluesky
                # @todo implement dedicated procedure for openproject
                # @todo implement dedicated procedure for taiga