import asyncio
import json
import sqlite3
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest
from aiogram.dispatcher.event.bases import SkipHandler
from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.base import StorageKey
from aiogram.fsm.storage.memory import MemoryStorage

import master
from project_repository import ProjectRecord, ProjectRepository


@pytest.fixture(autouse=True)
def reset_wizard_state():
    master.PROJECT_WIZARD_SESSIONS.clear()
    master.PROJECT_WIZARD_LOCK = asyncio.Lock()
    yield
    master.PROJECT_WIZARD_SESSIONS.clear()
    master.PROJECT_WIZARD_LOCK = asyncio.Lock()
    master.PROJECT_REPOSITORY = None
    master.MANAGER = None


@pytest.fixture
def repo(tmp_path: Path, monkeypatch) -> ProjectRepository:
    config_dir = tmp_path / "config"
    config_dir.mkdir()
    json_path = config_dir / "projects.json"
    initial = [
        {
            "bot_name": "SampleBot",
            "bot_token": "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
            "project_slug": "sample",
            "default_model": "codex",
            "workdir": str(tmp_path),
            "allowed_chat_id": 100,
        }
    ]
    json_path.write_text(json.dumps(initial, ensure_ascii=False, indent=2), encoding="utf-8")
    db_path = config_dir / "master.db"
    repository = ProjectRepository(db_path, json_path)
    master.PROJECT_REPOSITORY = repository
    monkeypatch.setenv("MASTER_ADMIN_IDS", "1")
    return repository


def _build_manager(repo: ProjectRepository, tmp_path: Path) -> master.MasterManager:
    records = repo.list_projects()
    configs = [master.ProjectConfig.from_dict(record.to_dict()) for record in records]
    state_path = tmp_path / "state.json"
    state_store = master.StateStore(state_path, {cfg.project_slug: cfg for cfg in configs})
    return master.MasterManager(configs, state_store=state_store)


def _build_fsm_state(chat_id: int = 1, user_id: int = 1) -> tuple[MemoryStorage, FSMContext]:
    """构造 FSM 上下文，便于测试状态流程。"""
    storage = MemoryStorage()
    key = StorageKey(bot_id=0, chat_id=chat_id, user_id=user_id)
    return storage, FSMContext(storage=storage, key=key)


class DummyMessage:
    def __init__(self, chat_id: int = 1) -> None:
        self.text = ""
        self.chat = SimpleNamespace(id=chat_id)
        self.from_user = SimpleNamespace(id=chat_id, username="tester")
        self.message_id = 1
        self.bot = AsyncMock()
        self._answers = []
        self._edits = []

    async def answer(self, text: str, **kwargs):
        self._answers.append((text, kwargs))

    async def edit_text(self, text: str, **kwargs):
        self._edits.append((text, kwargs))

    async def edit_reply_markup(self, **kwargs):
        self._edits.append(("reply_markup", kwargs))


class DummyCallback:
    def __init__(self, data: str, chat_id: int = 1, message: DummyMessage | None = None) -> None:
        self.data = data
        self.from_user = SimpleNamespace(id=chat_id)
        self.message = message or DummyMessage(chat_id)
        self._answers = []

    async def answer(self, text: str | None = None, show_alert: bool = False):
        self._answers.append((text, show_alert))


def test_repository_initial_import_creates_backup(tmp_path: Path):
    config_dir = tmp_path / "cfg"
    config_dir.mkdir()
    json_path = config_dir / "projects.json"
    data = [
        {
            "bot_name": "InitBot",
            "bot_token": "654321:ABCDEFGHIJKLMNOPQRSTUVWXYZ987654",
            "project_slug": "init",
            "default_model": "codex",
        }
    ]
    json_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    repo = ProjectRepository(config_dir / "master.db", json_path)
    records = repo.list_projects()
    assert len(records) == 1
    backups = list(config_dir.glob("projects.json.*.bak"))
    assert backups, "初始化应生成 JSON 备份"
    exported = json.loads(json_path.read_text(encoding="utf-8"))
    assert exported[0]["project_slug"] == "init"


def test_repository_insert_updates_json(repo: ProjectRepository):
    new_record = ProjectRecord(
        bot_name="NewManageBot",
        bot_token="111111:ABCDEFGHIJKLMNOPQRSTUVWXYZ000000",
        project_slug="manage",
        default_model="codex",
        workdir=None,
        allowed_chat_id=None,
        legacy_name=None,
    )
    repo.insert_project(new_record)
    exported = json.loads(repo.json_path.read_text(encoding="utf-8"))
    slugs = {item["project_slug"] for item in exported}
    assert "manage" in slugs


