"""Бот ловца + API мини-приложения. Самостоятельный проект."""

from __future__ import annotations

import asyncio
import json
from pathlib import Path
from typing import Optional

from aiogram import Bot, Dispatcher, F
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.filters import Command, CommandStart
from aiogram.types import (
    InlineKeyboardButton,
    InlineKeyboardMarkup,
    MenuButtonWebApp,
    Message,
    WebAppInfo,
)
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles

from auth import make_media_token, parse_media_token, validate_init_data
from config import (
    ADMIN_TG_ID,
    API_HASH,
    API_ID,
    APP_DIR,
    BOT_TOKEN,
    PORT,
    SSL_CERTFILE,
    SSL_KEYFILE,
    WEBAPP_URL,
)
from crypto import create_invoice, get_invoices, verify_webhook_signature
from db import (
    add_balance,
    get_invoice,
    get_owner,
    get_watch_settings,
    list_active_invoices,
    list_owners,
    mark_invoice_paid,
    save_invoice,
    save_watch_settings,
    upsert_owner,
)
from runtime import CatcherPool
from watch import format_watch_alert, set_alert_sender

WEBAPP_DIR = APP_DIR / "webapp"

app = FastAPI(title="Catcher")
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)
api = APIRouter(prefix="/api")

pool = CatcherPool(int(API_ID or 0), API_HASH or "")
bot: Optional[Bot] = None
dp = Dispatcher()


def _require_token() -> str:
    token = (BOT_TOKEN or "").strip()
    if not token:
        raise RuntimeError("Задай BOT_TOKEN в catcher/config.py (отдельный бот в @BotFather).")
    return token


def _webapp_url() -> str:
    url = (WEBAPP_URL or "").strip() or f"https://api.ozpay.ru:{PORT}/"
    if not url.endswith("/"):
        url += "/"
    return url


def _open_app_markup() -> InlineKeyboardMarkup:
    return InlineKeyboardMarkup(
        inline_keyboard=[[
            InlineKeyboardButton(
                text="Открыть панель ловца",
                web_app=WebAppInfo(url=_webapp_url()),
            )
        ]]
    )


async def _send_watch_alert(payload: dict) -> None:
    if bot is None:
        return
    chat_id = payload.get("alert_chat_id")
    if chat_id is None:
        return
    text, markup_data = format_watch_alert(payload)
    markup = None
    if markup_data:
        try:
            rows = []
            for row in markup_data.get("inline_keyboard") or []:
                buttons = []
                for btn in row:
                    kwargs = {"text": btn["text"], "url": btn["url"]}
                    extra = {}
                    if btn.get("style"):
                        extra["style"] = btn["style"]
                    if btn.get("icon_custom_emoji_id"):
                        extra["icon_custom_emoji_id"] = btn["icon_custom_emoji_id"]
                    try:
                        buttons.append(InlineKeyboardButton(**kwargs, **extra))
                    except Exception:
                        buttons.append(InlineKeyboardButton(**kwargs))
                rows.append(buttons)
            markup = InlineKeyboardMarkup(inline_keyboard=rows)
        except Exception:
            markup = None
    try:
        await bot.send_message(
            chat_id=int(chat_id),
            text=text,
            parse_mode=ParseMode.HTML,
            disable_web_page_preview=True,
            reply_markup=markup,
        )
    except Exception as exc:
        print(f"catcher alert failed chat={chat_id}: {exc}")
        if markup is None:
            return
        try:
            await bot.send_message(
                chat_id=int(chat_id),
                text=text,
                parse_mode=ParseMode.HTML,
                disable_web_page_preview=True,
            )
        except Exception as retry_exc:
            print(f"catcher alert retry failed chat={chat_id}: {retry_exc}")


@dp.message(CommandStart())
async def on_start(message: Message) -> None:
    await message.answer(
        "Это отдельный бот ловли сообщений.\n"
        "Каждый пользователь подключает один свой Telegram-аккаунт как юзербот "
        "и ловит ключевые слова в выбранных чатах.\n\n"
        "Откройте мини-приложение, чтобы войти и настроить мониторинг.",
        reply_markup=_open_app_markup(),
    )


@dp.message(Command("id"))
async def on_id(message: Message) -> None:
    chat = message.chat
    await message.answer(
        f"chat_id этого чата: <code>{chat.id}</code>\n"
        "Вставьте его в мини-приложении — сюда будут приходить оповещения. "
        "Бот должен оставаться в чате.",
    )


