#!/usr/bin/env python3
"""Create or update a webhook integration screenshot using a test fixture."""

import argparse
import base64
import os
import re
import subprocess
import sys
from typing import Any
from urllib.parse import parse_qsl, urlencode

import zulip

SCREENSHOTS_DIR = os.path.abspath(os.path.dirname(__file__))
TOOLS_DIR = os.path.abspath(os.path.dirname(SCREENSHOTS_DIR))
ROOT_DIR = os.path.dirname(TOOLS_DIR)
sys.path.insert(0, ROOT_DIR)

# check for the venv
from tools.lib import sanity_check

sanity_check.check_venv(__file__)

from scripts.lib.setup_path import setup_path

setup_path()

os.environ["DJANGO_SETTINGS_MODULE"] = "zproject.settings"
import django

django.setup()

import orjson
import requests

from scripts.lib.zulip_tools import BOLDRED, ENDC
from tools.lib.test_script import prepare_puppeteer_run
from zerver.actions.create_user import do_create_user, notify_created_bot
from zerver.actions.message_send import do_send_messages, internal_prep_stream_message
from zerver.actions.streams import bulk_add_subscriptions
from zerver.actions.user_settings import do_change_avatar_fields
from zerver.lib.integrations import (
    FIXTURELESS_INTEGRATIONS_WITH_SCREENSHOTS,
    INCOMING_WEBHOOK_INTEGRATIONS,
    INTEGRATIONS,
    BotIntegration,
    FixturelessScreenshotConfig,
    IncomingWebhookIntegration,
    Integration,
    WebhookScreenshotConfig,
    get_fixture_path,
    get_image_path,
    split_fixture_path,
)
from zerver.lib.storage import static_path
from zerver.lib.streams import create_stream_if_needed, ensure_stream
from zerver.lib.upload import upload_avatar_image
from zerver.lib.url_encoding import topic_narrow_url
from zerver.lib.webhooks.common import call_fixture_to_headers, parse_multipart_string
from zerver.models import Message, Stream, UserProfile
from zerver.models.realms import Realm, get_realm
from zerver.models.users import get_user_by_delivery_email
from zerver.webhooks.fixtureless_integrations import MessageThread


def create_integration_bot(integration: Integration, bot_name: str | None = None) -> UserProfile:
    realm = get_realm("zulip")
    owner = get_user_by_delivery_email("iago@zulip.com", realm)
    email_prefix = (
        re.sub(r"[^a-zA-Z0-9_-]", "", bot_name.strip()).lower()[:64]
        if bot_name
        else integration.name
    )
    bot_email = f"{email_prefix}-bot@example.com"
    if bot_name is None:
        if isinstance(integration, BotIntegration):
            bot_name = integration.display_name
        else:
            bot_name = f"{integration.display_name} Bot"
    try:
        bot = UserProfile.objects.get(email=bot_email)
    except UserProfile.DoesNotExist:
        bot = do_create_user(
            email=bot_email,
            password="123",
            realm=owner.realm,
            full_name=bot_name,
            bot_type=UserProfile.INCOMING_WEBHOOK_BOT,
            bot_owner=owner,
            acting_user=owner,
        )
        notify_created_bot(bot)

        bot_avatar_path = integration.get_bot_avatar_path()
        bot_avatar_path = static_path(bot_avatar_path)
        if os.path.isfile(bot_avatar_path):
            with open(bot_avatar_path, "rb") as f:
                upload_avatar_image(f, bot, content_type="image/png")
                do_change_avatar_fields(bot, UserProfile.AVATAR_FROM_USER, acting_user=owner)
        else:
            raise ValueError(f"Bot avatar file {bot_avatar_path} does not exist.")

    return bot


def create_integration_channel(channel_name: str, bot: UserProfile) -> None:
    assert isinstance(bot.bot_owner, UserProfile)
    realm = bot.bot_owner.realm
    channel, _created = create_stream_if_needed(realm, channel_name)
    bulk_add_subscriptions(realm, [channel], [bot, bot.bot_owner], acting_user=bot)


def create_user(
    realm: Realm, username: str, acting_user: UserProfile, channel: Stream
) -> UserProfile:
    try:
        user = UserProfile.objects.get(realm=realm, full_name=username)
    except UserProfile.DoesNotExist:
        sender_email = f"{username.replace(' ', '').lower()}@example.com"
        user = do_create_user(
            email=sender_email,
            password="123",
            realm=realm,
            full_name=username,
            acting_user=acting_user,
        )

    bulk_add_subscriptions(realm, [channel], [user], acting_user=acting_user)

    return user


