"""Web API 路由测试 — Issue #246 Step 4

覆盖 21 个路由的 happy path + 错误处理 + 安全测试。
"""

from __future__ import annotations

import json
import sqlite3
from pathlib import Path
from unittest.mock import patch

import pytest

from index1.config import Config


# ── Fixtures ──


@pytest.fixture()
def app_config(tmp_path):
    """创建测试用 Config，指向临时数据库"""
    db_path = tmp_path / "test_index.db"
    cog_path = tmp_path / "test_cognitive.db"
    return Config(
        db_path=str(db_path),
        cognitive_db_path=str(cog_path),
        embed_backend="onnx",
        watch_paths=[str(tmp_path)],
    )


@pytest.fixture()
def _seed_corpus(app_config):
    """预填充 corpus 数据库 — 插入文档和 chunk"""
    from index1.db import Database

    db = Database(app_config.db_path_resolved, app_config.effective_embedding_dim)
    db.connect()
    db.conn.execute(
        "INSERT INTO documents (path, hash, collection, doc_type)"
        " VALUES (?, ?, ?, ?)",
        ("src/foo.py", "abc123", "test_coll", "python"),
    )
    db.conn.execute(
        "INSERT INTO chunks (doc_id, content, section, parent_section, chunk_index,"
        " tags, token_count, summary)"
        " VALUES (1, 'def hello(): pass', 'hello', '', 0, '', 5, 'greeting')",
    )
    db.conn.commit()
    return db


@pytest.fixture()
def _seed_cognition(app_config):
    """预填充 cognition 数据库"""
    from index1.cognitive.db_cog import CogDatabase

    db = CogDatabase(
        app_config.cognitive_db_path_resolved,
        app_config.effective_embedding_dim,
    )
    db.connect()
    # 插入 session
    db.conn.execute(
        "INSERT INTO sessions (external_id, collection, created_at)"
        " VALUES ('sess-1', 'test_coll', datetime('now'))",
    )
    # 插入 fact
    db.conn.execute(
        "INSERT INTO facts (assertion, category, confidence, source, session_id,"
        " collection, scope, status, created_at, updated_at, layer, decay_rate,"
        " access_count, return_count)"
        " VALUES ('test fact', 'discovery', 0.8, 'test', 'sess-1',"
        " 'test_coll', 'project', 'active', datetime('now'), datetime('now'),"
        " 'L3a', 0.01, 0, 0)",
    )
    # 插入 raw_event
    db.conn.execute(
        "INSERT INTO raw_events (session_id, hook_event, tool_name, collection,"
        " importance, timestamp, payload)"
        " VALUES ('sess-1', 'PostToolUse', 'Edit', 'test_coll', 0.7,"
        " datetime('now'), '{}')",
    )
    # 插入 observation
    try:
        db.conn.execute(
            "INSERT INTO observations (session_id, collection, obs_type, title,"
            " narrative, timestamp)"
            " VALUES ('sess-1', 'test_coll', 'tool_use', 'Test observation',"
            " 'Observed something', datetime('now'))",
        )
    except sqlite3.OperationalError:
        pass  # observations 表可能不存在
    db.conn.commit()
    return db


@pytest.fixture()
def client(app_config, _seed_corpus, _seed_cognition):
    """Flask test client"""
    from index1.web import create_app

    app = create_app(app_config)
    app.config["TESTING"] = True
    with app.test_client() as c:
        yield c


@pytest.fixture()
def client_no_cog(app_config, _seed_corpus):
    """Flask test client — 无 cognition DB"""
    from index1.web import create_app

    # 不创建 cognitive.db → _cog_db = None
    app = create_app(app_config)
    app.config["TESTING"] = True
    with app.test_client() as c:
        yield c


# ═══════════════════════════════════════════
# P2 页面路由（简单 GET）
# ═══════════════════════════════════════════


class TestPageRoutes:
    """P2: 页面路由"""

    def test_index_page(self, client):
        resp = client.get("/")
        assert resp.status_code == 200

    def test_favicon(self, client):
        resp = client.get("/favicon.ico")
        assert resp.status_code == 200
        assert "image/svg+xml" in resp.content_type
        assert b"<svg" in resp.data

    def test_observer_page(self, client):
        """Observer 页面 — 无模板时返回 fallback HTML"""
        resp = client.get("/observer")
        assert resp.status_code == 200

    def test_compare_page(self, client):
        """Compare 页面 — 有模板时 200，无模板时 500（可接受）"""
        resp = client.get("/compare")
        assert resp.status_code in (200, 500)