@dp.message(F.web_app_data)
async def on_web_app_data(message: Message) -> None:
    await message.answer("Настройки сохраняются в мини-приложении.")


async def current_user(
    x_telegram_init_data: Optional[str] = Header(default="", alias="X-Telegram-Init-Data"),
) -> dict:
    try:
        user = validate_init_data(x_telegram_init_data or "", _require_token())
    except ValueError as exc:
        raise HTTPException(status_code=401, detail=str(exc)) from exc
    upsert_owner(user)
    return user


async def media_user(t: str = "") -> dict:
    try:
        user_id = parse_media_token(t, _require_token())
    except ValueError as exc:
        raise HTTPException(status_code=401, detail=str(exc)) from exc
    return {"id": user_id}


def _photo_response(data: Optional[bytes]) -> Response:
    if not data:
        raise HTTPException(status_code=404, detail="Нет фото")
    return Response(
        content=data,
        media_type="image/jpeg",
        headers={"Cache-Control": "private, max-age=900"},
    )


def _http_error(exc: BaseException) -> HTTPException:
    if isinstance(exc, ValueError):
        return HTTPException(status_code=400, detail=str(exc))
    return HTTPException(status_code=400, detail=str(exc) or "Ошибка юзербота")


@api.get("/health")
async def health() -> dict:
    return {"status": "ok"}


@api.get("/me")
async def api_me(user: dict = Depends(current_user)) -> dict:
    runtime = await pool.get(user["id"])
    owner = get_owner(user["id"])
    return {
        "user": {
            "id": owner["id"],
            "first_name": owner["first_name"] or user.get("first_name") or "",
            "last_name": owner["last_name"] or user.get("last_name") or "",
            "username": owner["username"] or user.get("username") or "",
            "balance": owner.get("balance") or 0,
        },
        "media_token": make_media_token(user["id"], _require_token()),
        "admin": int(user["id"]) == int(ADMIN_TG_ID),
        "session": runtime.snapshot(),
        "watch": get_watch_settings(user["id"]),
    }


@api.get("/session")
async def api_session(user: dict = Depends(current_user)) -> dict:
    runtime = await pool.get(user["id"])
    return runtime.snapshot()


@api.post("/session/login")
async def api_session_login(request: Request, user: dict = Depends(current_user)) -> dict:
    body = await request.json()
    runtime = await pool.get(user["id"])
    try:
        return await runtime.request_code(str(body.get("phone") or ""))
    except (ValueError, RuntimeError) as exc:
        raise _http_error(exc) from exc


@api.post("/session/code")
async def api_session_code(request: Request, user: dict = Depends(current_user)) -> dict:
    body = await request.json()
    runtime = await pool.get(user["id"])
    try:
        return await runtime.submit_code(str(body.get("code") or ""))
    except (ValueError, RuntimeError) as exc:
        raise _http_error(exc) from exc


@api.post("/session/password")
async def api_session_password(request: Request, user: dict = Depends(current_user)) -> dict:
    body = await request.json()
    runtime = await pool.get(user["id"])
    try:
        return await runtime.submit_password(str(body.get("password") or ""))
    except (ValueError, RuntimeError) as exc:
        raise _http_error(exc) from exc


@api.post("/session/cancel")
async def api_session_cancel(user: dict = Depends(current_user)) -> dict:
    runtime = await pool.get(user["id"])
    return await runtime.cancel_login()


@api.post("/session/logout")
async def api_session_logout(user: dict = Depends(current_user)) -> dict:
    runtime = await pool.get(user["id"])
    try:
        return await runtime.logout()
    except RuntimeError as exc:
        raise _http_error(exc) from exc


@api.get("/watch")
async def api_watch(user: dict = Depends(current_user)) -> dict:
    return get_watch_settings(user["id"])


@api.post("/watch")
async def api_watch_save(request: Request, user: dict = Depends(current_user)) -> dict:
    body = await request.json()
    if not isinstance(body, dict):
        raise HTTPException(status_code=400, detail="Некорректные настройки")
    return save_watch_settings(user["id"], body)


@api.get("/dialogs")
async def api_dialogs(user: dict = Depends(current_user)) -> dict:
    runtime = await pool.get(user["id"])
    try:
        dialogs = await runtime.list_dialogs()
    except RuntimeError as exc:
        raise _http_error(exc) from exc
    return {"dialogs": dialogs}