def get_fixture_info(fixture_path: str) -> tuple[Any, bool, bool, str]:
    json_fixture = fixture_path.endswith(".json")
    multipart_fixture = fixture_path.endswith(".multipart")
    _, fixture_name = split_fixture_path(fixture_path)

    if fixture_name:
        if not os.path.exists(fixture_path):
            raise FileNotFoundError(
                f"{BOLDRED}The fixture '{fixture_path}' does not exist. Please check the fixture file name and try again.{ENDC}"
            )
        if json_fixture:
            with open(fixture_path, "rb") as fb:
                data = orjson.loads(fb.read())
        else:
            with open(fixture_path) as f:
                data = f.read().strip()
            if multipart_fixture:
                data = parse_multipart_string(data)
    else:
        data = ""

    return data, json_fixture, multipart_fixture, fixture_name


def get_requests_headers(integration_dir_name: str, fixture_name: str) -> dict[str, Any]:
    headers = call_fixture_to_headers(integration_dir_name, fixture_name)

    def fix_name(header: str) -> str:
        return header.removeprefix("HTTP_").replace("_", "-")

    return {fix_name(k): v for k, v in headers.items()}


def custom_headers(headers_json: str) -> dict[str, str]:
    if not headers_json:
        return {}
    try:
        return orjson.loads(headers_json)
    except orjson.JSONDecodeError as ve:
        raise argparse.ArgumentTypeError(
            f"Encountered an error while attempting to parse custom headers: {ve}\n"
            "Note: all strings must be enclosed within \"\" instead of ''"
        )


def send_bot_payload_message(
    bot: UserProfile,
    integration: IncomingWebhookIntegration,
    fixture_path: str,
    config: WebhookScreenshotConfig,
) -> bool:
    # Delete all messages, so new message is the only one it's message group
    Message.objects.filter(realm_id=bot.realm_id, sender=bot).delete()
    data, json_fixture, multipart_fixture, fixture_name = get_fixture_info(fixture_path)

    headers = get_requests_headers(integration.dir_name, fixture_name)
    if config.custom_headers:
        headers.update(config.custom_headers)
    if config.use_basic_auth:
        credentials = base64.b64encode(f"{bot.email}:{bot.api_key}".encode()).decode()
        auth = f"basic {credentials}"
        headers.update(dict(Authorization=auth))

    assert isinstance(bot.bot_owner, UserProfile)
    channel_name = config.channel or integration.name
    url = f"{bot.bot_owner.realm.url}/{integration.url}"
    params = {"api_key": bot.api_key, "stream": channel_name}
    params.update(config.extra_params)

    extra_args = {}
    if multipart_fixture:
        extra_args = {"data": data}

    elif not json_fixture and data:
        assert isinstance(data, str)

        # fixtures with url parameters
        if "&" in data:
            # Overwrite the fixture params, in case of overlap.
            parsed_params = dict(parse_qsl(data))
            parsed_params.update(params)
            params = parsed_params

        # fixtures with plain/text payload
        else:
            extra_args = {"data": data}

    elif config.payload_as_query_param:
        params[config.payload_param_name] = orjson.dumps(data).decode()

    else:
        extra_args = {"json": data}

    url = f"{url}?{urlencode(params)}"

    try:
        response = requests.post(url=url, headers=headers, **extra_args)
    except requests.exceptions.ConnectionError:
        print(
            "This tool needs the local dev server to be running. "
            "Please start it using tools/run-dev before running this tool."
        )
        sys.exit(1)
    if response.status_code != 200:
        print(response.json())
        print("Failed to trigger webhook")
        return False
    else:
        print(f"Triggered {integration.name} webhook")
        return True


def send_bot_mock_message(bot: UserProfile, channel: str, topic: str, message: str) -> None:
    # Delete all messages, so that the new message is the only one in its message group
    Message.objects.filter(realm_id=bot.realm_id, sender=bot).delete()
    assert bot.bot_owner is not None
    url = f"{bot.bot_owner.realm.url}"
    client = zulip.Client(email=bot.email, api_key=bot.api_key, site=url)

    request = {"type": "stream", "to": channel, "topic": topic, "content": message}
    client.send_message(request)


def capture_last_message_screenshot(bot: UserProfile, image_path: str) -> None:
    message = Message.objects.filter(realm_id=bot.realm_id, sender=bot).last()
    realm = get_realm("zulip")
    if message is None:
        print(f"No message found for {bot.full_name}")
        return
    message_id = str(message.id)
    screenshot_script = os.path.join(SCREENSHOTS_DIR, "message-screenshot.ts")
    subprocess.check_call(["node", screenshot_script, message_id, image_path, realm.url])


