"""Yooz Bot SDK for Python 3.8+ (standard library only, one file).

    from yooz_bot import Bot, keyboard, button

    bot = Bot("123:abc...")

    @bot.command("start")
    def start(msg):
        msg.reply(f"سلام <b>{msg.sender_name}</b>!", parse_mode="HTML",
                  reply_markup=keyboard([[button("👍", data="like")]]))

    @bot.callback("like")
    def like(query):
        query.answer("ممنون!")
        query.edit_text("پسندیدید 👍")

    @bot.message()
    def echo(msg):
        if msg.text:
            msg.reply(msg.text)

    bot.run_polling()           # or bot.run_webhook(port=8080, secret="...")

Any Bot API method is also available directly: ``bot.send_message(chat_id=…,
text=…)``, ``bot.get_me()``, ``bot.call("sendMessage", …)``. Files: pass a
path (``pathlib.Path``), bytes, an open binary file or ``InputFile``; a
string is sent as is (a ``file_id`` or URL).
"""

import io
import json
import mimetypes
import os
import re
import threading
import time
import traceback
import urllib.error
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

__all__ = [
    "Bot",
    "YoozError",
    "InputFile",
    "Message",
    "CallbackQuery",
    "keyboard",
    "button",
]
__version__ = "1.0.0"

DEFAULT_API = os.environ.get("YOOZ_API", "https://yooz-app.ir")
_FILE_FIELDS = {"photo", "document", "video", "animation", "voice", "audio"}


class YoozError(Exception):
    """A Bot API call answered ``ok: false``."""

    def __init__(self, description, code=None, parameters=None):
        super().__init__(description)
        self.description = description
        self.code = code
        self.parameters = parameters or {}


class InputFile:
    """A file to upload: ``InputFile(b"...", "photo.jpg")``."""

    def __init__(self, data, name="file", mime=None):
        if isinstance(data, (str, Path)):
            path = Path(data)
            name = path.name if name == "file" else name
            data = path.read_bytes()
        elif hasattr(data, "read"):
            name = os.path.basename(getattr(data, "name", name)) or name
            data = data.read()
        self.data = bytes(data)
        self.name = name
        self.mime = mime or mimetypes.guess_type(name)[0] or "application/octet-stream"


def _as_file(value):
    if isinstance(value, InputFile):
        return value
    if isinstance(value, (bytes, bytearray, Path)) or hasattr(value, "read"):
        return InputFile(value)
    return None


def button(text, data=None, url=None):
    """An inline keyboard button: callback ``data`` or a link ``url``."""
    if (data is None) == (url is None):
        raise ValueError("button needs exactly one of data= or url=")
    return {"text": text, "url": url} if url else {"text": text, "callback_data": data}


def keyboard(rows):
    """``reply_markup`` for rows of :func:`button`."""
    return {"inline_keyboard": [list(row) for row in rows]}


def _camel(name):
    head, *rest = name.split("_")
    return head + "".join(p[:1].upper() + p[1:] for p in rest)


class Message:
    """An incoming message, with shortcuts that answer in its chat."""

    def __init__(self, bot, raw):
        self.bot = bot
        self.raw = raw
        self.id = raw.get("message_id")
        self.chat = raw.get("chat", {})
        self.chat_id = self.chat.get("id")
        self.sender = raw.get("from") or {}
        self.text = raw.get("text") or raw.get("caption") or ""

    @property
    def sender_name(self):
        return self.sender.get("first_name") or self.sender.get("username") or ""

    @property
    def command(self):
        """``"start"`` for ``/start@my_bot payload``, else None."""
        m = re.match(r"^/([A-Za-z0-9_]{1,32})(?:@\w+)?(?:\s|$)", self.text)
        return m.group(1).lower() if m else None

    @property
    def args(self):
        """Text after the command."""
        return self.text.split(None, 1)[1] if self.command and " " in self.text.strip() else ""

    @property
    def is_private(self):
        return self.chat.get("type") == "private"

    def __getitem__(self, key):
        return self.raw[key]

    def get(self, key, default=None):
        return self.raw.get(key, default)

    def answer(self, text, **params):
        """Sends [text] to this chat."""
        return self.bot.send_message(chat_id=self.chat_id, text=text, **params)

    def reply(self, text, **params):
        """Sends [text] as a reply to this message."""
        params.setdefault("reply_parameters", {"message_id": self.id})
        return self.answer(text, **params)

    def reply_photo(self, photo, caption=None, **params):
        return self.bot.send_photo(
            chat_id=self.chat_id, photo=photo, caption=caption,
            reply_parameters={"message_id": self.id}, **params,
        )

    def typing(self):
        return self.bot.send_chat_action(chat_id=self.chat_id, action="typing")

    def edit_text(self, text, **params):
        return self.bot.edit_message_text(chat_id=self.chat_id, message_id=self.id, text=text, **params)

    def delete(self):
        return self.bot.delete_message(chat_id=self.chat_id, message_id=self.id)


