from __future__ import annotations

from uuid import uuid4

import pytest
from sqlalchemy import select

from dms.extensions import db
from dms.models import AuditLog, Category, Document, User


def _auth(token: str) -> dict[str, str]:
    return {"Authorization": f"Bearer {token}"}


def _category(app, code: str) -> Category:
    with app.app_context():
        category = db.session.scalar(
            select(Category).where(Category.category_code == code)
        )
        assert category is not None
        db.session.expunge(category)
        return category


def _create_payload(**overrides):
    payload = {
        "categoryCode": "NEW_CODE",
        "categoryName": "新分类",
        "categoryType": "SCENE",
        "parentId": None,
        "sortNo": 10,
    }
    payload.update(overrides)
    return payload


def _update_payload(category: Category, **overrides):
    payload = {
        "categoryName": category.category_name,
        "categoryType": category.category_type,
        "parentId": str(category.parent_id) if category.parent_id else None,
        "sortNo": category.sort_no,
        "rowVersion": category.row_version,
    }
    payload.update(overrides)
    return payload


def _document(
    *,
    document_type: str,
    category: Category | None,
    parent: Document | None = None,
) -> Document:
    kwargs = {
        "document_name": f"{document_type}测试文档",
        "document_type": document_type,
        "document_status": "DRAFT",
        "security_level": "PUBLIC" if document_type == "ATTACHMENT" else "INTERNAL",
        "visibility_type": (
            "ALL_AUTHENTICATED" if document_type == "ATTACHMENT" else "CUSTOM"
        ),
        "attachment_type": "OTHER" if document_type == "ATTACHMENT" else None,
        "category_id": category.id if category else None,
        "category_name": category.category_name if category else None,
        "category_path": category.category_path if category else None,
        "parent_document_id": parent.id if parent else None,
        "root_document_id": parent.id if parent else None,
        "original_file_name": "test.pdf",
        "file_relative_path": "original/test.pdf",
        "file_extension": "pdf",
        "file_hash": "0" * 64,
    }
    return Document(**kwargs)


def test_category_tree_requires_authentication(b3_client):
    response = b3_client.get("/api/v1/categories/tree")
    assert response.status_code == 401
    assert response.get_json()["code"] == "TOKEN_INVALID"


@pytest.mark.parametrize("username", ["user", "admin"])
def test_all_enabled_roles_can_query_tree(b3_client, token_for, username):
    response = b3_client.get(
        "/api/v1/categories/tree",
        headers=_auth(token_for(username)),
    )
    assert response.status_code == 200


def test_tree_defaults_to_enabled_and_omits_deleted(b3_client, login_token):
    data = b3_client.get(
        "/api/v1/categories/tree", headers=_auth(login_token)
    ).get_json()["data"]
    codes = {node["categoryCode"] for node in data}
    assert "DISABLED_ROOT" not in codes
    assert "DELETED_CODE" not in codes


def test_tree_status_filter(b3_client, login_token):
    data = b3_client.get(
        "/api/v1/categories/tree?status=DISABLED",
        headers=_auth(login_token),
    ).get_json()["data"]
    assert [node["categoryCode"] for node in data] == ["DISABLED_ROOT"]


def test_tree_stable_sort_and_string_ids(b3_client, login_token):
    data = b3_client.get(
        "/api/v1/categories/tree", headers=_auth(login_token)
    ).get_json()["data"]
    assert [node["categoryCode"] for node in data] == [
        "ROOT_A",
        "ROOT_B",
        "ROOT_C",
    ]
    assert all(isinstance(node["id"], str) for node in data)
    assert data[0]["parentId"] is None
    assert [node["categoryCode"] for node in data[0]["children"]] == [
        "CHILD_A1",
        "CHILD_A2",
    ]


@pytest.mark.parametrize(
    "query,expected",
    [
        ("keyword=样式A1", ["CHILD_A1"]),
        ("keyword=CHILD_A2", ["CHILD_A2"]),
    ],
)
def test_keyword_matches_name_or_code_and_preserves_ancestor(
    b3_client, login_token, query, expected
):
    data = b3_client.get(
        f"/api/v1/categories/tree?{query}",
        headers=_auth(login_token),
    ).get_json()["data"]
    assert [node["categoryCode"] for node in data] == ["ROOT_A"]
    assert [node["categoryCode"] for node in data[0]["children"]] == expected