def test_repository_insert_normalizes_fields(repo: ProjectRepository, tmp_path: Path):
    workdir_dir = tmp_path / "workspace"
    workdir_dir.mkdir()
    messy = ProjectRecord(
        bot_name=" @MixedBot ",
        bot_token="333333:ABCDEFGHIJKLMNOPQRSTUVWXYZ999999",
        project_slug=" Mixed Slug ",
        default_model=" CODEX ",
        workdir=str(workdir_dir) + "  ",
        allowed_chat_id=None,
        legacy_name=" Legacy Name ",
    )
    repo.insert_project(messy)
    stored = repo.get_by_slug("Mixed Slug")
    assert stored is not None
    assert stored.project_slug == "mixed-slug"
    assert stored.bot_name == "MixedBot"
    assert stored.default_model == "codex"
    assert stored.workdir == str(workdir_dir)
    exported = json.loads(repo.json_path.read_text(encoding="utf-8"))
    targets = [item for item in exported if item["bot_name"] == "MixedBot"]
    assert targets, "JSON 应写入归一化后的记录"
    assert targets[0]["project_slug"] == "mixed-slug"


def test_repository_repair_existing_rows(tmp_path: Path):
    json_path = tmp_path / "projects.json"
    json_path.write_text("[]", encoding="utf-8")
    db_path = tmp_path / "master.db"
    conn = sqlite3.connect(str(db_path))
    conn.execute(
        """
        CREATE TABLE projects (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            bot_name TEXT NOT NULL UNIQUE,
            bot_token TEXT NOT NULL,
            project_slug TEXT NOT NULL UNIQUE,
            default_model TEXT NOT NULL,
            workdir TEXT,
            allowed_chat_id INTEGER,
            legacy_name TEXT,
            created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
            updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
        );
        """
    )
    conn.execute(
        """
        INSERT INTO projects (
            bot_name, bot_token, project_slug, default_model,
            workdir, allowed_chat_id, legacy_name, created_at, updated_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'), strftime('%s','now'));
        """,
        (
            "@LegacyBot ",
            "999999:ABCDEFGHIJKLMNOPQRSTUVWXYZ888888",
            "Legacy Project  ",
            " CODEX ",
            f" {tmp_path} ",
            " 42 ",
            " Legacy Alias ",
        ),
    )
    conn.commit()
    conn.close()
    repo = ProjectRepository(db_path, json_path)
    repaired = repo.get_by_slug("Legacy Project")
    assert repaired is not None
    assert repaired.project_slug == "legacy-project"
    assert repaired.bot_name == "LegacyBot"
    assert repaired.allowed_chat_id == 42
    exported = json.loads(json_path.read_text(encoding="utf-8"))
    assert exported[0]["project_slug"] == "legacy-project"


def test_validate_field_rejects_duplicate_bot(repo: ProjectRepository):
    session = master.ProjectWizardSession(chat_id=1, user_id=1, mode="create")
    value, error = master._validate_field_value(session, "bot_name", "SampleBot")
    assert value is None
    assert error == "该 bot 名已被其它项目占用"


def test_validate_workdir_requires_existing_path(repo: ProjectRepository, tmp_path: Path):
    session = master.ProjectWizardSession(chat_id=1, user_id=1, mode="create")
    missing_value, missing_error = master._validate_field_value(session, "workdir", str(tmp_path / "missing"))
    assert missing_value is None
    assert "目录不存在" in missing_error
    workdir = tmp_path / "workdir"
    workdir.mkdir()
    value, error = master._validate_field_value(session, "workdir", str(workdir))
    assert error is None
    assert value == str(workdir)


def test_start_project_create_registers_session(repo: ProjectRepository, tmp_path: Path):
    manager = _build_manager(repo, tmp_path)
    callback = DummyCallback("project:create:*")

    async def _run():
        await master._start_project_create(callback, manager)

    asyncio.run(_run())
    assert callback.message._answers, "应发送提示消息"
    assert callback.message.chat.id in master.PROJECT_WIZARD_SESSIONS
    assert master.PROJECT_WIZARD_SESSIONS[callback.message.chat.id].mode == "create"


def test_handle_wizard_cancel_clears_session(repo: ProjectRepository, tmp_path: Path):
    manager = _build_manager(repo, tmp_path)
    callback = DummyCallback("project:create:*")

    async def _prepare():
        await master._start_project_create(callback, manager)

    asyncio.run(_prepare())
    message = callback.message
    message.text = "取消"

    async def _cancel():
        await master._handle_wizard_message(message, manager)

    asyncio.run(_cancel())
    assert message.chat.id not in master.PROJECT_WIZARD_SESSIONS


