import asyncio
import json
import os
import re
import secrets
import tempfile
from datetime import datetime
from typing import Dict, Any, List, Optional, Tuple

import httpx
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel

from aiogram import Bot, Dispatcher, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder


# =========================================================
# Utils
# =========================================================

EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


def now_iso() -> str:
    return datetime.utcnow().isoformat()


def parse_kv(text: str) -> Dict[str, str]:
    out: Dict[str, str] = {}
    for part in text.split():
        if "=" in part:
            k, v = part.split("=", 1)
            out[k.strip().lower()] = v.strip()
    return out


# =========================================================
# Config
# =========================================================

def load_config(path: str = "config.json") -> Dict[str, Any]:
    if not os.path.exists(path):
        raise RuntimeError(f"config.json not found at {path}")

    with open(path, "r", encoding="utf-8") as f:
        cfg = json.load(f)

    if not cfg.get("BOT_TOKEN"):
        raise RuntimeError("Missing BOT_TOKEN in config.json")
    if not cfg.get("ADMIN_ID"):
        raise RuntimeError("Missing ADMIN_ID in config.json")

    cfg.setdefault("BASE_URL", "http://localhost:8000")
    cfg.setdefault("DATA_DIR", "data")
    cfg.setdefault("HOST", "0.0.0.0")
    cfg.setdefault("PORT", 8000)
    return cfg


CONFIG = load_config()
BOT_TOKEN = CONFIG["BOT_TOKEN"]
ADMIN_ID = int(CONFIG["ADMIN_ID"])
BASE_URL = str(CONFIG["BASE_URL"]).rstrip("/")
DATA_DIR = str(CONFIG["DATA_DIR"])
HOST = str(CONFIG["HOST"])
PORT = int(CONFIG["PORT"])


# =========================================================
# Data files
# =========================================================

PROFILES_FILE = os.path.join(DATA_DIR, "profiles.json")
EMAILS_FILE = os.path.join(DATA_DIR, "emails.json")
MINUS_FILE = os.path.join(DATA_DIR, "minus.json")

DATA_LOCK = asyncio.Lock()


def ensure_data_files():
    os.makedirs(DATA_DIR, exist_ok=True)

    if not os.path.exists(PROFILES_FILE):
        with open(PROFILES_FILE, "w", encoding="utf-8") as f:
            json.dump({"last_id": 0, "items": []}, f, ensure_ascii=False, indent=2)

    if not os.path.exists(EMAILS_FILE):
        with open(EMAILS_FILE, "w", encoding="utf-8") as f:
            json.dump({"last_id": 0, "items": []}, f, ensure_ascii=False, indent=2)

    if not os.path.exists(MINUS_FILE):
        with open(MINUS_FILE, "w", encoding="utf-8") as f:
            json.dump({"items": []}, f, ensure_ascii=False, indent=2)