def test_keyword_parent_match_includes_descendants(b3_client, login_token):
    data = b3_client.get(
        "/api/v1/categories/tree?keyword=ROOT_A",
        headers=_auth(login_token),
    ).get_json()["data"]
    assert [node["categoryCode"] for node in data[0]["children"]] == [
        "CHILD_A1",
        "CHILD_A2",
    ]


def test_create_top_level_category(b3_app, b3_client, login_token):
    response = b3_client.post(
        "/api/v1/categories",
        json=_create_payload(categoryCode="  TOP_NEW  ", categoryName="  顶级新分类  "),
        headers=_auth(login_token),
    )
    data = response.get_json()["data"]
    assert response.status_code == 201
    assert data["categoryCode"] == "TOP_NEW"
    assert data["categoryName"] == "顶级新分类"
    assert data["categoryPath"] == "/顶级新分类"
    assert data["documentCount"] == 0
    assert data["rowVersion"] == 0
    with b3_app.app_context():
        category = db.session.scalar(
            select(Category).where(Category.category_code == "TOP_NEW")
        )
        admin_id = db.session.scalar(
            select(User.id).where(User.username == "admin")
        )
        assert category.created_by == admin_id
        assert category.updated_by == admin_id


def test_create_child_category_path(b3_app, b3_client, login_token):
    parent = _category(b3_app, "ROOT_B")
    response = b3_client.post(
        "/api/v1/categories",
        json=_create_payload(
            categoryCode="NEW_CHILD",
            categoryName="新子类",
            categoryType="STYLE",
            parentId=str(parent.id),
        ),
        headers=_auth(login_token),
    )
    data = response.get_json()["data"]
    assert response.status_code == 201
    assert data["parentId"] == str(parent.id)
    assert data["categoryPath"] == "/场景根B/新子类"


def test_create_rejects_invalid_category_type(b3_client, login_token):
    response = b3_client.post(
        "/api/v1/categories",
        json=_create_payload(categoryType="BAD"),
        headers=_auth(login_token),
    )
    assert response.status_code == 400
    assert response.get_json()["code"] == "INVALID_ARGUMENT"


def test_create_rejects_missing_parent(b3_client, login_token):
    response = b3_client.post(
        "/api/v1/categories",
        json=_create_payload(parentId="99999999"),
        headers=_auth(login_token),
    )
    assert response.status_code == 400


def test_create_rejects_disabled_parent(b3_app, b3_client, login_token):
    parent = _category(b3_app, "DISABLED_ROOT")
    response = b3_client.post(
        "/api/v1/categories",
        json=_create_payload(parentId=str(parent.id)),
        headers=_auth(login_token),
    )
    assert response.status_code == 400


@pytest.mark.parametrize("category_code", ["ROOT_A", "DELETED_CODE"])
def test_category_code_is_permanently_unique(
    b3_client, login_token, category_code
):
    response = b3_client.post(
        "/api/v1/categories",
        json=_create_payload(categoryCode=category_code),
        headers=_auth(login_token),
    )
    assert response.status_code == 409


@pytest.mark.parametrize("username", ["user"])
def test_non_admin_cannot_create(b3_client, token_for, username):
    response = b3_client.post(
        "/api/v1/categories",
        json=_create_payload(),
        headers=_auth(token_for(username)),
    )
    assert response.status_code == 403


def test_edit_name_and_version(b3_app, b3_client, login_token):
    category = _category(b3_app, "ROOT_B")
    response = b3_client.put(
        f"/api/v1/categories/{category.id}",
        json=_update_payload(category, categoryName="新场景B"),
        headers=_auth(login_token),
    )
    data = response.get_json()["data"]
    assert response.status_code == 200
    assert data["categoryName"] == "新场景B"
    assert data["categoryPath"] == "/新场景B"
    assert data["rowVersion"] == category.row_version + 1


def test_move_category(b3_app, b3_client, login_token):
    category = _category(b3_app, "CHILD_A1")
    parent = _category(b3_app, "ROOT_B")
    response = b3_client.put(
        f"/api/v1/categories/{category.id}",
        json=_update_payload(category, parentId=str(parent.id)),
        headers=_auth(login_token),
    )
    data = response.get_json()["data"]
    assert response.status_code == 200
    assert data["parentId"] == str(parent.id)
    assert data["categoryPath"] == "/场景根B/样式A1"