def send_thread_messages(
    bot: UserProfile,
    channel_name: str,
    topic: str,
    thread_messages_data: list[MessageThread],
    integration: Integration,
) -> list[int]:
    realm = bot.realm
    channel = ensure_stream(realm, channel_name, acting_user=bot.bot_owner)
    assert bot.bot_owner is not None
    user = create_user(
        realm=realm, username="Bo Williams", acting_user=bot.bot_owner, channel=channel
    )

    Message.objects.filter(
        realm=realm,
        recipient=channel.recipient,
        subject__iexact=topic,
    ).delete()

    message_requests = []
    for messages in thread_messages_data:
        sender_name = messages["sender"]
        content = messages["message"]

        if sender_name in {bot.full_name, integration.display_name, integration.name.title()}:
            sender = bot
        else:
            sender = user

        message_request = internal_prep_stream_message(
            sender,
            channel,
            topic,
            content,
        )
        message_requests.append(message_request)

    sent_message_results = do_send_messages(message_requests)
    return [result.message_id for result in sent_message_results]


def capture_thread_screenshot(
    bot: UserProfile, channel_name: str, topic: str, message_ids: list[int], image_path: str
) -> None:
    assert bot.bot_owner is not None
    stream = ensure_stream(bot.realm, channel_name, acting_user=bot.bot_owner)
    narrow_uri = topic_narrow_url(realm=bot.realm, stream=stream, topic_name=topic)
    narrow = f"{channel_name}/{topic}"
    screenshot_script = os.path.join(SCREENSHOTS_DIR, "thread-screenshot.ts")
    subprocess.check_call(
        [
            "node",
            screenshot_script,
            narrow_uri,
            narrow,
            str(min(message_ids)),
            image_path,
            bot.realm.url,
        ]
    )


def generate_screenshots_for_integration(integration: Integration) -> None:
    if integration.screenshot_configs is not None:
        for screenshot_config in integration.screenshot_configs:
            generate_screenshot_from_config(integration.name, screenshot_config)


def generate_screenshot_from_config(
    integration_name: str, screenshot_config: WebhookScreenshotConfig | FixturelessScreenshotConfig
) -> None:
    integration = INTEGRATIONS[integration_name]
    bot = create_integration_bot(integration, screenshot_config.bot_name)
    channel_name = screenshot_config.channel or integration.name
    create_integration_channel(channel_name, bot)
    image_path = get_image_path(integration, screenshot_config)
    match (integration, screenshot_config):
        case (IncomingWebhookIntegration(), WebhookScreenshotConfig()):
            fixture_path = get_fixture_path(integration, screenshot_config)
            if send_bot_payload_message(bot, integration, fixture_path, screenshot_config):
                capture_last_message_screenshot(bot, image_path)
        case (_, FixturelessScreenshotConfig(message=messages)) if isinstance(messages, list):
            message_ids = send_thread_messages(
                bot, channel_name, screenshot_config.topic, messages, integration
            )
            if message_ids:
                capture_thread_screenshot(
                    bot, channel_name, screenshot_config.topic, message_ids, image_path
                )
        case (_, FixturelessScreenshotConfig(message=message)) if isinstance(message, str):
            send_bot_mock_message(
                bot,
                channel=channel_name,
                topic=screenshot_config.topic,
                message=message,
            )
            capture_last_message_screenshot(bot, image_path)
        case _:
            raise ValueError(f"Invalid configuration for integration {integration.name}")
    if os.path.exists(image_path):
        print(f"Screenshot captured to: {BOLDRED}{image_path}{ENDC}")


parser = argparse.ArgumentParser()

batch_group = parser.add_mutually_exclusive_group(required=True)
batch_group.add_argument("--all", action="store_true")
batch_group.add_argument("--all-webhook", action="store_true")
batch_group.add_argument("--all-fixtureless", action="store_true")
batch_group.add_argument(
    "--skip-until",
    help="Name of the integration to start processing from, including all that follow in alphabetical order. Similar to --all",
)
batch_group.add_argument("--integration", nargs="+", help="Names of specific integrations")

common_group = parser.add_argument_group()
common_group.add_argument("--image-name", help="Name for the screenshot image")
common_group.add_argument("--image-dir", help="Directory name where to save the screenshot image")
common_group.add_argument("--bot-name", help="Name to use for the bot")

