#!/usr/bin/env python3
import sys

if sys.version_info < (3, 8):
    raise RuntimeError("At least Python 3.8 is required")

from mastodon import Mastodon, AttribAccessDict
from typing import (
    Optional,
    Any,
    Tuple,
    Sequence,
    Dict,
    List,
    Iterable,
    AsyncIterator,
    cast,
    Union,
)
from dateutil.parser import parse as parse_date
from tempfile import NamedTemporaryFile
from urllib.parse import urlencode, urlparse, urlunparse
from os import fchmod
from getpass import getpass
import logging, random, stat, magic, mimetypes, json
import websockets
from collections import namedtuple

StreamEvent = namedtuple("StreamEvent", ["event", "payload"])


class MastodonPoster(object):
    SCOPES = frozenset(["write:media", "write:statuses"])

    COMMON_EXTENSIONS = frozenset(
        [
            ".jpg",
            ".gif",
            ".tif",
            ".png",
        ]
    )

    STREAM_TYPES = frozenset(
        [
            "user",
            "public",
            "public:local",
            "hashtag",
            "hashtag:local",
            "list",
            "direct",
        ]
    )

    @staticmethod
    def get_mimetype(data: bytes) -> str:
        return magic.from_buffer(data, mime=True)

    @classmethod
    def get_extension(cls, data: bytes) -> str:
        extensions = set(cls.COMMON_EXTENSIONS) & set(
            mimetypes.guess_all_extensions(cls.get_mimetype(data))
        )
        try:
            return extensions.pop()
        except KeyError:
            raise ValueError("Unable to determine extension")

    @staticmethod
    def secret_file():
        f = NamedTemporaryFile(mode="w+t", encoding="utf8")
        fchmod(f.file.fileno(), stat.S_IRUSR | stat.S_IWUSR)
        return f

    @classmethod
    def get_secrets_from_server(
        cls, url: str, app_name: str, scopes: Iterable[str] = SCOPES
    ) -> Tuple[str, str]:
        client_id, client_secret = Mastodon.create_app(
            app_name, api_base_url=url, scopes=list(scopes)
        )

        return client_id, client_secret

    def __init__(
        self,
        api_base_url: str,
        client_id: str,
        client_secret: str,
        access_token: Optional[str] = None,
        scopes: Iterable[str] = SCOPES,
    ):
        self.api_base_url = api_base_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.access_token = access_token
        self.scopes = list(scopes)
        self._user_id: Optional[str] = None

        if not self.client_secret or not self.client_secret:
            raise RuntimeError("Client information is not configured")

        self.mastodon: Optional[Mastodon] = None

        if self.access_token:
            logging.debug("Initializing with access token")
            with self.secret_file() as tokenfile:
                print(self.access_token, file=tokenfile)
                tokenfile.file.seek(0)
                try:
                    self.mastodon = Mastodon(
                        access_token=tokenfile.name,
                        api_base_url=self.api_base_url,
                        feature_set="pleroma",
                    )
                except:
                    self.mastodon = Mastodon(
                        access_token=tokenfile.name, api_base_url=self.api_base_url
                    )

        else:
            logging.debug("Initializing for setup")

            with self.secret_file() as secretfile:
                print(self.client_id, file=secretfile)
                print(self.client_secret, file=secretfile)
                secretfile.file.flush()
                secretfile.file.seek(0)

                try:
                    self.mastodon = Mastodon(
                        client_id=secretfile.name,
                        api_base_url=self.api_base_url,
                        feature_set="pleroma",
                    )
                except:
                    self.mastodon = Mastodon(
                        client_id=secretfile.name, api_base_url=self.api_base_url
                    )

    def get_mastodon(self) -> Mastodon:
        if self.mastodon is None:
            raise RuntimeError("Mastodon is not available")
        return self.mastodon

    def setup_token(self) -> str:
        username = input("Username: ")
        password = getpass("Password: ")
        return self.get_mastodon().log_in(username, password, scopes=self.scopes)

    def upload(self, data: bytes, description: str) -> str:
        mimetype = self.get_mimetype(data)
        if not mimetype:
            raise ValueError("Unknown MIME type")

        return self.get_mastodon().media_post(data, mimetype, description)["id"]

    def notifications(
        self,
        id: Optional[str] = None,
        since_id: Optional[int] = None,
        mentions_only: bool = False,
        limit=100,
    ) -> List[Dict[str, Any]]:
        return self.get_mastodon().notifications(
            id=id, since_id=since_id, mentions_only=mentions_only, limit=limit
        )

    def status(self, id: str) -> Dict[str, Any]:
        return self.get_mastodon().status(id)

    @classmethod
    def attrib_access_dict(cls, root: Dict[str, Any]):
        for key, value in list(root.items()):
            if isinstance(value, dict):
                root[key] = cls.attrib_access_dict(value)

            elif isinstance(value, list):
                for i, subvalue in enumerate(value):
                    if isinstance(subvalue, dict):
                        value[i] = cls.attrib_access_dict(subvalue)

        return AttribAccessDict(root)

    @classmethod
    def build_post_args(
        cls,
        mode: str,
        content: str,
        markdown: bool = False,
        photos: Optional[Sequence[str]] = None,
        sensitive: bool = False,
        visibility: Optional[str] = None,
        in_reply_to: Optional[Union[Dict[str, Any], str]] = None,
        untag: bool = False,
    ) -> Dict[str, Any]:
        post_args: Dict[str, Any] = {
            "status": content,
            "sensitive": sensitive,
        }

        if markdown:
            post_args["content_type"] = "text/markdown"

        if photos:
            post_args["media_ids"] = list(photos)

        if visibility:
            post_args["visibility"] = visibility

        if mode == "reply":
            if isinstance(in_reply_to, dict) and in_reply_to:
                # TODO: Halcy said he was going to make this unnecessary down the line.
                post_args["to_status"] = cls.attrib_access_dict(in_reply_to)
            else:
                raise ValueError("in_reply_to must be a dict for reply()")
        elif in_reply_to:
            if isinstance(in_reply_to, str):
                post_args["in_reply_to_id"] = in_reply_to
            else:
                raise ValueError("in_reply_to must be a str for post()")

        if untag:
            if mode == "reply":
                post_args["untag"] = True
            else:
                raise ValueError("untag is not supported outside of replies")

        return post_args

    def post(self, *args, **kwargs) -> str:
        return self.get_mastodon().status_post(
            **self.build_post_args("post", *args, **kwargs)
        )["id"]

    def reply(self, *args, **kwargs) -> str:
        return self.get_mastodon().status_reply(
            **self.build_post_args("reply", *args, **kwargs)
        )["id"]

    def repeat(self, note_id: str) -> None:
        self.get_mastodon().status_reblog(note_id)

    def unrepeat(self, note_id: str) -> None:
        self.get_mastodon().status_unreblog(note_id)

    @property
    def user_id(self) -> str:
        if self._user_id is None:
            self._user_id = self.get_mastodon().account_verify_credentials().id
        return self._user_id

    @classmethod
    def parse_created_ats(cls, d: Dict[str, Any]) -> None:
        for key, value in list(d.items()):
            if key == "created_at" and isinstance(value, str):
                try:
                    d[key] = parse_date(value)
                except:
                    pass

            elif isinstance(value, dict):
                cls.parse_created_ats(value)

    @classmethod
    def parse_stream_event(cls, raw_message: str) -> StreamEvent:
        try:
            if not raw_message:
                raise ValueError
            message = json.loads(raw_message)

            if not isinstance(message, dict) or not message.get("event", None):
                raise ValueError

            event = message["event"]
            if event == "filters_changed":
                return StreamEvent(event, None)

            str_payload = message.get("payload", None)
            if not str_payload:
                raise ValueError

            if event == "delete":
                return StreamEvent(event, str_payload)

            payload = json.loads(str_payload)
            cls.parse_created_ats(payload)

            return StreamEvent(event, payload)

        except (KeyError, ValueError, TypeError, IndexError, AttributeError) as e:
            raise ValueError(f"Bad message: {raw_message}")

    async def stream(self, stream: str) -> AsyncIterator[StreamEvent]:
        params = {
            "access_token": self.access_token,
        }
        if stream:
            if stream not in self.STREAM_TYPES:
                raise ValueError(f"Invalid stream type: {stream}")

            params["stream"] = stream

        parsed_url = list(urlparse(self.api_base_url))
        parsed_url[0] = "wss" if parsed_url[0].lower() == "https" else "ws"
        parsed_url[2] = "/api/v1/streaming"
        parsed_url[4] = urlencode(params)
        url = urlunparse(parsed_url)

        logging.debug(f"Streaming {url}")
        async with websockets.connect(url) as websocket:
            async for message in websocket:
                if not message:
                    continue

                str_message: str
                if isinstance(message, bytes):
                    try:
                        str_message = message.decode("utf8")
                    except UnicodeDecodeError as e:
                        logging.warning(f"Failed to decode: {e}")
                        continue

                elif isinstance(message, str):
                    str_message = cast(str, message)

                else:
                    continue

                try:
                    yield self.parse_stream_event(str_message)
                except ValueError as e:
                    logging.exception(str(e))