def _read_json_sync(path: str, default: Any) -> Any:
    if not os.path.exists(path):
        return default
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def _atomic_write_sync(path: str, data: Any):
    d = os.path.dirname(path)
    os.makedirs(d, exist_ok=True)

    fd, tmp_path = tempfile.mkstemp(prefix=".tmp_", dir=d)
    os.close(fd)
    try:
        with open(tmp_path, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        os.replace(tmp_path, path)
    finally:
        if os.path.exists(tmp_path):
            try:
                os.remove(tmp_path)
            except OSError:
                pass


async def read_json(path: str, default: Any) -> Any:
    return await asyncio.to_thread(_read_json_sync, path, default)


async def write_json(path: str, data: Any):
    await asyncio.to_thread(_atomic_write_sync, path, data)


async def get_profiles_data() -> Dict[str, Any]:
    return await read_json(PROFILES_FILE, {"last_id": 0, "items": []})


async def save_profiles_data(data: Dict[str, Any]):
    await write_json(PROFILES_FILE, data)


async def get_emails_data() -> Dict[str, Any]:
    return await read_json(EMAILS_FILE, {"last_id": 0, "items": []})


async def save_emails_data(data: Dict[str, Any]):
    await write_json(EMAILS_FILE, data)


async def get_minus_data() -> Dict[str, Any]:
    return await read_json(MINUS_FILE, {"items": []})


async def save_minus_data(data: Dict[str, Any]):
    await write_json(MINUS_FILE, data)


def find_profile_by_key(items: List[Dict[str, Any]], inbound_key: str) -> Optional[Dict[str, Any]]:
    for p in items:
        if p.get("inbound_key") == inbound_key:
            return p
    return None


def find_profile_by_id(items: List[Dict[str, Any]], pid: int) -> Optional[Dict[str, Any]]:
    for p in items:
        if int(p.get("id", 0)) == pid:
            return p
    return None


def find_email_record(items: List[Dict[str, Any]], profile_id: int, email: str) -> Optional[Dict[str, Any]]:
    el = email.lower()
    for e in items:
        if int(e.get("profile_id", 0)) == profile_id and e.get("email", "").lower() == el:
            return e
    return None


def find_email_by_id(items: List[Dict[str, Any]], eid: int) -> Optional[Dict[str, Any]]:
    for e in items:
        if int(e.get("id", 0)) == eid:
            return e
    return None


# =========================================================
# Unisender
# =========================================================

UNISENDER_SUBSCRIBE_URL = "https://api.unisender.com/ru/api/subscribe"


async def call_unisender(profile: Dict[str, Any], email: str) -> Tuple[bool, str]:
    params = {
        "format": "json",
        "api_key": profile["apikey"],
        "list_ids": profile["list_id"],
        "double_optin": str(profile.get("double_optin", 3)),
        "overwrite": str(profile.get("overwrite", 1)),
        "fields[email]": email
    }
    tag = (profile.get("tag") or "").strip()
    if tag:
        params["tags"] = tag

    try:
        async with httpx.AsyncClient(timeout=20) as client:
            r = await client.get(UNISENDER_SUBSCRIBE_URL, params=params)
            text = r.text
            if r.status_code != 200:
                return False, f"HTTP {r.status_code}: {text[:500]}"
            if '"error"' in text.lower():
                return False, text[:1000]
            return True, text[:1000]
    except Exception as e:
        return False, f"Exception: {e!r}"


def outgoing_template(profile: Dict[str, Any]) -> str:
    tag = (profile.get("tag") or "").strip()
    tags_part = f"&tags={tag}" if tag else "&tags="
    return (
        "https://api.unisender.com/ru/api/subscribe?format=json"
        f"&api_key={profile['apikey']}"
        f"&list_ids={profile['list_id']}"
        "&fields[email]={EMAIL}"
        f"{tags_part}"
        f"&double_optin={profile.get('double_optin', 3)}"
        f"&overwrite={profile.get('overwrite', 1)}"
    )


# =========================================================
# Bot
# =========================================================

bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()


def admin_only(user_id: int) -> bool:
    return user_id == ADMIN_ID


def approval_kb(email_id: int):
    kb = InlineKeyboardBuilder()
    kb.button(text="✅ Подтвердить", callback_data=f"approve:{email_id}")
    kb.button(text="❌ Отклонить", callback_data=f"reject:{email_id}")
    kb.adjust(2)
    return kb.as_markup()


# =========================================================
# Commands - Profiles
# =========================================================

@dp.message(Command("start"))
async def cmd_start(message: Message):
    if not admin_only(message.from_user.id):
        return

    await message.answer(
        "Бот подтверждения email → Unisender.\n\n"
        "Профили:\n"
        "/wh_new name=site1 apikey=XXX list_id=49 tag=TAG double=3 overwrite=1\n"
        "/wh_list\n"
        "/wh_set <id> key=value ...\n"
        "/wh_del <id>\n"
        "/wh_stats <id>\n"
        "/wh_show <id>\n\n"
        "Минус-фильтры:\n"
        "/minus <фраза>\n"
        "/minus_list\n"
        "/minus_del <фраза1> <фраза2> ...\n\n"
        "Входящие ссылки:\n"
        f"{BASE_URL}/inbound/INBOUND_KEY?email=test@example.com\n"
        f"{BASE_URL}/inbound?key=INBOUND_KEY&email=test@example.com"
    )


@dp.message(Command("wh_new"))
async def cmd_wh_new(message: Message):
    if not admin_only(message.from_user.id):
        return

    args = message.text.replace("/wh_new", "", 1).strip()
    kv = parse_kv(args)

    name = kv.get("name")
    apikey = kv.get("apikey")
    list_id = kv.get("list_id")
    tag = kv.get("tag", "")

    double_optin = int(kv.get("double", kv.get("double_optin", "3")))
    overwrite = int(kv.get("overwrite", "1"))

    if not (name and apikey and list_id):
        await message.answer(
            "Нужно минимум: name, apikey, list_id.\n"
            "Пример:\n"
            "/wh_new name=site1 apikey=XXX list_id=49 tag=PAY double=3 overwrite=1"
        )
        return

    if double_optin not in (0, 3, 4):
        await message.answer("double_optin допустим: 0, 3, 4.")
        return
    if overwrite not in (0, 1, 2):
        await message.answer("overwrite допустим: 0, 1, 2.")
        return

    inbound_key = kv.get("inbound_key") or secrets.token_urlsafe(10)

    async with DATA_LOCK:
        profiles_data = await get_profiles_data()
        items = profiles_data["items"]

        if any(p.get("inbound_key") == inbound_key for p in items):
            await message.answer("Такой inbound_key уже существует. Укажи другой.")
            return

        profiles_data["last_id"] += 1
        pid = profiles_data["last_id"]

        items.append({
            "id": pid,
            "name": name,
            "apikey": apikey,
            "list_id": str(list_id),
            "tag": tag,
            "double_optin": double_optin,
            "overwrite": overwrite,
            "inbound_key": inbound_key,
            "created_at": now_iso()
        })

        await save_profiles_data(profiles_data)

    inbound_url = f"{BASE_URL}/inbound/{inbound_key}?email=example@mail.com"
    await message.answer(
        "Профиль создан.\n"
        f"ID: {pid}\n"
        f"Name: {name}\n"
        f"Inbound key: {inbound_key}\n\n"
        "Готовая ссылка для входящего вызова:\n"
        f"{inbound_url}"
    )


@dp.message(Command("wh_list"))
async def cmd_wh_list(message: Message):
    if not admin_only(message.from_user.id):
        return

    async with DATA_LOCK:
        profiles_data = await get_profiles_data()
        items = sorted(profiles_data["items"], key=lambda x: int(x.get("id", 0)))

    if not items:
        await message.answer("Профилей нет. Создай через /wh_new.")
        return

    lines = []
    for p in items:
        inbound_url = f"{BASE_URL}/inbound/{p['inbound_key']}?email=test@example.com"
        lines.append(
            f"#{p['id']} {p['name']}\n"
            f"list_id={p['list_id']} tag={p.get('tag') or '-'} "
            f"double={p.get('double_optin', 3)} overwrite={p.get('overwrite', 1)}\n"
            f"inbound: {inbound_url}\n"
        )

    await message.answer("\n".join(lines))


@dp.message(Command("wh_set"))
async def cmd_wh_set(message: Message):
    if not admin_only(message.from_user.id):
        return

    parts = message.text.split(maxsplit=2)
    if len(parts) < 3 or not parts[1].isdigit():
        await message.answer("Пример: /wh_set 1 tag=NEW overwrite=2")
        return

    pid = int(parts[1])
    kv = parse_kv(parts[2])

    allowed = {"name", "apikey", "list_id", "tag", "double", "double_optin", "overwrite", "inbound_key"}
    kv = {k: v for k, v in kv.items() if k in allowed}

    if not kv:
        await message.answer("Нет допустимых параметров.")
        return

    async with DATA_LOCK:
        profiles_data = await get_profiles_data()
        items = profiles_data["items"]
        p = find_profile_by_id(items, pid)
        if not p:
            await message.answer("Профиль не найден.")
            return

        if "inbound_key" in kv:
            new_key = kv["inbound_key"]
            if any(x.get("inbound_key") == new_key and int(x.get("id", 0)) != pid for x in items):
                await message.answer("Такой inbound_key уже занят.")
                return
            p["inbound_key"] = new_key

        if "name" in kv:
            p["name"] = kv["name"]
        if "apikey" in kv:
            p["apikey"] = kv["apikey"]
        if "list_id" in kv:
            p["list_id"] = str(kv["list_id"])
        if "tag" in kv:
            p["tag"] = kv["tag"]

        if "double" in kv or "double_optin" in kv:
            d = int(kv.get("double", kv.get("double_optin")))
            if d not in (0, 3, 4):
                await message.answer("double_optin допустим: 0, 3, 4.")
                return
            p["double_optin"] = d

        if "overwrite" in kv:
            o = int(kv["overwrite"])
            if o not in (0, 1, 2):
                await message.answer("overwrite допустим: 0, 1, 2.")
                return
            p["overwrite"] = o

        await save_profiles_data(profiles_data)

    await message.answer(f"Профиль #{pid} обновлён.")


@dp.message(Command("wh_del"))
async def cmd_wh_del(message: Message):
    if not admin_only(message.from_user.id):
        return

    parts = message.text.split()
    if len(parts) < 2 or not parts[1].isdigit():
        await message.answer("Пример: /wh_del 1")
        return
    pid = int(parts[1])

    async with DATA_LOCK:
        profiles_data = await get_profiles_data()
        before = len(profiles_data["items"])
        profiles_data["items"] = [p for p in profiles_data["items"] if int(p.get("id", 0)) != pid]
        after = len(profiles_data["items"])
        await save_profiles_data(profiles_data)

        emails_data = await get_emails_data()
        emails_data["items"] = [e for e in emails_data["items"] if int(e.get("profile_id", 0)) != pid]
        await save_emails_data(emails_data)

    if before == after:
        await message.answer(f"Профиль #{pid} не найден.")
    else:
        await message.answer(f"Профиль #{pid} удалён вместе с данными.")


@dp.message(Command("wh_show"))
async def cmd_wh_show(message: Message):
    if not admin_only(message.from_user.id):
        return

    parts = message.text.split()
    if len(parts) < 2 or not parts[1].isdigit():
        await message.answer("Пример: /wh_show 1")
        return
    pid = int(parts[1])

    async with DATA_LOCK:
        profiles_data = await get_profiles_data()
        p = find_profile_by_id(profiles_data["items"], pid)

    if not p:
        await message.answer("Профиль не найден.")
        return

    await message.answer(
        f"Профиль #{p['id']} {p['name']}\n\n"
        "Шаблон исходящего вызова:\n"
        f"{outgoing_template(p)}\n\n"
        "Где {EMAIL} бот подставляет подтверждённый адрес."
    )


@dp.message(Command("wh_stats"))
async def cmd_wh_stats(message: Message):
    if not admin_only(message.from_user.id):
        return

    parts = message.text.split()
    if len(parts) < 2 or not parts[1].isdigit():
        await message.answer("Пример: /wh_stats 1")
        return
    pid = int(parts[1])

    async with DATA_LOCK:
        profiles_data = await get_profiles_data()
        p = find_profile_by_id(profiles_data["items"], pid)
        if not p:
            await message.answer("Профиль не найден.")
            return

        emails_data = await get_emails_data()
        items = [e for e in emails_data["items"] if int(e.get("profile_id", 0)) == pid]

    counts = {"pending": 0, "approved": 0, "rejected": 0, "skipped": 0}
    for e in items:
        s = e.get("status", "pending")
        counts[s] = counts.get(s, 0) + 1

    await message.answer(
        f"Статистика профиля #{pid} ({p['name']}):\n"
        f"pending: {counts.get('pending', 0)}\n"
        f"approved: {counts.get('approved', 0)}\n"
        f"rejected: {counts.get('rejected', 0)}\n"
        f"skipped: {counts.get('skipped', 0)}"
    )


# =========================================================
# Commands - Minus
# =========================================================

@dp.message(Command("minus"))
async def cmd_minus_add(message: Message):
    if not admin_only(message.from_user.id):
        return

    phrase = message.text.replace("/minus", "", 1).strip()
    if not phrase:
        await message.answer("Пример: /minus spam")
        return

    async with DATA_LOCK:
        minus_data = await get_minus_data()
        items = minus_data["items"]
        if phrase not in items:
            items.append(phrase)
            items.sort(key=lambda x: x.lower())
        await save_minus_data(minus_data)

    await message.answer(f"Добавлено в минус: {phrase}")


@dp.message(Command("minus_list"))
async def cmd_minus_list(message: Message):
    if not admin_only(message.from_user.id):
        return

    async with DATA_LOCK:
        minus_data = await get_minus_data()
        items = minus_data["items"]

    if not items:
        await message.answer("Минус-условий нет.")
        return

    await message.answer("Минус-условия:\n" + "\n".join(f"- {p}" for p in items))


@dp.message(Command("minus_del"))
async def cmd_minus_del(message: Message):
    if not admin_only(message.from_user.id):
        return

    parts = message.text.split()
    if len(parts) < 2:
        await message.answer("Пример: /minus_del spam temp")
        return

    targets = parts[1:]

    async with DATA_LOCK:
        minus_data = await get_minus_data()
        minus_data["items"] = [p for p in minus_data["items"] if p not in targets]
        await save_minus_data(minus_data)

    await message.answer("Удалено (если было): " + ", ".join(targets))


# =========================================================
# Callbacks
# =========================================================

@dp.callback_query(F.data.startswith("approve:"))
async def cb_approve(query: CallbackQuery):
    if not admin_only(query.from_user.id):
        await query.answer("Нет доступа", show_alert=True)
        return

    eid = int(query.data.split(":", 1)[1])

    # Читаем записи
    async with DATA_LOCK:
        profiles_data = await get_profiles_data()
        emails_data = await get_emails_data()

        e = find_email_by_id(emails_data["items"], eid)
        if not e:
            await query.answer("Запись не найдена", show_alert=True)
            return
        if e.get("status") != "pending":
            await query.answer("Уже обработано", show_alert=True)
            return

        p = find_profile_by_id(profiles_data["items"], int(e["profile_id"]))
        if not p:
            e["status"] = "rejected"
            e["reason"] = "profile_missing"
            e["decided_at"] = now_iso()
            await save_emails_data(emails_data)
            await query.message.edit_text("❌ Профиль удалён. Email отклонён.")
            await query.answer("Профиль не найден", show_alert=True)
            return

    ok, resp_text = await call_unisender(p, e["email"])

    async with DATA_LOCK:
        emails_data = await get_emails_data()
        e2 = find_email_by_id(emails_data["items"], eid)
        if not e2:
            await query.answer("Запись исчезла", show_alert=True)
            return

        if ok:
            e2["status"] = "approved"
            e2["reason"] = ""
        else:
            e2["status"] = "rejected"
            e2["reason"] = "unisender_error"

        e2["unisender_response"] = resp_text
        e2["decided_at"] = now_iso()

        await save_emails_data(emails_data)

    if ok:
        await query.message.edit_text(
            f"✅ Подтверждено и отправлено в Unisender.\n"
            f"Профиль: {p['name']} (#{p['id']})\n"
            f"Email: {e['email']}"
        )
        await query.answer("Готово")
    else:
        await query.message.edit_text(
            f"⚠️ Ошибка Unisender, email отклонён.\n"
            f"Профиль: {p['name']} (#{p['id']})\n"
            f"Email: {e['email']}\n\n"
            f"Ответ: {resp_text[:400]}"
        )
        await query.answer("Ошибка отправки")


@dp.callback_query(F.data.startswith("reject:"))
async def cb_reject(query: CallbackQuery):
    if not admin_only(query.from_user.id):
        await query.answer("Нет доступа", show_alert=True)
        return

    eid = int(query.data.split(":", 1)[1])

    async with DATA_LOCK:
        profiles_data = await get_profiles_data()
        emails_data = await get_emails_data()

        e = find_email_by_id(emails_data["items"], eid)
        if not e:
            await query.answer("Запись не найдена", show_alert=True)
            return
        if e.get("status") != "pending":
            await query.answer("Уже обработано", show_alert=True)
            return

        p = find_profile_by_id(profiles_data["items"], int(e["profile_id"]))

        e["status"] = "rejected"
        e["reason"] = "admin_reject"
        e["decided_at"] = now_iso()

        await save_emails_data(emails_data)

    pname = p["name"] if p else "unknown"
    await query.message.edit_text(
        f"❌ Отклонено.\nПрофиль: {pname}\nEmail: {e['email']}"
    )
    await query.answer("Отклонено")


# =========================================================
# FastAPI inbound
# =========================================================

class InboundPayload(BaseModel):
    email: Optional[str] = None
    key: Optional[str] = None  # если захотят слать key в JSON


app = FastAPI(title="Email approval bot (GET link inbound)")


async def process_inbound(inbound_key: str, email: str) -> Dict[str, Any]:
    email = (email or "").strip()

    if not inbound_key:
        raise HTTPException(status_code=400, detail="key is required")
    if not email:
        raise HTTPException(status_code=400, detail="email is required")
    if not EMAIL_RE.match(email):
        raise HTTPException(status_code=400, detail="invalid email")

    async with DATA_LOCK:
        profiles_data = await get_profiles_data()
        emails_data = await get_emails_data()
        minus_data = await get_minus_data()

        profile = find_profile_by_key(profiles_data["items"], inbound_key)
        if not profile:
            raise HTTPException(status_code=404, detail="profile not found")

        # duplicate
        existing = find_email_record(emails_data["items"], int(profile["id"]), email)
        if existing:
            return {
                "status": existing.get("status"),
                "detail": "duplicate",
                "profile": profile.get("name")
            }

        # minus check
        low = email.lower()
        matched = None
        for phrase in minus_data["items"]:
            if phrase.lower() in low:
                matched = phrase
                break

        emails_data["last_id"] += 1
        eid = emails_data["last_id"]

        if matched:
            emails_data["items"].append({
                "id": eid,
                "profile_id": int(profile["id"]),
                "email": email,
                "status": "skipped",
                "reason": f"minus:{matched}",
                "unisender_response": "",
                "created_at": now_iso(),
                "decided_at": now_iso()
            })
            await save_emails_data(emails_data)
            return {"status": "skipped", "reason": f"minus:{matched}", "profile": profile.get("name")}

        # create pending
        emails_data["items"].append({
            "id": eid,
            "profile_id": int(profile["id"]),
            "email": email,
            "status": "pending",
            "reason": "",
            "unisender_response": "",
            "created_at": now_iso(),
            "decided_at": ""
        })
        await save_emails_data(emails_data)

    # notify admin out of lock
    try:
        await bot.send_message(
            chat_id=ADMIN_ID,
            text=(
                "Новый email для подтверждения:\n"
                f"Профиль: {profile['name']} (#{profile['id']})\n"
                f"Email: {email}\n"
                f"double_optin={profile.get('double_optin', 3)} "
                f"overwrite={profile.get('overwrite', 1)}"
            ),
            reply_markup=approval_kb(eid)
        )
    except Exception as e:
        return {"status": "pending", "warning": f"admin_notify_failed: {e!r}", "profile": profile.get("name")}

    return {"status": "pending", "profile": profile.get("name")}


@app.on_event("startup")
async def startup():
    ensure_data_files()
    asyncio.create_task(dp.start_polling(bot))


# ---- Главный сценарий: ссылка с параметрами ----

@app.get("/inbound/{inbound_key}")
async def inbound_by_path(inbound_key: str, email: str):
    """
    Пример:
    /inbound/KEY?email=test@example.com
    """
    return await process_inbound(inbound_key, email)


@app.get("/inbound")
async def inbound_by_query(key: str, email: str):
    """
    Пример:
    /inbound?key=KEY&email=test@example.com
    """
    return await process_inbound(key, email)


# ---- Запасной вариант: POST JSON ----

@app.post("/inbound/{inbound_key}")
async def inbound_post_path(inbound_key: str, payload: InboundPayload):
    email = payload.email
    return await process_inbound(inbound_key, email)


@app.post("/inbound")
async def inbound_post_query(payload: InboundPayload, request: Request):
    # key можно в JSON или query
    key = payload.key or request.query_params.get("key")
    email = payload.email
    return await process_inbound(key, email)


# =========================================================
# Local run
# =========================================================

if __name__ == "__main__":
    import uvicorn
    ensure_data_files()
    uvicorn.run("bot:app", host=HOST, port=PORT, reload=False)