class CallbackQuery:
    """An inline button press."""

    def __init__(self, bot, raw):
        self.bot = bot
        self.raw = raw
        self.id = raw.get("id")
        self.data = raw.get("data") or ""
        self.sender = raw.get("from") or {}
        self.message = Message(bot, raw["message"]) if raw.get("message") else None
        self._answered = False

    def answer(self, text=None, show_alert=False, url=None):
        """The short notice shown to the user (call once, quickly)."""
        self._answered = True
        params = {"callback_query_id": self.id, "show_alert": show_alert}
        if text:
            params["text"] = text
        if url:
            params["url"] = url
        return self.bot.answer_callback_query(**params)

    def edit_text(self, text, **params):
        return self.message.edit_text(text, **params)

    def edit_markup(self, reply_markup=None):
        return self.bot.edit_message_reply_markup(
            chat_id=self.message.chat_id, message_id=self.message.id,
            reply_markup=reply_markup or {"inline_keyboard": []},
        )


class Bot:
    """A Yooz bot: Bot API calls, handlers, long polling and webhooks."""

    def __init__(self, token=None, api=None, timeout=70, verify_tls=True):
        self.token = token or os.environ.get("YOOZ_BOT_TOKEN")
        if not self.token:
            raise ValueError("bot token missing (argument or YOOZ_BOT_TOKEN)")
        self.api = (api or DEFAULT_API).rstrip("/")
        self.timeout = timeout
        self._context = None
        if not verify_tls:
            import ssl

            self._context = ssl._create_unverified_context()
        self._handlers = []  # (kind, test, fn)
        self._errors = []
        self._stop = threading.Event()
        self.me = None

    # ------------------------------------------------------------ calls
    def call(self, method, **params):
        """Calls Bot API [method]; returns its ``result``."""
        params = {k: v for k, v in params.items() if v is not None}
        files = {k: f for k, v in params.items() if k in _FILE_FIELDS and (f := _as_file(v))}
        url = f"{self.api}/bot{self.token}/{method}"
        if files:
            boundary = uuid.uuid4().hex
            body = io.BytesIO()
            for key, value in params.items():
                body.write(f"--{boundary}\r\n".encode())
                if key in files:
                    f = files[key]
                    body.write(
                        f'Content-Disposition: form-data; name="{key}"; filename="{f.name}"\r\n'
                        f"Content-Type: {f.mime}\r\n\r\n".encode()
                    )
                    body.write(f.data)
                else:
                    text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
                    body.write(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode())
                    body.write(text.encode())
                body.write(b"\r\n")
            body.write(f"--{boundary}--\r\n".encode())
            data = body.getvalue()
            content_type = f"multipart/form-data; boundary={boundary}"
        else:
            data = json.dumps(params, ensure_ascii=False).encode()
            content_type = "application/json"
        req = urllib.request.Request(url, data=data, headers={"Content-Type": content_type})
        try:
            with urllib.request.urlopen(req, timeout=self.timeout, context=self._context) as res:
                answer = json.load(res)
        except urllib.error.HTTPError as e:
            try:
                answer = json.load(e)
            except ValueError:
                raise YoozError(f"HTTP {e.code}", e.code) from None
        if not answer.get("ok"):
            raise YoozError(answer.get("description", "error"), answer.get("error_code"), answer.get("parameters"))
        return answer.get("result")

    def __getattr__(self, name):
        if name.startswith("_"):
            raise AttributeError(name)
        method = _camel(name)
        return lambda **params: self.call(method, **params)

    def download(self, file_id):
        """Bytes of a file the bot received (photo, voice…)."""
        path = self.call("getFile", file_id=file_id)["file_path"]
        req = urllib.request.Request(f"{self.api}/file/bot{self.token}/{path}")
        with urllib.request.urlopen(req, timeout=self.timeout, context=self._context) as res:
            return res.read()

    # --------------------------------------------------------- handlers
    def command(self, *names):
        """``@bot.command("start", "help")``: messages starting with them."""
        wanted = {n.lower().lstrip("/") for n in names}
        return self._register("message", lambda m: m.command in wanted)

    def message(self, pattern=None, content=None):
        """Any message; or text matching regex [pattern]; or having
        [content] (``"photo"``, ``"voice"``…)."""
        regex = re.compile(pattern) if isinstance(pattern, str) else pattern

        def test(m):
            if content and content not in m.raw:
                return False
            return regex is None or bool(regex.search(m.text))

        return self._register("message", test)

    def callback(self, data=None, prefix=None):
        """Button presses: exact [data], [prefix], or all."""

        def test(q):
            if data is not None:
                return q.data == data
            return prefix is None or q.data.startswith(prefix)

        return self._register("callback_query", test)

    def on(self, update_type):
        """Raw updates of [update_type] (e.g. ``"edited_message"``)."""
        return self._register(update_type, lambda _: True)

    def error(self, fn):
        """``@bot.error`` handler: ``fn(exc, update)``."""
        self._errors.append(fn)
        return fn

    def _register(self, kind, test):
        def decorator(fn):
            self._handlers.append((kind, test, fn))
            return fn

        return decorator

    def process_update(self, update):
        """Runs the first matching handler for one update dict."""
        for kind in ("message", "edited_message", "channel_post", "callback_query"):
            if kind in update:
                raw = update[kind]
                break
        else:
            return
        if kind == "callback_query":
            obj = CallbackQuery(self, raw)
        elif kind in ("message", "edited_message", "channel_post"):
            obj = Message(self, raw)
        else:
            obj = raw
        try:
            for handler_kind, test, fn in self._handlers:
                if handler_kind == kind and test(obj):
                    fn(obj)
                    break
            if kind == "callback_query" and not obj._answered:
                obj.answer()
        except Exception as exc:  # one bad update must not stop the bot
            if not self._errors:
                traceback.print_exc()
            for fn in self._errors:
                fn(exc, update)

    # ----------------------------------------------------------- running
    def run_polling(self, timeout=50, drop_pending=False, commands=None):
        """Long-polls ``getUpdates`` until :meth:`stop` (Ctrl+C also)."""
        self.call("deleteWebhook", drop_pending_updates=drop_pending)
        self.me = self.call("getMe")
        if commands:
            self.set_my_commands(commands=[{"command": c, "description": d} for c, d in commands.items()])
        print(f"@{self.me['username']} is running")
        offset = 0
        delay = 1
        try:
            while not self._stop.is_set():
                try:
                    updates = self.call("getUpdates", offset=offset, timeout=timeout)
                    delay = 1
                except (YoozError, OSError) as e:
                    print("getUpdates failed:", e)
                    time.sleep(delay)
                    delay = min(delay * 2, 30)
                    continue
                for update in updates:
                    offset = update["update_id"] + 1
                    self.process_update(update)
        except KeyboardInterrupt:
            pass

    def stop(self):
        self._stop.set()

    def run_webhook(self, url=None, host="0.0.0.0", port=8080, path="/", secret=None):
        """Serves updates POSTed by Yooz. With [url] (your public HTTPS
        address) it also calls ``setWebhook``."""
        if url:
            self.call("setWebhook", url=url, secret_token=secret)
        bot = self

        class Handler(BaseHTTPRequestHandler):
            def do_POST(self):
                if self.path.split("?")[0] != path:
                    self.send_response(404)
                    self.end_headers()
                    return
                if secret and self.headers.get("X-Telegram-Bot-Api-Secret-Token") != secret:
                    self.send_response(403)
                    self.end_headers()
                    return
                length = int(self.headers.get("Content-Length") or 0)
                try:
                    update = json.loads(self.rfile.read(length) or b"{}")
                except ValueError:
                    self.send_response(400)
                    self.end_headers()
                    return
                self.send_response(200)
                self.end_headers()
                bot.process_update(update)

            def log_message(self, *args):
                pass

        server = ThreadingHTTPServer((host, port), Handler)
        print(f"webhook listening on {host}:{port}{path}")
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            server.server_close()