def test_create_flow_writes_repository(repo: ProjectRepository, tmp_path: Path, monkeypatch):
    manager = _build_manager(repo, tmp_path)
    master.MANAGER = manager
    callback = DummyCallback("project:create:*")

    async def _prepare():
        await master._start_project_create(callback, manager)

    asyncio.run(_prepare())
    message = callback.message
    workdir = tmp_path / "new_workdir"
    workdir.mkdir()
    inputs = [
        "NewTesterBot",
        "222222:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456",
        "",  # 让系统自动生成 slug
        "",  # 默认模型回落到 codex
        str(workdir),
        "-10001",
    ]

    async def _run_flow():
        for text in inputs:
            message.text = text
            await master._handle_wizard_message(message, manager)

    asyncio.run(_run_flow())
    records = repo.list_projects()
    assert any(r.project_slug == "newtesterbot" for r in records)
    assert message.bot.send_message.await_count >= 1


def test_repository_delete_case_insensitive(repo: ProjectRepository):
    """确保删除接口在大小写不同的情况下依旧可以命中项目。"""
    repo.delete_project("SAMPLE")
    slugs = {record.project_slug for record in repo.list_projects()}
    assert "sample" not in slugs


def test_delete_flow_removes_project(repo: ProjectRepository, tmp_path: Path):
    manager = _build_manager(repo, tmp_path)
    master.MANAGER = manager
    callback = DummyCallback("project:delete:sample")
    storage, fsm_state = _build_fsm_state()
    async def _prepare():
        cfg = manager.require_project_by_slug("sample")
        await master._start_project_delete(callback, cfg, manager, fsm_state)

    asyncio.run(_prepare())
    assert callback.message._answers, "应发送确认提示"
    confirm = DummyCallback("project:delete_confirm:sample", message=callback.message)

    async def _route_confirm():
        with pytest.raises(SkipHandler):
            await master.on_project_action(confirm, fsm_state)

    asyncio.run(_route_confirm())

    async def _confirm():
        await master.on_project_delete_confirm(confirm, fsm_state)

    asyncio.run(_confirm())
    slugs = {record.project_slug for record in repo.list_projects()}
    assert "sample" not in slugs
    # MemoryStorage 无需显式关闭，但保持引用以便垃圾回收使用


def test_delete_flow_cancel(repo: ProjectRepository, tmp_path: Path):
    manager = _build_manager(repo, tmp_path)
    master.MANAGER = manager
    callback = DummyCallback("project:delete:sample")
    storage, fsm_state = _build_fsm_state()

    async def _prepare():
        cfg = manager.require_project_by_slug("sample")
        await master._start_project_delete(callback, cfg, manager, fsm_state)

    asyncio.run(_prepare())
    cancel = DummyCallback("project:delete_cancel", message=callback.message)

    async def _route_cancel():
        with pytest.raises(SkipHandler):
            await master.on_project_action(cancel, fsm_state)

    asyncio.run(_route_cancel())

    async def _cancel():
        await master.on_project_delete_cancel(cancel, fsm_state)
        return await fsm_state.get_state()

    remaining_state = asyncio.run(_cancel())
    assert remaining_state is None
    slugs = {record.project_slug for record in repo.list_projects()}
    assert "sample" in slugs
    assert cancel.message._answers[-1][0].startswith("已取消删除项目")


def test_delete_flow_text_confirm(repo: ProjectRepository, tmp_path: Path):
    manager = _build_manager(repo, tmp_path)
    master.MANAGER = manager
    callback = DummyCallback("project:delete:sample")
    storage, fsm_state = _build_fsm_state()

    async def _prepare():
        cfg = manager.require_project_by_slug("sample")
        await master._start_project_delete(callback, cfg, manager, fsm_state)

    asyncio.run(_prepare())

    text_message = DummyMessage()
    text_message.text = "确认删除"
    text_message.chat = callback.message.chat
    text_message.from_user = callback.message.from_user
    text_message.bot = callback.message.bot
    text_message.reply_to_message = callback.message

    async def _confirm():
        await master.on_project_delete_text(text_message, fsm_state)

    asyncio.run(_confirm())
    slugs = {record.project_slug for record in repo.list_projects()}
    assert "sample" not in slugs
    assert any("已删除" in answer for answer, _ in text_message._answers)