# ═══════════════════════════════════════════
# P0: /api/search
# ═══════════════════════════════════════════


class TestApiSearch:
    """P0: 搜索 API"""

    def test_empty_query(self, client):
        resp = client.get("/api/search")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["results"] == []
        assert data["total"] == 0

    def test_empty_query_string(self, client):
        resp = client.get("/api/search?q=")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["results"] == []

    def test_query_too_long(self, client):
        long_q = "a" * 501
        resp = client.get(f"/api/search?q={long_q}")
        assert resp.status_code == 400
        assert "too long" in resp.get_json()["error"]

    def test_invalid_top_k(self, client):
        resp = client.get("/api/search?q=test&top_k=abc")
        assert resp.status_code == 400
        assert "top_k" in resp.get_json()["error"]

    def test_normal_query(self, client):
        """正常搜索 — 结果可能为空但不能 500"""
        resp = client.get("/api/search?q=hello")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "results" in data
        assert "total" in data
        assert "mode" in data


# ═══════════════════════════════════════════
# P0: /api/status
# ═══════════════════════════════════════════


class TestApiStatus:
    """P0: 状态 API"""

    def test_status_ok(self, client):
        resp = client.get("/api/status")
        assert resp.status_code == 200
        data = resp.get_json()
        assert isinstance(data, dict)


# ═══════════════════════════════════════════
# P1: /api/doc/<id>/chunks, /api/browse, /api/collections, /api/chunk/<id>
# ═══════════════════════════════════════════


class TestCorpusApi:
    """P1: Corpus API"""

    def test_doc_chunks_found(self, client):
        resp = client.get("/api/doc/1/chunks")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "chunks" in data
        assert len(data["chunks"]) >= 1

    def test_doc_chunks_not_found(self, client):
        resp = client.get("/api/doc/9999/chunks")
        assert resp.status_code == 404

    def test_browse_no_collection(self, client):
        resp = client.get("/api/browse")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "docs" in data

    def test_browse_with_collection(self, client):
        resp = client.get("/api/browse?collection=test_coll")
        assert resp.status_code == 200

    def test_browse_invalid_collection(self, client):
        resp = client.get("/api/browse?collection=" + "x" * 201)
        assert resp.status_code == 400

    def test_browse_null_byte(self, client):
        resp = client.get("/api/browse?collection=abc%00def")
        assert resp.status_code == 400

    def test_collections_list(self, client):
        resp = client.get("/api/collections")
        assert resp.status_code == 200
        data = resp.get_json()
        assert isinstance(data, list)

    def test_chunk_found(self, client):
        resp = client.get("/api/chunk/1")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["id"] == 1
        assert "content" in data

    def test_chunk_not_found(self, client):
        resp = client.get("/api/chunk/9999")
        assert resp.status_code == 404


# ═══════════════════════════════════════════
# P0: /api/reindex (POST)
# ═══════════════════════════════════════════


class TestApiReindex:
    """P0: 索引 API — 安全性测试"""

    def test_no_paths(self, client):
        resp = client.post("/api/reindex", json={})
        assert resp.status_code == 400

    def test_empty_paths(self, client):
        resp = client.post("/api/reindex", json={"paths": []})
        assert resp.status_code == 400

    def test_too_many_paths(self, client):
        resp = client.post("/api/reindex", json={"paths": ["/tmp"] * 11})
        assert resp.status_code == 400
        assert "too many" in resp.get_json()["error"]

    def test_path_not_found(self, client):
        resp = client.post("/api/reindex", json={"paths": ["/nonexistent/path/xyz"]})
        assert resp.status_code == 400
        assert "not found" in resp.get_json()["error"]

    def test_path_traversal(self, client):
        """路径遍历攻击 — 必须被阻止"""
        payloads = [
            "/etc/passwd",
            "../../etc/shadow",
        ]
        for p in payloads:
            resp = client.post("/api/reindex", json={"paths": [p]})
            # 必须被拒绝（400 路径不存在 或 403 不在 home 内）
            assert resp.status_code in (400, 403), f"Path traversal not blocked: {p}"

    def test_null_byte_in_path(self, client):
        resp = client.post("/api/reindex", json={"paths": ["/tmp/\x00evil"]})
        assert resp.status_code == 400

    def test_valid_path_outside_home(self, client, tmp_path):
        """tmp_path 在 home 外 → 403（路径安全检查正确）"""
        test_file = tmp_path / "test.py"
        test_file.write_text("# test\n")
        resp = client.post("/api/reindex", json={"paths": [str(test_file)]})
        # tmp_path 可能在 home 外（/var/folders），被安全检查拒绝是正确行为
        assert resp.status_code in (200, 403, 500)