webhook_group = parser.add_argument_group("Webhook integrations options")
webhook_group.add_argument(
    "--fixture-name",
    help="Name of the fixture file to use. All other webhook options are ignored if the fixture is not specified.",
)
webhook_group.add_argument(
    "-A", "--use-basic-auth", action="store_true", help="Add basic auth headers to the request"
)
webhook_group.add_argument(
    "-Q",
    "--payload-as-query-param",
    action="store_true",
    help="Send payload as query param instead of body",
)
webhook_group.add_argument("-P", "--payload-param-name", help="Param name to use for the payload")
webhook_group.add_argument(
    "-H",
    "--custom-headers",
    type=custom_headers,
    help="Any additional headers to be sent with the request.",
)

fixtureless_group = parser.add_argument_group("Fixtureless non-webhook integrations options")
fixtureless_group.add_argument("-T", "--topic", help="Topic to use for the mock message")
fixtureless_group.add_argument("-M", "--message", help="Message to use for the mock message")
fixtureless_group.add_argument(
    "-C",
    "--channel",
    help="Channel name to use for the mock message. Ignored if --topic and --message are not specified.",
)

options = parser.parse_args()
prepare_puppeteer_run()


def validate_integration_count(
    options: argparse.Namespace, parser: argparse.ArgumentParser, option_name: str
) -> None:
    if len(options.integration) != 1:
        parser.error(
            f"Exactly one integration should be specified with --integration when --{option_name} is provided"
        )


def build_config(options: argparse.Namespace, config_keys: list[str]) -> dict[str, Any]:
    config = {
        key: getattr(options, key) for key in config_keys if getattr(options, key) is not None
    }
    return config


def generate_full_batch_screenshots(
    option_key: str,
    integration_list: list[Integration] | list[IncomingWebhookIntegration],
    batch_type: str,
) -> None:
    for key, value in vars(options).items():
        if key != option_key and value:
            print(f"Generating screenshots for {batch_type}. Ignoring all command-line options")
            break
    for integration in integration_list:
        generate_screenshots_for_integration(integration)


if options.all:
    generate_full_batch_screenshots("all", list(INTEGRATIONS.values()), "all integrations")
elif options.all_webhook:
    generate_full_batch_screenshots(
        "all_webhook", INCOMING_WEBHOOK_INTEGRATIONS, "all incoming webhook integrations"
    )
elif options.all_fixtureless:
    fixtureless_integrations_list = [
        INTEGRATIONS[name] for name in FIXTURELESS_INTEGRATIONS_WITH_SCREENSHOTS
    ]
    generate_full_batch_screenshots(
        "all_fixtureless", fixtureless_integrations_list, "all fixtureless integrations"
    )
elif options.skip_until:
    for key, value in vars(options).items():
        if key != "skip_until" and value:
            print(
                f"Generating screenshots for all integrations starting from {options.skip_until}. Ignoring all command-line options"
            )
            break
    skip = True
    for integration_name in sorted(INTEGRATIONS):
        if integration_name == options.skip_until:
            skip = False
        if skip:
            continue
        generate_screenshots_for_integration(INTEGRATIONS[integration_name])

elif options.fixture_name:
    validate_integration_count(options, parser, "fixture-name")
    webhook_options = [action.dest for action in webhook_group._group_actions]
    common_options = [action.dest for action in common_group._group_actions]
    config = build_config(options, webhook_options + common_options)
    webhook_screenshot_config = WebhookScreenshotConfig(**config)
    generate_screenshot_from_config(options.integration[0], webhook_screenshot_config)

elif options.topic or options.message:
    if not options.topic or not options.message:
        parser.error("Both --topic and --message must be specified together")
    validate_integration_count(options, parser, "topic")
    fixtureless_options = [action.dest for action in fixtureless_group._group_actions]
    common_options = [action.dest for action in common_group._group_actions]
    config = build_config(options, fixtureless_options + common_options)
    fixtureless_screenshot_config = FixturelessScreenshotConfig(**config)
    generate_screenshot_from_config(options.integration[0], fixtureless_screenshot_config)

elif options.integration:
    for integration in options.integration:
        assert integration in INTEGRATIONS
        configs = INTEGRATIONS[integration].screenshot_configs
        if configs is None:
            parser.error(f"Could not find screenshot configuration for integration {integration}. ")
        for screenshot_config in configs:
            generate_screenshot_from_config(integration, screenshot_config)

else:
    parser.error(
        "Could not find configuration for integration. "
        "You can specify a fixture file to use, using the --fixture flag. "
        "Or add a configuration to the corresponding integration in zerver.lib.integrations.INTEGRATIONS.",
    )