def test_delete_flow_text_cancel(repo: ProjectRepository, tmp_path: Path):
    manager = _build_manager(repo, tmp_path)
    master.MANAGER = manager
    callback = DummyCallback("project:delete:sample")
    storage, fsm_state = _build_fsm_state()

    async def _prepare():
        cfg = manager.require_project_by_slug("sample")
        await master._start_project_delete(callback, cfg, manager, fsm_state)

    asyncio.run(_prepare())

    text_message = DummyMessage(chat_id=1)
    text_message.text = "取消"
    text_message.chat = callback.message.chat
    text_message.from_user = callback.message.from_user
    text_message.bot = callback.message.bot
    text_message.reply_to_message = callback.message

    async def _cancel():
        await master.on_project_delete_text(text_message, fsm_state)

    asyncio.run(_cancel())
    current_state = asyncio.run(fsm_state.get_state())
    assert current_state is None
    slugs = {record.project_slug for record in repo.list_projects()}
    assert "sample" in slugs
    assert any("已取消删除项目" in answer for answer, _ in text_message._answers)


def test_delete_flow_fallbacks_to_original_slug(repo: ProjectRepository, tmp_path: Path):
    """验证删除流程在 slug 归一化后可以成功执行。"""
    record = repo.get_by_slug("sample")
    updated = ProjectRecord(
        bot_name=record.bot_name,
        bot_token=record.bot_token,
        project_slug="SampleCase",
        default_model=record.default_model,
        workdir=record.workdir,
        allowed_chat_id=record.allowed_chat_id,
        legacy_name=record.legacy_name,
    )
    repo.update_project("sample", updated)
    manager = _build_manager(repo, tmp_path)
    master.MANAGER = manager
    master.PROJECT_REPOSITORY = repo
    callback = DummyCallback("project:delete:samplecase")
    storage, fsm_state = _build_fsm_state()

    async def _prepare():
        cfg = manager.require_project_by_slug("samplecase")
        await master._start_project_delete(callback, cfg, manager, fsm_state)

    asyncio.run(_prepare())

    confirm = DummyCallback("project:delete_confirm:samplecase", message=callback.message)

    async def _confirm():
        await master.on_project_delete_confirm(confirm, fsm_state)

    asyncio.run(_confirm())
    assert repo.get_by_bot_name(record.bot_name) is None


def test_projects_overview_hides_create_button(repo: ProjectRepository, tmp_path: Path):
    manager = _build_manager(repo, tmp_path)
    text, markup = master._projects_overview(manager)
    assert text == "请选择操作："
    assert markup is not None
    labels = [btn.text for row in markup.inline_keyboard for btn in row]
    assert "➕ 新增项目" not in labels
    assert "🚀 启动全部项目" in labels


def test_manage_action_sends_inline_menu(repo: ProjectRepository, tmp_path: Path, monkeypatch):
    manager = _build_manager(repo, tmp_path)
    master.MANAGER = manager
    callback = DummyCallback("project:manage:sample")
    _, fsm_state = _build_fsm_state()

    async def _invoke():
        await master.on_project_action(callback, fsm_state)

    asyncio.run(_invoke())
    assert callback.message._answers, "应发送管理选项"
    buttons = callback.message._answers[0][1]["reply_markup"].inline_keyboard
    texts = [btn.text for row in buttons for btn in row]
    assert "📝 编辑" in texts
    assert "🗑 删除" in texts


def test_manage_button_handler_builds_keyboard(repo: ProjectRepository, tmp_path: Path, monkeypatch):
    manager = _build_manager(repo, tmp_path)

    async def fake_manager():
        return manager

    monkeypatch.setattr(master, "_ensure_manager", fake_manager)
    message = DummyMessage()
    message.text = master.MASTER_MANAGE_BUTTON_TEXT

    async def _invoke():
        await master.on_master_manage_button(message)

    asyncio.run(_invoke())
    assert message._answers
    markup = message._answers[0][1]["reply_markup"]
    labels = [btn.text for row in markup.inline_keyboard for btn in row]
    assert "➕ 新增项目" in labels
    assert any(label.startswith("⚙️ 管理") for label in labels)


def test_refresh_action_skips_slug_validation(repo: ProjectRepository, tmp_path: Path, monkeypatch):
    manager = _build_manager(repo, tmp_path)
    master.MANAGER = manager
    master.PROJECT_REPOSITORY = repo
    callback_message = DummyMessage()
    callback = DummyCallback("project:refresh:*", message=callback_message)
    _, fsm_state = _build_fsm_state()

    async def _invoke():
        await master.on_project_action(callback, fsm_state)

    asyncio.run(_invoke())
    assert callback._answers == [(None, False)]
    assert callback_message._edits, "应刷新项目列表文本"
    text, kwargs = callback_message._edits[0]
    assert text == "请选择操作："
    assert kwargs["reply_markup"] is not None