@api.get("/dialogs/{chat_id}/photo")
async def api_dialog_photo(chat_id: int, t: str = "") -> Response:
    user = await media_user(t)
    runtime = await pool.get(user["id"])
    try:
        data = await runtime.dialog_photo(chat_id)
    except RuntimeError as exc:
        raise _http_error(exc) from exc
    return _photo_response(data)


@api.get("/session/photo")
async def api_session_photo(t: str = "") -> Response:
    user = await media_user(t)
    runtime = await pool.get(user["id"])
    try:
        data = await runtime.self_photo()
    except RuntimeError as exc:
        raise _http_error(exc) from exc
    return _photo_response(data)


def _require_admin(user: dict) -> None:
    if int(user["id"]) != int(ADMIN_TG_ID):
        raise HTTPException(status_code=403, detail="Нет доступа")


def _invoice_amount_usd(inv: dict) -> float:
    try:
        return round(float(inv.get("amount") or 0), 2)
    except (TypeError, ValueError):
        return 0.0


async def _notify_balance(tg_id: int, amount: float, *, admin: bool = False) -> None:
    if bot is None:
        return
    prefix = "Админ пополнил баланс" if admin else "Баланс пополнен"
    try:
        await bot.send_message(
            int(tg_id),
            f"{prefix}: <b>${amount:.2f}</b>",
        )
    except Exception:
        pass


async def apply_paid_invoice(inv: dict) -> Optional[dict]:
    if str(inv.get("status") or "") != "paid":
        return None
    try:
        invoice_id = int(inv.get("invoice_id") or 0)
    except (TypeError, ValueError):
        return None
    if invoice_id <= 0:
        return None
    local = get_invoice(invoice_id)
    if local is None:
        try:
            owner_id = int(inv.get("payload") or 0)
        except (TypeError, ValueError):
            owner_id = 0
        amount = _invoice_amount_usd(inv)
        if owner_id <= 0 or amount <= 0:
            return None
        save_invoice(
            invoice_id,
            owner_id,
            amount,
            str(inv.get("bot_invoice_url") or inv.get("pay_url") or ""),
            str(inv.get("mini_app_invoice_url") or ""),
        )
        local = get_invoice(invoice_id)
    if not local or local.get("status") == "paid":
        return local
    credited = mark_invoice_paid(invoice_id)
    if credited and credited.get("credited"):
        await _notify_balance(credited["owner_tg_id"], credited["amount_usd"])
    return credited


async def sync_active_invoices() -> None:
    pending = list_active_invoices()
    if not pending:
        return
    try:
        remote = await get_invoices([item["invoice_id"] for item in pending])
    except Exception as exc:
        print(f"catcher pay poll: {exc}")
        return
    by_id = {}
    for item in remote:
        if isinstance(item, dict) and item.get("invoice_id") is not None:
            by_id[int(item["invoice_id"])] = item
    for local in pending:
        inv = by_id.get(int(local["invoice_id"]))
        if inv:
            await apply_paid_invoice(inv)


async def _invoice_poller() -> None:
    while True:
        await asyncio.sleep(12)
        try:
            await sync_active_invoices()
        except Exception as exc:
            print(f"catcher pay poller: {exc}")


@api.post("/pay")
async def api_pay_create(request: Request, user: dict = Depends(current_user)) -> dict:
    body = await request.json()
    try:
        amount = round(float(body.get("amount") or 0), 2)
    except (TypeError, ValueError):
        amount = 0
    if amount < 1:
        raise HTTPException(status_code=400, detail="Минимум $1")
    if amount > 500:
        raise HTTPException(status_code=400, detail="Максимум $500")
    try:
        invoice = await create_invoice(amount_usd=amount, owner_tg_id=user["id"])
    except RuntimeError as exc:
        raise _http_error(exc) from exc
    invoice_id = int(invoice.get("invoice_id") or 0)
    if invoice_id <= 0:
        raise HTTPException(status_code=502, detail="Crypto Pay не создал счёт")
    pay_url = str(invoice.get("bot_invoice_url") or invoice.get("pay_url") or "")
    mini_url = str(
        invoice.get("mini_app_invoice_url")
        or invoice.get("web_app_invoice_url")
        or ""
    )
    save_invoice(invoice_id, user["id"], amount, pay_url, mini_url)
    return {
        "invoice_id": invoice_id,
        "amount": amount,
        "pay_url": pay_url,
        "mini_url": mini_url,
        "status": "active",
        "balance": get_owner(user["id"]).get("balance") or 0,
    }