def test_descendant_paths_are_synchronized(b3_app, b3_client, login_token):
    with b3_app.app_context():
        child = db.session.scalar(
            select(Category).where(Category.category_code == "CHILD_A1")
        )
        grandchild = Category(
            category_code="GRANDCHILD",
            category_name="孙分类",
            category_type="OTHER",
            parent_id=child.id,
            category_path=f"{child.category_path}/孙分类",
            sort_no=1,
            status="ENABLED",
        )
        db.session.add(grandchild)
        db.session.commit()
    root = _category(b3_app, "ROOT_A")
    response = b3_client.put(
        f"/api/v1/categories/{root.id}",
        json=_update_payload(root, categoryName="新根A"),
        headers=_auth(login_token),
    )
    assert response.status_code == 200
    assert _category(b3_app, "CHILD_A1").category_path == "/新根A/样式A1"
    assert (
        _category(b3_app, "GRANDCHILD").category_path
        == "/新根A/样式A1/孙分类"
    )


def test_document_category_redundancy_is_synchronized(
    b3_app, b3_client, login_token
):
    with b3_app.app_context():
        category = db.session.scalar(
            select(Category).where(Category.category_code == "CHILD_A1")
        )
        document = _document(document_type="MAIN", category=category)
        db.session.add(document)
        db.session.commit()
        document_id = document.id
    root = _category(b3_app, "ROOT_A")
    response = b3_client.put(
        f"/api/v1/categories/{root.id}",
        json=_update_payload(root, categoryName="同步根"),
        headers=_auth(login_token),
    )
    assert response.status_code == 200
    with b3_app.app_context():
        document = db.session.get(Document, document_id)
        assert document.category_name == "样式A1"
        assert document.category_path == "/同步根/样式A1"
        assert document.row_version == 1


def test_sort_only_edit_does_not_touch_descendants_or_documents(
    b3_app, b3_client, login_token
):
    with b3_app.app_context():
        child = db.session.scalar(
            select(Category).where(Category.category_code == "CHILD_A1")
        )
        document = _document(document_type="MAIN", category=child)
        db.session.add(document)
        db.session.commit()
        child_id = child.id
        document_id = document.id
        child_version = child.row_version
        document_version = document.row_version
    root = _category(b3_app, "ROOT_A")
    response = b3_client.put(
        f"/api/v1/categories/{root.id}",
        json=_update_payload(root, sortNo=99),
        headers=_auth(login_token),
    )
    assert response.status_code == 200
    with b3_app.app_context():
        assert db.session.get(Category, child_id).row_version == child_version
        assert db.session.get(Document, document_id).row_version == document_version


def test_move_to_self_is_rejected(b3_app, b3_client, login_token):
    category = _category(b3_app, "ROOT_A")
    response = b3_client.put(
        f"/api/v1/categories/{category.id}",
        json=_update_payload(category, parentId=str(category.id)),
        headers=_auth(login_token),
    )
    assert response.status_code == 400


def test_move_to_descendant_is_rejected(b3_app, b3_client, login_token):
    category = _category(b3_app, "ROOT_A")
    child = _category(b3_app, "CHILD_A1")
    response = b3_client.put(
        f"/api/v1/categories/{category.id}",
        json=_update_payload(category, parentId=str(child.id)),
        headers=_auth(login_token),
    )
    assert response.status_code == 400


def test_update_row_version_conflict(b3_app, b3_client, login_token):
    category = _category(b3_app, "ROOT_B")
    response = b3_client.put(
        f"/api/v1/categories/{category.id}",
        json=_update_payload(category, rowVersion=category.row_version + 1),
        headers=_auth(login_token),
    )
    assert response.status_code == 409
    assert response.get_json()["code"] == "DATA_VERSION_CONFLICT"
    assert response.get_json()["details"]["currentRowVersion"] == 0