# ═══════════════════════════════════════════
# P0: /api/cognition/facts
# ═══════════════════════════════════════════


class TestCognitionFacts:
    """P0: Facts API"""

    def test_facts_basic(self, client):
        resp = client.get("/api/cognition/facts")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "facts" in data
        assert "total" in data
        assert data["total"] >= 1

    def test_facts_pagination(self, client):
        resp = client.get("/api/cognition/facts?limit=1&offset=0")
        assert resp.status_code == 200
        data = resp.get_json()
        assert len(data["facts"]) <= 1

    def test_facts_since_id(self, client):
        resp = client.get("/api/cognition/facts?since_id=0")
        assert resp.status_code == 200

    def test_facts_filter_category(self, client):
        resp = client.get("/api/cognition/facts?category=discovery")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["total"] >= 1

    def test_facts_filter_session(self, client):
        resp = client.get("/api/cognition/facts?session_id=sess-1")
        assert resp.status_code == 200

    def test_facts_filter_collection(self, client):
        resp = client.get("/api/cognition/facts?collection=test_coll")
        assert resp.status_code == 200

    def test_facts_limit_overflow(self, client):
        """limit > 200 被截断"""
        resp = client.get("/api/cognition/facts?limit=999")
        assert resp.status_code == 200

    def test_facts_invalid_limit(self, client):
        """limit 非数字 → 降级到默认 50"""
        resp = client.get("/api/cognition/facts?limit=abc")
        assert resp.status_code == 200

    def test_facts_no_cog_db(self, client_no_cog):
        """无 cognitive DB → 503"""
        resp = client_no_cog.get("/api/cognition/facts")
        assert resp.status_code == 503


# ═══════════════════════════════════════════
# P1: /api/cognition/fact/<id>
# ═══════════════════════════════════════════


class TestCognitionFactDetail:
    """P1: Fact 详情"""

    def test_fact_found(self, client):
        resp = client.get("/api/cognition/fact/1")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["assertion"] == "test fact"

    def test_fact_not_found(self, client):
        resp = client.get("/api/cognition/fact/9999")
        assert resp.status_code == 404

    def test_fact_no_cog_db(self, client_no_cog):
        resp = client_no_cog.get("/api/cognition/fact/1")
        assert resp.status_code == 503


# ═══════════════════════════════════════════
# P0: /api/cognition/events
# ═══════════════════════════════════════════


class TestCognitionEvents:
    """P0: Events API"""

    def test_events_basic(self, client):
        resp = client.get("/api/cognition/events")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "events" in data
        assert "total" in data
        assert data["total"] >= 1

    def test_events_since_id(self, client):
        resp = client.get("/api/cognition/events?since_id=0")
        assert resp.status_code == 200

    def test_events_filter_session(self, client):
        resp = client.get("/api/cognition/events?session_id=sess-1")
        assert resp.status_code == 200

    def test_events_filter_hook(self, client):
        resp = client.get("/api/cognition/events?hook_event=PostToolUse")
        assert resp.status_code == 200

    def test_events_filter_collection(self, client):
        resp = client.get("/api/cognition/events?collection=test_coll")
        assert resp.status_code == 200

    def test_events_include_payload(self, client):
        resp = client.get("/api/cognition/events?include_payload=true")
        assert resp.status_code == 200

    def test_events_no_cog_db(self, client_no_cog):
        resp = client_no_cog.get("/api/cognition/events")
        assert resp.status_code == 503


# ═══════════════════════════════════════════
# P0: /api/cognition/observations
# ═══════════════════════════════════════════


class TestCognitionObservations:
    """P0: Observations API"""

    def test_observations_basic(self, client):
        resp = client.get("/api/cognition/observations")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "observations" in data
        assert "total" in data

    def test_observations_filter_collection(self, client):
        resp = client.get("/api/cognition/observations?collection=test_coll")
        assert resp.status_code == 200

    def test_observations_filter_session(self, client):
        resp = client.get("/api/cognition/observations?session_id=sess-1")
        assert resp.status_code == 200

    def test_observations_since_id(self, client):
        resp = client.get("/api/cognition/observations?since_id=0")
        assert resp.status_code == 200

    def test_observations_no_cog_db(self, client_no_cog):
        resp = client_no_cog.get("/api/cognition/observations")
        assert resp.status_code == 503


# ═══════════════════════════════════════════
# P1: /api/cognition/stats, /collections, /sessions
# ═══════════════════════════════════════════