@api.get("/pay/{invoice_id}")
async def api_pay_status(invoice_id: int, user: dict = Depends(current_user)) -> dict:
    local = get_invoice(invoice_id)
    if not local or int(local["owner_tg_id"]) != int(user["id"]):
        raise HTTPException(status_code=404, detail="Счёт не найден")
    if local["status"] != "paid":
        try:
            remote = await get_invoices([invoice_id])
        except RuntimeError:
            remote = []
        if remote:
            credited = await apply_paid_invoice(remote[0])
            if credited:
                local = credited
    owner = get_owner(user["id"])
    return {
        "invoice_id": local["invoice_id"],
        "status": local["status"],
        "amount": local["amount_usd"],
        "balance": owner.get("balance") or 0,
    }


@api.post("/crypto/webhook")
async def api_crypto_webhook(request: Request) -> dict:
    raw = await request.body()
    signature = request.headers.get("crypto-pay-api-signature") or request.headers.get("Crypto-Pay-Api-Signature") or ""
    try:
        if not verify_webhook_signature(raw, signature):
            raise HTTPException(status_code=401, detail="Неверная подпись")
    except RuntimeError as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc
    try:
        data = json.loads(raw.decode("utf-8") or "{}")
    except json.JSONDecodeError:
        raise HTTPException(status_code=400, detail="Некорректный JSON")
    if str(data.get("update_type") or "") == "invoice_paid":
        payload = data.get("payload") or {}
        if isinstance(payload, dict):
            payload = dict(payload)
            payload.setdefault("status", "paid")
            await apply_paid_invoice(payload)
    return {"ok": True}


@api.get("/admin/users")
async def api_admin_users(q: str = "", user: dict = Depends(current_user)) -> dict:
    _require_admin(user)
    return {"users": list_owners(q)}


@api.post("/admin/credit")
async def api_admin_credit(request: Request, user: dict = Depends(current_user)) -> dict:
    _require_admin(user)
    body = await request.json()
    try:
        target = int(body.get("tg_id") or 0)
    except (TypeError, ValueError):
        target = 0
    if target <= 0:
        raise HTTPException(status_code=400, detail="Укажите Telegram ID")
    try:
        amount = round(float(body.get("amount") or 0), 2)
    except (TypeError, ValueError):
        amount = 0
    if amount < 0.01:
        raise HTTPException(status_code=400, detail="Укажите сумму в $")
    owner = add_balance(target, amount, reason="admin", actor_tg_id=user["id"])
    await _notify_balance(target, amount, admin=True)
    return {"user": owner}


app.include_router(api)


@app.get("/")
async def index() -> FileResponse:
    return FileResponse(WEBAPP_DIR / "index.html")


app.mount("/", StaticFiles(directory=WEBAPP_DIR), name="webapp")


@app.on_event("startup")
async def startup() -> None:
    global bot
    token = _require_token()
    bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
    set_alert_sender(_send_watch_alert)
    try:
        await bot.set_chat_menu_button(
            menu_button=MenuButtonWebApp(text="Панель", web_app=WebAppInfo(url=_webapp_url()))
        )
    except Exception as exc:
        print(f"catcher menu button: {exc}")
    await pool.boot_all()
    asyncio.create_task(dp.start_polling(bot))
    asyncio.create_task(_invoice_poller())
    print(f"catcher bot + mini-app на порту {PORT}")
    print(
        "crypto webhook: "
        f"{_webapp_url()}api/crypto/webhook  "
        "(включи в @CryptoBot → Crypto Pay → My Apps → Webhooks)"
    )


@app.on_event("shutdown")
async def shutdown() -> None:
    await pool.shutdown_all()
    if bot is not None:
        await bot.session.close()


def run() -> None:
    import uvicorn

    kwargs = {
        "app": "server:app",
        "app_dir": str(APP_DIR),
        "host": "0.0.0.0",
        "port": PORT,
        "reload": False,
        "proxy_headers": True,
        "forwarded_allow_ips": "127.0.0.1",
    }
    cert = Path(SSL_CERTFILE) if SSL_CERTFILE else None
    key = Path(SSL_KEYFILE) if SSL_KEYFILE else None
    if cert and key and cert.exists() and key.exists():
        kwargs["ssl_certfile"] = str(cert)
        kwargs["ssl_keyfile"] = str(key)
    uvicorn.run(**kwargs)