def test_delete_with_descendants_conflicts(b3_app, b3_client, login_token):
    category = _category(b3_app, "ROOT_A")
    response = b3_client.delete(
        f"/api/v1/categories/{category.id}?rowVersion={category.row_version}",
        headers=_auth(login_token),
    )
    assert response.status_code == 409
    payload = response.get_json()
    assert payload["code"] == "CATEGORY_IN_USE"
    assert payload["details"]["childCategoryCount"] == 2
    assert payload["details"]["documentCount"] == 0
    assert payload["details"]["categoryId"] == str(category.id)


@pytest.mark.parametrize("document_type", ["MAIN", "SUB_PLAN"])
def test_delete_with_plan_document_conflicts(
    b3_app, b3_client, login_token, document_type
):
    with b3_app.app_context():
        category = db.session.scalar(
            select(Category).where(Category.category_code == "ROOT_C")
        )
        if document_type == "MAIN":
            document = _document(document_type="MAIN", category=category)
            db.session.add(document)
        else:
            main = _document(document_type="MAIN", category=category)
            db.session.add(main)
            db.session.flush()
            db.session.add(
                _document(document_type="SUB_PLAN", category=category, parent=main)
            )
            main.is_deleted = True
        db.session.commit()
    category = _category(b3_app, "ROOT_C")
    response = b3_client.delete(
        f"/api/v1/categories/{category.id}?rowVersion={category.row_version}",
        headers=_auth(login_token),
    )
    assert response.status_code == 409
    assert response.get_json()["details"]["documentCount"] == 1


def test_attachment_does_not_block_category_delete(
    b3_app, b3_client, login_token
):
    with b3_app.app_context():
        db.session.add(_document(document_type="ATTACHMENT", category=None))
        db.session.commit()
    category = _category(b3_app, "ROOT_C")
    response = b3_client.delete(
        f"/api/v1/categories/{category.id}?rowVersion={category.row_version}",
        headers=_auth(login_token),
    )
    assert response.status_code == 200


def test_successful_delete_is_logical(b3_app, b3_client, login_token):
    category = _category(b3_app, "ROOT_C")
    response = b3_client.delete(
        f"/api/v1/categories/{category.id}?rowVersion={category.row_version}",
        headers=_auth(login_token),
    )
    assert response.status_code == 200
    assert response.get_json()["data"] == {
        "id": str(category.id),
        "deleted": True,
    }
    with b3_app.app_context():
        deleted = db.session.get(Category, category.id)
        assert deleted.is_deleted is True
        assert deleted.deleted_at is not None
        assert deleted.row_version == category.row_version + 1


@pytest.mark.parametrize(
    "operation,action",
    [
        ("create", "CREATE_CATEGORY"),
        ("edit", "EDIT_CATEGORY"),
        ("delete", "DELETE_CATEGORY"),
    ],
)
def test_category_success_audit(
    b3_app, b3_client, login_token, operation, action
):
    request_id = str(uuid4())
    headers = {
        **_auth(login_token),
        "X-Request-Id": request_id,
        "User-Agent": "b3-category-test",
    }
    if operation == "create":
        response = b3_client.post(
            "/api/v1/categories",
            json=_create_payload(categoryCode="AUDIT_CREATE"),
            headers=headers,
        )
        target_id = response.get_json()["data"]["id"]
    else:
        category = _category(b3_app, "ROOT_C")
        target_id = str(category.id)
        if operation == "edit":
            response = b3_client.put(
                f"/api/v1/categories/{category.id}",
                json=_update_payload(category, categoryName="审计编辑"),
                headers=headers,
            )
        else:
            response = b3_client.delete(
                f"/api/v1/categories/{category.id}?rowVersion={category.row_version}",
                headers=headers,
            )
    assert response.status_code in {200, 201}
    with b3_app.app_context():
        audit = db.session.scalar(
            select(AuditLog).where(AuditLog.action_type == action)
        )
        assert audit is not None
        assert audit.target_type == "CATEGORY"
        assert str(audit.target_id) == target_id
        assert audit.username == "admin"
        assert audit.real_name == "系统管理员"
        assert audit.organization_name == "机关"
        assert audit.request_id == request_id
        assert audit.user_agent == "b3-category-test"


def test_seed_categories_is_idempotent_and_non_destructive(b3_app):
    from dms.seed_categories import seed_categories

    with b3_app.app_context():
        first = seed_categories()
        second = seed_categories()
        assert first == (5, 0)
        assert second == (0, 5)
        assert db.session.scalar(select(db.func.count(User.id))) == 4