class TestCognitionMeta:
    """P1: 认知元数据 API"""

    def test_stats(self, client):
        resp = client.get("/api/cognition/stats")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "facts_by_category" in data

    def test_stats_with_collection(self, client):
        resp = client.get("/api/cognition/stats?collection=test_coll")
        assert resp.status_code == 200

    def test_stats_no_cog_db(self, client_no_cog):
        resp = client_no_cog.get("/api/cognition/stats")
        assert resp.status_code == 503

    def test_cognition_collections(self, client):
        resp = client.get("/api/cognition/collections")
        assert resp.status_code == 200
        data = resp.get_json()
        assert isinstance(data, list)

    def test_cognition_collections_no_cog(self, client_no_cog):
        resp = client_no_cog.get("/api/cognition/collections")
        assert resp.status_code == 503

    def test_sessions(self, client):
        resp = client.get("/api/cognition/sessions")
        assert resp.status_code == 200
        data = resp.get_json()
        assert isinstance(data, list)
        assert len(data) >= 1

    def test_sessions_with_collection(self, client):
        resp = client.get("/api/cognition/sessions?collection=test_coll")
        assert resp.status_code == 200

    def test_sessions_no_cog(self, client_no_cog):
        resp = client_no_cog.get("/api/cognition/sessions")
        assert resp.status_code == 503


# ═══════════════════════════════════════════
# P1: /api/cognition/observer
# ═══════════════════════════════════════════


class TestCognitionObserver:
    """P1: Observer 健康检查"""

    def test_observer_health(self, client):
        resp = client.get("/api/cognition/observer")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "health" in data
        assert "providers" in data
        assert isinstance(data["providers"], list)

    def test_observer_no_cog(self, client_no_cog):
        """无 cog DB 也不能 500"""
        resp = client_no_cog.get("/api/cognition/observer")
        assert resp.status_code == 200
        data = resp.get_json()
        assert "health" in data


# ═══════════════════════════════════════════
# P2: Compare API
# ═══════════════════════════════════════════


class TestCompareApi:
    """P2: 对比 API"""

    def test_compare_list_no_data_dir(self, client):
        """data 目录不存在 → 空列表"""
        resp = client.get("/api/compare/list")
        assert resp.status_code == 200
        data = resp.get_json()
        assert isinstance(data, list)

    def test_compare_detail_not_found(self, client):
        resp = client.get("/api/compare/nonexistent")
        assert resp.status_code == 404

    def test_compare_detail_invalid_id(self, client):
        """非 alphanumeric ID → 400"""
        resp = client.get("/api/compare/../../etc")
        # Flask 会 404 因为路由不匹配 /
        # 但如果匹配成功，应该 400
        assert resp.status_code in (400, 404)

    def test_compare_detail_special_chars(self, client):
        resp = client.get("/api/compare/test;DROP")
        assert resp.status_code in (400, 404)


# ═══════════════════════════════════════════
# 安全测试：SQL 注入
# ═══════════════════════════════════════════


SQL_INJECTION_PAYLOADS = [
    "'; DROP TABLE facts--",
    "1 OR 1=1",
    "1; SELECT * FROM sqlite_master--",
    "' UNION SELECT sql FROM sqlite_master--",
]


class TestSqlInjection:
    """安全: SQL 注入防护"""

    @pytest.mark.parametrize("payload", SQL_INJECTION_PAYLOADS)
    def test_facts_category_injection(self, client, payload):
        resp = client.get(f"/api/cognition/facts?category={payload}")
        assert resp.status_code in (200, 400)  # 不能 500

    @pytest.mark.parametrize("payload", SQL_INJECTION_PAYLOADS)
    def test_facts_session_injection(self, client, payload):
        resp = client.get(f"/api/cognition/facts?session_id={payload}")
        assert resp.status_code in (200, 400)

    @pytest.mark.parametrize("payload", SQL_INJECTION_PAYLOADS)
    def test_search_injection(self, client, payload):
        resp = client.get(f"/api/search?q={payload}")
        assert resp.status_code in (200, 400)

    @pytest.mark.parametrize("payload", SQL_INJECTION_PAYLOADS)
    def test_events_session_injection(self, client, payload):
        resp = client.get(f"/api/cognition/events?session_id={payload}")
        assert resp.status_code in (200, 400)

    @pytest.mark.parametrize("payload", SQL_INJECTION_PAYLOADS)
    def test_browse_collection_injection(self, client, payload):
        resp = client.get(f"/api/browse?collection={payload}")
        assert resp.status_code in (200, 400)
