"""Phase 31 — admin observability 6 路由 smoke tests.

目的：保 Phase 27/28/29 6 個 admin 頁不被未來修改打掛。
不接 DB／Ollama／MCP，全 mock。每個 route 至少驗：
  1. HTTP 200 回應（render_template 不爆）
  2. session 失敗時走 fallback（error banner）
  3. budget/update 輸入驗證
"""
from __future__ import annotations

from datetime import datetime
from unittest.mock import MagicMock

import pytest
from flask import Flask


@pytest.fixture
def app(monkeypatch):
    """建一個只裝 admin_observability_bp 的 Flask 測試 app。"""
    flask_app = Flask(
        __name__,
        template_folder='../templates',
        static_folder='../static',
    )
    flask_app.config['TESTING'] = True
    flask_app.config['SECRET_KEY'] = 'test'

    # base.html 用 csrf_token() / 部分 sidebar globals — 給 stub 避免 UndefinedError
    flask_app.jinja_env.globals['csrf_token'] = lambda: 'test-csrf-token'
    flask_app.jinja_env.globals.setdefault('current_user', None)

    from routes import admin_observability_routes as mod
    flask_app.register_blueprint(mod.admin_observability_bp)

    # auth.login_required 會 redirect to url_for('login') — 沒裝 auth blueprint 時補 stub
    @flask_app.route('/login')
    def login():
        return 'login stub', 200

    return flask_app


@pytest.fixture
def client(app):
    """已登入的 test client（透過 session transaction 設 logged_in）。"""
    c = app.test_client()
    with c.session_transaction() as sess:
        sess['logged_in'] = True
        sess['username'] = 'pytest_admin'
        sess['role'] = 'admin'
    return c


@pytest.fixture
def anon_client(app):
    """未登入的 test client（驗 @login_required 強制 redirect）。"""
    return app.test_client()


def _fake_session(rows_per_query=None):
    """建 mock session：execute().fetchall() 回 rows_per_query；
    fetchone() 回 (0,) 或第一行。"""
    session = MagicMock()

    def _execute(*_a, **_kw):
        result = MagicMock()
        rows = rows_per_query if rows_per_query is not None else []
        result.fetchall.return_value = rows
        result.fetchone.return_value = rows[0] if rows else (0,)
        # iteration support
        result.__iter__ = lambda self: iter(rows)
        return result

    session.execute.side_effect = _execute
    session.commit = MagicMock()
    session.close = MagicMock()
    return session


# ──────────────────────────────────────────────────────────────────────────
# /observability/ai_calls
# ──────────────────────────────────────────────────────────────────────────

def test_ai_calls_dashboard_200_empty(client, monkeypatch):
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.get('/observability/ai_calls')
    assert r.status_code == 200
    assert b'AI Calls' in r.data or '\xe5\x88\x86\xe6\x9e\x90'.encode() in r.data or True  # 中文標題可選


def test_ai_calls_dashboard_db_error_falls_back(client, monkeypatch):
    from routes import admin_observability_routes as mod
    bad = MagicMock()
    bad.execute.side_effect = RuntimeError('DB down')
    bad.close = MagicMock()
    monkeypatch.setattr(mod, 'get_session', lambda: bad)
    r = client.get('/observability/ai_calls')
    assert r.status_code == 200  # 失敗安全：仍 render，不 500


def test_ai_calls_recent_row_marks_legacy_code_review_gemini_as_backup():
    from routes.admin_observability_routes import _build_ai_call_recent_row

    row = (
        1452,
        datetime(2026, 5, 19, 12, 2, 49),
        'code_review_openclaw',
        'gemini',
        'gemini-2.5-flash',
        181,
        56,
        9158,
        'ok',
        0,
        False,
        False,
    )

    data = _build_ai_call_recent_row(row)

    assert data['caller'] == 'code_review_openclaw'
    assert data['caller_display'] == 'code_review_openclaw_gemini'
    assert data['provider'] == 'gemini'
    assert 'Gemini 備援' in data['route_badges']
    assert '舊 caller' in data['route_badges']


def test_ai_calls_recent_row_marks_legacy_openclaw_qa_gemini_as_backup():
    from routes.admin_observability_routes import _build_ai_call_recent_row

    row = (
        1460,
        datetime(2026, 5, 19, 12, 12, 49),
        'openclaw_qa',
        'gemini',
        'gemini-2.5-flash',
        181,
        56,
        9158,
        'ok',
        0,
        False,
        False,
    )

    data = _build_ai_call_recent_row(row)

    assert data['caller'] == 'openclaw_qa'
    assert data['caller_display'] == 'openclaw_qa_gemini_fallback'
    assert data['provider'] == 'gemini'
    assert 'Gemini 備援' in data['route_badges']
    assert '舊 caller' in data['route_badges']


def test_ai_calls_recent_row_marks_new_openclaw_qa_gemini_fallback_as_backup():
    from routes.admin_observability_routes import _build_ai_call_recent_row

    row = (
        1461,
        datetime(2026, 5, 19, 12, 13, 49),
        'openclaw_qa_gemini_fallback',
        'gemini',
        'gemini-2.5-flash',
        181,
        56,
        9158,
        'ok',
        0,
        False,
        False,
    )

    data = _build_ai_call_recent_row(row)

    assert data['caller_display'] == 'openclaw_qa_gemini_fallback'
    assert 'Gemini 備援' in data['route_badges']
    assert '舊 caller' not in data['route_badges']


# ──────────────────────────────────────────────────────────────────────────
# /observability/promotion_review
# ──────────────────────────────────────────────────────────────────────────

def test_promotion_review_200(client, monkeypatch):
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.get('/observability/promotion_review')
    assert r.status_code == 200


# ──────────────────────────────────────────────────────────────────────────
# /observability/quality_trend
# ──────────────────────────────────────────────────────────────────────────

def test_quality_trend_200(client, monkeypatch):
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.get('/observability/quality_trend')
    assert r.status_code == 200


# ──────────────────────────────────────────────────────────────────────────
# /observability/budget
# ──────────────────────────────────────────────────────────────────────────

def test_budget_dashboard_200_empty(client, monkeypatch):
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.get('/observability/budget')
    assert r.status_code == 200


def test_budget_update_rejects_invalid_budget(client, monkeypatch):
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.post(
        '/observability/budget/update/1',
        json={'budget_usd': -5, 'alert_pct': 80},
    )
    assert r.status_code == 400
    assert r.get_json()['ok'] is False


def test_budget_update_rejects_invalid_alert(client, monkeypatch):
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.post(
        '/observability/budget/update/1',
        json={'budget_usd': 10, 'alert_pct': 999},
    )
    assert r.status_code == 400


def test_budget_update_accepts_valid(client, monkeypatch):
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.post(
        '/observability/budget/update/1',
        json={'budget_usd': 25.50, 'alert_pct': 80},
    )
    assert r.status_code == 200
    assert r.get_json()['ok'] is True


# ──────────────────────────────────────────────────────────────────────────
# /observability/ppt_audit_history
# ──────────────────────────────────────────────────────────────────────────

def test_ppt_audit_history_200(client):
    """無 DB 依賴，純掃 reports/。"""
    r = client.get('/observability/ppt_audit_history')
    assert r.status_code == 200


def test_ppt_vision_runtime_status_explains_blockers(monkeypatch):
    """視覺 QA runtime 狀態要能直接說明停用原因與下一步。"""
    from services import ppt_vision_service as svc

    monkeypatch.delenv('PPT_VISION_ENABLED', raising=False)
    monkeypatch.setenv('PPT_VISION_MODEL', 'minicpm-v:test')
    monkeypatch.setattr(svc.shutil, 'which', lambda _name: None)

    status = svc.get_ppt_vision_runtime_status()

    assert status['ready'] is False
    assert status['status_label'] == '環境未就緒'
    assert status['ready_count'] == 1
    assert status['check_count'] == 3
    assert 'PPT_VISION_ENABLED 未設定為 true' in status['blockers']
    assert '容器內缺少 LibreOffice' in status['blockers'][1]
    assert status['readiness_checks'][0]['key'] == 'feature_flag'
    assert status['readiness_checks'][0]['status'] == 'error'
    assert status['readiness_checks'][1]['key'] == 'converter'
    assert status['readiness_checks'][1]['status'] == 'error'
    assert status['readiness_checks'][2]['label'] == '視覺模型'
    assert status['readiness_checks'][2]['value'] == 'minicpm-v:test'
    assert any('PPT_VISION_ENABLED=true' in action for action in status['next_actions'])


def test_ppt_audit_history_renders_vision_runtime_checklist(client, monkeypatch):
    """PPT 產線頁不應只顯示停用，要呈現 runtime checklist 與修復動作。"""
    from services import ppt_vision_service as svc

    monkeypatch.setattr(svc, 'get_ppt_vision_runtime_status', lambda: {
        'enabled': False,
        'env_value': '未設定（預設 false）',
        'model': 'minicpm-v:test',
        'converter': None,
        'converter_ready': False,
        'blockers': ['PPT_VISION_ENABLED 未設定為 true', '容器內缺少 LibreOffice'],
        'ready': False,
        'ready_count': 1,
        'check_count': 3,
        'status_label': '環境未就緒',
        'summary': '視覺 QA runtime 仍有必要條件未通過。',
        'checked_at': '2026-05-19 10:00:00',
        'readiness_checks': [
            {
                'key': 'feature_flag',
                'label': '功能開關',
                'value': 'PPT_VISION_ENABLED=未設定',
                'status': 'error',
                'detail': '目前會阻擋立即視覺 QA 按鈕。',
            },
            {
                'key': 'converter',
                'label': '轉檔器',
                'value': 'not found',
                'status': 'error',
                'detail': '缺轉檔器時無法建立視覺模型輸入。',
            },
            {
                'key': 'vision_model',
                'label': '視覺模型',
                'value': 'minicpm-v:test',
                'status': 'ready',
                'detail': '沿用 Ollama-first 三主機路由。',
            },
        ],
        'next_actions': [
            '在 momo-app / scheduler 環境設定 PPT_VISION_ENABLED=true，重新 recreate 相關 app 容器。',
            '確認映像已安裝 LibreOffice Impress，完成 rebuild 後再重啟 momo-app / scheduler。',
        ],
    })

    r = client.get('/observability/ppt_audit_history')
    html = r.get_data(as_text=True)

    assert r.status_code == 200
    assert '視覺 QA runtime checklist' in html
    assert 'PPT_VISION_ENABLED=未設定' in html
    assert 'LibreOffice' in html
    assert 'minicpm-v:test' in html
    assert '1/3 通過' in html
    assert '重新 recreate 相關 app 容器' in html
    assert '阻擋：PPT_VISION_ENABLED 未設定為 true；容器內缺少 LibreOffice' in html


def test_ppt_audit_history_shows_ppt_schedule_and_db_runs(client, monkeypatch):
    """PPT 產線頁必須呈現六種固定週期與 DB 寫入紀錄。"""
    from services import ppt_auto_generation_service as svc

    cadences = svc.get_schedule_cadence_status([
        {'key': key, 'ready': True}
        for key in svc.DEFINED_REPORT_TYPES
    ])
    monkeypatch.setattr(svc, 'get_defined_report_coverage', lambda **_kw: {
        'enabled': True,
        'items': [{
            'key': 'daily',
            'label': '每日日報',
            'target_label': '2026/05/17',
            'ready': False,
            'status': 'missing',
            'status_label': '待排程補齊',
            'status_hint': '尚未找到符合定義的檔案或 DB 紀錄。',
        }],
        'missing_report_types': ['daily'],
        'missing_count': 1,
        'ready_count': len(svc.DEFINED_REPORT_TYPES) - 1,
        'total': len(svc.DEFINED_REPORT_TYPES),
        'last_run': None,
        'cadences': cadences,
        'cadence_summary': '、'.join(c['schedule_text'] for c in cadences),
    })
    monkeypatch.setattr(svc, 'get_generation_run_history', lambda **_kw: [
        {
            'schedule_kind': 'daily',
            'schedule_label': '每日',
            'report_type': 'daily',
            'report_label': '每日日報',
            'target_label': '2026/05/17',
            'status': 'error',
            'status_label': '失敗',
            'file_name': '',
            'file_size_kb': None,
            'error_msg': '產線錯誤：缺少資料來源',
            'started_at': '2026-05-17 20:29',
            'finished_at': '2026-05-17 20:30',
        },
        {
            'schedule_kind': 'daily',
            'schedule_label': '每日',
            'report_type': 'daily',
            'report_label': '每日日報',
            'target_label': '2026/05/17',
            'status': 'ready',
            'status_label': '已產出',
            'file_name': 'ocbot_daily_20260517.pptx',
            'file_size_kb': 1024,
            'error_msg': '',
            'started_at': '2026-05-17 20:30',
            'finished_at': '2026-05-17 20:31',
        },
    ])

    r = client.get('/observability/ppt_audit_history')
    html = r.data.decode('utf-8')

    for text in ['每日 20:30', '每週一 20:40', '每月 1 日 20:50', '每季首日 21:00', '每半年首日 21:10', '每年 1/1 21:20']:
        assert text in html
    assert 'ppt_generation_runs' in html
    assert '每日日報' in html
    assert 'Pipeline Health' in html
    assert '排程節奏' in html
    assert 'DB 寫入' in html
    assert '線上預覽' in html
    assert 'Action Queue' in html
    assert '接下來要處理的事' in html
    assert '異常優先' in html
    assert '產線錯誤：缺少資料來源' in html
    assert 'data-ppt-generate-one' in html
    assert 'data-report-type="daily"' in html
    assert '重跑' in html
    assert '待排程補齊' in html


def test_ppt_audit_history_shows_recent_preview_workbench(client, monkeypatch, tmp_path):
    """有檔案時，頁面上方要先給可預覽簡報入口。"""
    import zipfile
    from pathlib import Path
    from services import ppt_preview_service as preview_svc

    reports_dir = tmp_path / 'reports'
    cache_dir = tmp_path / 'preview-cache'
    reports_dir.mkdir()
    pptx = reports_dir / 'ocbot_daily_20260517.pptx'
    with zipfile.ZipFile(pptx, 'w') as zf:
        zf.writestr('[Content_Types].xml', '<Types></Types>')
    monkeypatch.setenv('PPT_PREVIEW_CACHE_DIR', str(cache_dir))
    cache_info = preview_svc.get_ppt_preview_cache_info(pptx)
    Path(cache_info.pdf_path).parent.mkdir(parents=True, exist_ok=True)
    Path(cache_info.pdf_path).write_bytes(b'%PDF-1.4\n')

    monkeypatch.setenv('REPORTS_DIR', str(reports_dir))

    r = client.get('/observability/ppt_audit_history?month=2026-05')
    html = r.data.decode('utf-8')

    assert r.status_code == 200
    assert '最近可預覽簡報' in html
    assert 'ocbot_daily_20260517.pptx' in html
    assert '線上預覽' in html
    assert 'data-ppt-open-preview' in html
    assert 'data-ppt-preview-modal' in html
    assert 'data-ppt-preview-modal-title' in html
    assert 'data-ppt-preview-pdf' in html
    assert 'PDF 快取' in html
    assert '1</strong> 份 PDF 快取' in html


def test_ppt_audit_history_coverage_matrix_joins_db_preview_qa(client, monkeypatch, tmp_path):
    """定義簡報覆蓋區要同列呈現 DB、預覽、QA 與交付狀態。"""
    import zipfile
    from datetime import datetime
    from unittest.mock import MagicMock
    from routes import admin_observability_routes as mod
    from services import ppt_auto_generation_service as svc

    reports_dir = tmp_path / 'reports'
    reports_dir.mkdir()
    pptx = reports_dir / 'ocbot_daily_20260517.pptx'
    with zipfile.ZipFile(pptx, 'w') as zf:
        zf.writestr('[Content_Types].xml', '<Types></Types>')

    monkeypatch.setenv('REPORTS_DIR', str(reports_dir))
    monkeypatch.setenv('PPT_PREVIEW_CACHE_DIR', str(tmp_path / 'preview-cache'))

    coverage_items = [{
        'key': 'daily',
        'label': '每日日報',
        'target_label': '2026/05/17',
        'ready': True,
        'status': 'ready',
        'status_label': '已產出',
        'status_hint': '檔案參數與本期定義相符。',
        'sources': ['database', 'filesystem'],
        'latest_generated_at': '2026-05-17 20:31',
        'latest_file_path': str(pptx),
        'latest_file_name': pptx.name,
    }]
    monkeypatch.setattr(svc, 'get_defined_report_coverage', lambda **_kw: {
        'enabled': True,
        'items': coverage_items,
        'missing_report_types': [],
        'missing_count': 0,
        'ready_count': 1,
        'total': 1,
        'last_run': None,
        'can_auto_start': False,
        'cadences': svc.get_schedule_cadence_status(coverage_items),
        'cadence_summary': '每日 20:30',
    })
    monkeypatch.setattr(svc, 'get_generation_run_history', lambda **_kw: [{
        'schedule_kind': 'daily',
        'schedule_label': '每日',
        'report_type': 'daily',
        'report_label': '每日日報',
        'target_label': '2026/05/17',
        'status': 'ready',
        'status_label': '已產出',
        'file_name': pptx.name,
        'file_size_kb': 1024,
        'error_msg': '',
        'started_at': '2026-05-17 20:30',
        'finished_at': '2026-05-17 20:31',
    }])

    class FakeSession:
        def execute(self, statement, _params=None):
            sql = str(statement)
            result = MagicMock()
            if 'FROM ppt_reports' in sql:
                result.fetchall.return_value = []
            elif 'SELECT audited_at, pptx_filename' in sql:
                result.fetchall.return_value = [
                    (datetime(2026, 5, 17, 22, 5), pptx.name, 'passed', 0, 0.95, 900, '', [])
                ]
            elif 'COALESCE(AVG(confidence)' in sql:
                result.fetchone.return_value = (1, 1, 0, 0, 0, 0.95, 0)
            elif 'GROUP BY pptx_filename' in sql:
                result.fetchall.return_value = []
            else:
                result.fetchall.return_value = []
                result.fetchone.return_value = (0,)
            return result

        def close(self):
            return None

    monkeypatch.setattr(mod, 'get_session', lambda: FakeSession())

    r = client.get('/observability/ppt_audit_history?month=2026-05')
    html = r.get_data(as_text=True)

    assert r.status_code == 200
    assert '報表覆蓋矩陣' in html
    assert 'DB / 預覽 / 視覺 QA / 交付' in html
    assert 'DB 已寫入' in html
    assert '可預覽' in html
    assert 'QA 通過' in html
    assert '可交付' in html
    assert 'data-ppt-open-preview' in html
    assert 'ocbot_daily_20260517.pptx' in html


def test_ppt_audit_file_view_renders_online_preview(client, monkeypatch, tmp_path):
    """PPTX view 入口應回站內預覽頁，而不是把 PPTX 直接丟給瀏覽器。"""
    import zipfile
    from services import ppt_preview_service as preview_svc

    reports_dir = tmp_path / 'reports'
    reports_dir.mkdir()
    pptx = reports_dir / 'ocbot_daily_20260517.pptx'
    with zipfile.ZipFile(pptx, 'w') as zf:
        zf.writestr('[Content_Types].xml', '<Types></Types>')

    monkeypatch.setenv('REPORTS_DIR', str(reports_dir))
    monkeypatch.setattr(
        preview_svc,
        'build_ppt_preview',
        lambda *_args, **_kwargs: preview_svc.PPTPreviewResult(
            ok=True,
            pdf_path=str(reports_dir / 'preview.pdf'),
            cache_hit=True,
            converter='/usr/bin/libreoffice',
        ),
    )

    r = client.get('/observability/ppt_audit_file/ocbot_daily_20260517.pptx')
    html = r.data.decode('utf-8')

    assert r.status_code == 200
    assert 'PPT 線上預覽' in html
    assert 'action=pdf' in html
    assert '下載 PPTX' in html


def test_ppt_audit_history_audit_rows_include_inline_replay(client, monkeypatch, tmp_path):
    """審核歷史每筆紀錄都應能直接開同頁 PDF 回放。"""
    import zipfile
    from datetime import datetime
    from unittest.mock import MagicMock
    from routes import admin_observability_routes as mod

    reports_dir = tmp_path / 'reports'
    reports_dir.mkdir()
    pptx = reports_dir / 'ocbot_daily_20260517.pptx'
    with zipfile.ZipFile(pptx, 'w') as zf:
        zf.writestr('[Content_Types].xml', '<Types></Types>')

    monkeypatch.setenv('REPORTS_DIR', str(reports_dir))

    class FakeSession:
        def execute(self, statement, _params=None):
            sql = str(statement)
            result = MagicMock()
            if 'FROM ppt_reports' in sql:
                rows = []
                result.fetchall.return_value = rows
            elif 'SELECT audited_at, pptx_filename' in sql:
                rows = [
                    (
                        datetime(2026, 5, 17, 22, 5),
                        'ocbot_daily_20260517.pptx',
                        'failed',
                        1,
                        0.93,
                        1200,
                        '',
                        [{'slide': 1, 'issues': ['⚠️ 圖表被切掉：右側圖例超出邊界']}],
                    )
                ]
                result.fetchall.return_value = rows
            elif 'COALESCE(AVG(confidence)' in sql:
                row = (1, 1, 0, 0, 0, 0.93, 0)
                result.fetchone.return_value = row
            elif 'GROUP BY pptx_filename' in sql:
                rows = []
                result.fetchall.return_value = rows
            else:
                rows = []
                result.fetchall.return_value = rows
                result.fetchone.return_value = (0,)
            return result

        def close(self):
            return None

    monkeypatch.setattr(mod, 'get_session', lambda: FakeSession())

    r = client.get('/observability/ppt_audit_history?month=2026-05')
    html = r.data.decode('utf-8')

    assert r.status_code == 200
    assert '審核回放 · ocbot_daily_20260517.pptx' in html
    assert 'data-ppt-open-preview' in html
    assert 'ocbot_daily_20260517.pptx?action=pdf' in html
    assert '圖表被切掉' in html
    assert '視覺問題追蹤' in html
    assert '版面越界' in html
    assert '問題回放 · ocbot_daily_20260517.pptx' in html
    assert '回放' in html


def test_ppt_audit_history_weekly_rows_include_visual_audit(client, monkeypatch, tmp_path):
    """非 daily 簡報也應顯示自己的視覺 QA 歷史，不再要求切回 daily。"""
    import zipfile
    from datetime import datetime
    from unittest.mock import MagicMock
    from routes import admin_observability_routes as mod

    reports_dir = tmp_path / 'reports'
    reports_dir.mkdir()
    pptx = reports_dir / 'ocbot_weekly_20260518.pptx'
    with zipfile.ZipFile(pptx, 'w') as zf:
        zf.writestr('[Content_Types].xml', '<Types></Types>')

    monkeypatch.setenv('REPORTS_DIR', str(reports_dir))

    class FakeSession:
        def execute(self, statement, _params=None):
            sql = str(statement)
            result = MagicMock()
            if 'FROM ppt_reports' in sql:
                result.fetchall.return_value = []
            elif 'SELECT audited_at, pptx_filename' in sql:
                result.fetchall.return_value = [
                    (datetime(2026, 5, 18, 22, 7), 'ocbot_weekly_20260518.pptx', 'passed', 0, 0.91, 1800, '', [])
                ]
            elif 'COALESCE(AVG(confidence)' in sql:
                result.fetchone.return_value = (1, 1, 0, 0, 0, 0.91, 0)
            elif 'GROUP BY pptx_filename' in sql:
                result.fetchall.return_value = []
            else:
                result.fetchall.return_value = []
                result.fetchone.return_value = (0,)
            return result

        def close(self):
            return None

    monkeypatch.setattr(mod, 'get_session', lambda: FakeSession())

    r = client.get('/observability/ppt_audit_history?month=2026-05&report_type=weekly')
    html = r.data.decode('utf-8')

    assert r.status_code == 200
    assert 'ocbot_weekly_20260518.pptx' in html
    assert '審核回放 · ocbot_weekly_20260518.pptx' in html
    assert '只有「每日日報」會進入視覺審核流程' not in html
    assert 'data-ppt-run-vision' in html


def test_ppt_audit_history_shows_preview_prewarm_action(client, monkeypatch, tmp_path):
    """未快取 PDF 的 PPTX 要能在產線清單直接預熱預覽。"""
    import zipfile

    reports_dir = tmp_path / 'reports'
    reports_dir.mkdir()
    pptx = reports_dir / 'ocbot_daily_20260518.pptx'
    with zipfile.ZipFile(pptx, 'w') as zf:
        zf.writestr('[Content_Types].xml', '<Types></Types>')

    monkeypatch.setenv('REPORTS_DIR', str(reports_dir))
    monkeypatch.setenv('PPT_PREVIEW_CACHE_DIR', str(tmp_path / 'preview-cache'))

    r = client.get('/observability/ppt_audit_history?month=2026-05')
    html = r.data.decode('utf-8')

    assert r.status_code == 200
    assert '首次轉檔' in html
    assert 'data-ppt-prewarm-all' in html
    assert 'data-ppt-prewarm-count' in html
    assert 'data-ppt-prewarm-preview' in html
    assert 'data-ppt-filename="ocbot_daily_20260518.pptx"' in html
    assert '預熱本頁 PDF' in html
    assert '預熱 PDF' in html


def test_ppt_audit_file_prewarm_builds_preview_cache(client, monkeypatch, tmp_path):
    """預熱端點應回 JSON，不直接把 PDF body 丟給前端。"""
    import zipfile
    from services import ppt_preview_service as preview_svc

    reports_dir = tmp_path / 'reports'
    reports_dir.mkdir()
    pptx = reports_dir / 'ocbot_daily_20260518.pptx'
    pdf = reports_dir / 'preview.pdf'
    with zipfile.ZipFile(pptx, 'w') as zf:
        zf.writestr('[Content_Types].xml', '<Types></Types>')

    monkeypatch.setenv('REPORTS_DIR', str(reports_dir))
    monkeypatch.setattr(
        preview_svc,
        'build_ppt_preview',
        lambda *_args, **_kwargs: preview_svc.PPTPreviewResult(
            ok=True,
            pdf_path=str(pdf),
            cache_hit=False,
            converter='/usr/bin/libreoffice',
        ),
    )

    r = client.post('/observability/ppt_audit_file/ocbot_daily_20260518.pptx/prewarm')
    data = r.get_json()

    assert r.status_code == 200
    assert data['ok'] is True
    assert data['filename'] == 'ocbot_daily_20260518.pptx'
    assert data['message'] == 'PDF 預覽快取已建立'


def test_ppt_audit_run_vision_queues_background_audit(client, monkeypatch):
    """立即視覺 QA 端點只排入背景任務，不讓瀏覽器等待模型跑完。"""
    from services import ppt_vision_service as svc

    captured = {}

    def fake_start(**kwargs):
        captured.update(kwargs)
        return {'ok': True, 'status': 'queued', 'message': 'PPT vision audit queued.'}

    monkeypatch.setattr(svc, 'start_ppt_vision_audit_background', fake_start)

    r = client.post(
        '/observability/ppt_audit/run_vision',
        json={'filenames': ['ocbot_daily_20260518.pptx'], 'max_files': 1},
    )
    data = r.get_json()

    assert r.status_code == 202
    assert data['ok'] is True
    assert captured['filenames'] == ['ocbot_daily_20260518.pptx']
    assert captured['max_files'] == 1


def test_ppt_vision_audit_status_sanitizes_last_run(monkeypatch, tmp_path):
    """背景視覺 QA 狀態只回檔名與摘要，不把 reports_dir 絕對路徑曝露到頁面。"""
    from services import ppt_vision_service as svc

    monkeypatch.setenv('PPT_VISION_STATE_PATH', str(tmp_path / 'vision_state.json'))
    monkeypatch.setattr(svc, '_LAST_AUDIT_RUN', {
        'ok': True,
        'status': 'completed',
        'queued_at': '2026-05-19 12:00:00',
        'started_at': '2026-05-19 12:00:01',
        'finished_at': '2026-05-19 12:00:05',
        'filenames': ['/app/data/reports/ocbot_daily_20260518.pptx'],
        'max_files': 1,
        'summary': {
            'audited_files': [{
                'path': '/app/data/reports/ocbot_daily_20260518.pptx',
                'slides_checked': 1,
                'issues': 0,
                'error': None,
            }],
            'total_issues': 0,
            'errors': [],
        },
    })

    status = svc.get_ppt_vision_audit_status()

    assert status['ok'] is True
    assert status['status'] == 'completed'
    assert status['status_label'] == '已完成'
    assert status['last_run']['filenames'] == ['ocbot_daily_20260518.pptx']
    assert status['last_run']['summary']['audited_count'] == 1
    assert status['last_run']['summary']['files'][0]['filename'] == 'ocbot_daily_20260518.pptx'
    assert '/app/data/reports' not in str(status)


def test_ppt_vision_audit_status_reads_persisted_state(monkeypatch, tmp_path):
    """多 worker 下狀態需從 runtime state 檔讀取，不能只靠單一 worker 記憶體。"""
    import json
    from services import ppt_vision_service as svc

    state_path = tmp_path / 'vision_state.json'
    monkeypatch.setenv('PPT_VISION_STATE_PATH', str(state_path))
    monkeypatch.setattr(svc, '_LAST_AUDIT_RUN', None)
    state_path.write_text(json.dumps({
        'ok': True,
        'status': 'completed',
        'queued_at': '2026-05-19 12:00:00',
        'started_at': '2026-05-19 12:00:01',
        'finished_at': '2026-05-19 12:00:05',
        'updated_at': '2026-05-19 12:00:05',
        'filenames': ['/app/data/reports/ocbot_daily_20260518.pptx'],
        'max_files': 1,
        'summary': {
            'audited_files': [{
                'path': '/app/data/reports/ocbot_daily_20260518.pptx',
                'slides_checked': 1,
                'issues': 0,
                'error': None,
            }],
            'total_issues': 0,
            'errors': [],
        },
    }), encoding='utf-8')

    status = svc.get_ppt_vision_audit_status()

    assert status['status'] == 'completed'
    assert status['last_run']['updated_at'] == '2026-05-19 12:00:05'
    assert status['last_run']['filenames'] == ['ocbot_daily_20260518.pptx']
    assert '/app/data/reports' not in str(status)


def test_ppt_vision_audit_background_respects_persisted_running(monkeypatch, tmp_path):
    """另一個 worker 已在跑時，新 request 應回 already_running，避免重複開模型任務。"""
    import json
    from services import ppt_vision_service as svc

    state_path = tmp_path / 'vision_state.json'
    monkeypatch.setenv('PPT_VISION_STATE_PATH', str(state_path))
    monkeypatch.setattr(svc, '_LAST_AUDIT_RUN', None)
    state_path.write_text(json.dumps({
        'ok': True,
        'status': 'running',
        'queued_at': '2999-05-19 12:00:00',
        'started_at': '2999-05-19 12:00:01',
        'updated_at': '2999-05-19 12:00:01',
        'filenames': ['/app/data/reports/ocbot_daily_20260518.pptx'],
        'max_files': 1,
    }), encoding='utf-8')

    result = svc.start_ppt_vision_audit_background(
        filenames=['ocbot_daily_20260518.pptx'],
        max_files=1,
    )

    assert result['ok'] is True
    assert result['status'] == 'already_running'
    assert result['last_run']['filenames'] == ['ocbot_daily_20260518.pptx']


def test_ppt_vision_audit_status_marks_dead_worker_stale(monkeypatch, tmp_path):
    """部署 reload 後若舊 worker 已不存在，running state 要自動轉為可診斷錯誤。"""
    import json
    from services import ppt_vision_service as svc

    state_path = tmp_path / 'vision_state.json'
    monkeypatch.setenv('PPT_VISION_STATE_PATH', str(state_path))
    monkeypatch.setattr(svc, '_LAST_AUDIT_RUN', None)
    monkeypatch.setattr(svc, '_pid_exists', lambda _pid: False)
    state_path.write_text(json.dumps({
        'ok': True,
        'status': 'running',
        'queued_at': '2999-05-19 12:00:00',
        'started_at': '2999-05-19 12:00:01',
        'updated_at': '2999-05-19 12:00:01',
        'pid': 999999,
        'filenames': ['/app/data/reports/ocbot_daily_20260518.pptx'],
        'max_files': 1,
    }), encoding='utf-8')

    status = svc.get_ppt_vision_audit_status()

    assert status['running'] is False
    assert status['status'] == 'error'
    assert 'worker no longer running' in status['last_run']['error']
    assert status['last_run']['filenames'] == ['ocbot_daily_20260518.pptx']


def test_ppt_vision_audit_background_allows_retry_after_dead_worker(monkeypatch, tmp_path):
    """dead PID 的 running state 不應阻擋下一次手動補跑。"""
    import json
    from services import ppt_vision_service as svc

    state_path = tmp_path / 'vision_state.json'
    monkeypatch.setenv('PPT_VISION_STATE_PATH', str(state_path))
    monkeypatch.setattr(svc, '_LAST_AUDIT_RUN', None)
    monkeypatch.setattr(svc, '_pid_exists', lambda _pid: False)
    monkeypatch.setattr(svc, 'audit_recent_ppts', lambda **_kwargs: {
        'audited_files': [],
        'total_issues': 0,
        'errors': [],
    })
    state_path.write_text(json.dumps({
        'ok': True,
        'status': 'running',
        'queued_at': '2999-05-19 12:00:00',
        'started_at': '2999-05-19 12:00:01',
        'updated_at': '2999-05-19 12:00:01',
        'pid': 999999,
        'filenames': ['/app/data/reports/ocbot_daily_20260518.pptx'],
        'max_files': 1,
    }), encoding='utf-8')

    result = svc.start_ppt_vision_audit_background(
        filenames=['ocbot_daily_20260518.pptx'],
        max_files=1,
    )

    assert result['ok'] is True
    assert result['status'] == 'queued'


def test_ppt_audit_vision_status_route_returns_json(client, monkeypatch):
    """頁面輪詢用 status endpoint 要能回最近一次背景視覺 QA 狀態。"""
    from services import ppt_vision_service as svc

    monkeypatch.setattr(svc, 'get_ppt_vision_audit_status', lambda: {
        'ok': True,
        'running': False,
        'status': 'completed',
        'status_label': '已完成',
        'message': '最近一次視覺 QA 已完成。',
        'last_run': {
            'summary': {'audited_count': 2, 'total_issues': 1, 'error_count': 0, 'errors': [], 'files': []},
        },
    })

    r = client.get('/observability/ppt_audit/vision_status')
    data = r.get_json()

    assert r.status_code == 200
    assert data['ok'] is True
    assert data['status'] == 'completed'
    assert data['last_run']['summary']['audited_count'] == 2


def test_ppt_audit_history_renders_last_vision_status(client, monkeypatch):
    """PPT 產線頁要在按下立即 QA 前後都看得到背景狀態卡。"""
    from services import ppt_vision_service as svc

    monkeypatch.setattr(svc, 'get_ppt_vision_audit_status', lambda: {
        'ok': True,
        'running': False,
        'status': 'completed',
        'status_label': '已完成',
        'message': '最近一次視覺 QA 已完成。',
        'last_run': {
            'queued_at': '2026-05-19 12:00:00',
            'started_at': '2026-05-19 12:00:01',
            'finished_at': '2026-05-19 12:00:05',
            'summary': {'audited_count': 2, 'total_issues': 1, 'error_count': 0, 'errors': [], 'files': []},
        },
    })

    r = client.get('/observability/ppt_audit_history')
    html = r.get_data(as_text=True)

    assert r.status_code == 200
    assert 'data-ppt-vision-status' in html
    assert 'data-ppt-vision-status-title' in html
    assert '最近一次視覺 QA 已完成。' in html
    assert '2 份 / 1 問題' in html
    assert 'Runtime' in html


def test_ppt_audit_trigger_aider_heal_accepts_issue_summary(client, monkeypatch):
    """視覺 QA failed 常只有 issues_found；AiderHeal 應可吃診斷摘要派工。"""
    from routes import admin_observability_routes as mod
    from services import aider_heal_executor as svc

    captured = {}
    mod._PPT_AIDER_HEAL_ACTIVE.clear()

    def fake_execute_code_fix(**kwargs):
        captured.update(kwargs)
        return {
            'success': True,
            'action': 'CODE_FIX',
            'message': '已派出 AiderHeal',
            'commit_sha': None,
            'reverted': False,
        }

    monkeypatch.setattr(svc, 'execute_code_fix', fake_execute_code_fix)

    class ImmediateThread:
        def __init__(self, target, **_kwargs):
            self.target = target

        def start(self):
            self.target()

    monkeypatch.setattr(mod.threading, 'Thread', ImmediateThread)

    r = client.post(
        '/observability/ppt_audit/trigger_aider_heal',
        json={
            'pptx_filename': 'ocbot_daily_20260517.pptx',
            'issue_summary': 'S1: 圖表被切掉：右側圖例超出邊界',
        },
    )
    data = r.get_json()

    assert r.status_code == 202
    assert data['ok'] is True
    assert data['status'] == 'queued'
    assert captured['error_type'] == 'ppt_vision_audit_failure'
    assert captured['target_file'] == 'services/ppt_generator.py'
    assert 'ocbot_daily_20260517.pptx' in captured['error_message']
    assert '圖表被切掉' in captured['error_message']
    assert captured['context']['issue_summary'] == 'S1: 圖表被切掉：右側圖例超出邊界'
    assert mod._PPT_AIDER_HEAL_ACTIVE == {}


def test_ppt_audit_trigger_aider_heal_dedupes_same_file(client, monkeypatch):
    """同一份 PPT 已在背景修復時，重複按鈕不應重開第二條 AiderHeal。"""
    from routes import admin_observability_routes as mod
    from services import aider_heal_executor as svc

    calls = []

    def fake_execute_code_fix(**kwargs):
        calls.append(kwargs)
        return {
            'success': True,
            'action': 'CODE_FIX',
            'message': '不應在此測試執行',
            'commit_sha': None,
            'reverted': False,
        }

    class HoldingThread:
        def __init__(self, target, **_kwargs):
            self.target = target

        def start(self):
            return None

    monkeypatch.setattr(svc, 'execute_code_fix', fake_execute_code_fix)
    monkeypatch.setattr(mod.threading, 'Thread', HoldingThread)
    mod._PPT_AIDER_HEAL_ACTIVE.clear()

    payload = {
        'pptx_filename': 'ocbot_daily_20260517.pptx',
        'issue_summary': 'S1: 圖表被切掉：右側圖例超出邊界',
    }
    first = client.post('/observability/ppt_audit/trigger_aider_heal', json=payload)
    second = client.post('/observability/ppt_audit/trigger_aider_heal', json=payload)

    try:
        assert first.status_code == 202
        assert first.get_json()['status'] == 'queued'
        assert second.status_code == 202
        assert second.get_json()['status'] == 'already_running'
        assert calls == []
    finally:
        mod._PPT_AIDER_HEAL_ACTIVE.clear()


def test_ppt_audit_aider_heal_status_reports_active_jobs(client, monkeypatch):
    """背景 AiderHeal 已派工時，狀態端點要能讓頁面重新整理後看見執行中。"""
    from routes import admin_observability_routes as mod
    from services import aider_heal_executor as svc

    def fake_execute_code_fix(**_kwargs):
        return {'success': True, 'message': '不應在此測試執行'}

    class HoldingThread:
        def __init__(self, target, **_kwargs):
            self.target = target

        def start(self):
            return None

    monkeypatch.setattr(svc, 'execute_code_fix', fake_execute_code_fix)
    monkeypatch.setattr(mod.threading, 'Thread', HoldingThread)
    mod._PPT_AIDER_HEAL_ACTIVE.clear()

    payload = {
        'pptx_filename': 'ocbot_daily_20260517.pptx',
        'issue_summary': 'S1: 圖表被切掉：右側圖例超出邊界',
    }
    first = client.post('/observability/ppt_audit/trigger_aider_heal', json=payload)
    status = client.get('/observability/ppt_audit/aider_heal_status')

    try:
        assert first.status_code == 202
        data = status.get_json()
        assert status.status_code == 200
        assert data['ok'] is True
        assert data['active_count'] == 1
        assert data['jobs'][0]['pptx_filename'] == 'ocbot_daily_20260517.pptx'
        assert data['jobs'][0]['target_file'] == 'services/ppt_generator.py'
        assert '圖表被切掉' in data['jobs'][0]['diagnosis']
    finally:
        mod._PPT_AIDER_HEAL_ACTIVE.clear()


def test_ppt_audit_history_shows_active_aider_heal_jobs(client):
    """PPT 產線頁要直接顯示正在背景修復的檔案。"""
    from routes import admin_observability_routes as mod

    mod._PPT_AIDER_HEAL_ACTIVE.clear()
    mod._PPT_AIDER_HEAL_ACTIVE['ocbot_daily_20260517.pptx'] = {
        'key': 'ocbot_daily_20260517.pptx',
        'pptx_filename': 'ocbot_daily_20260517.pptx',
        'target_file': 'services/ppt_generator.py',
        'queued_at': '2026-05-18 14:42:00',
        'diagnosis': 'S1: 圖表被切掉：右側圖例超出邊界',
    }
    try:
        r = client.get('/observability/ppt_audit_history')
        html = r.get_data(as_text=True)
        assert r.status_code == 200
        assert 'AiderHeal 執行中' in html
        assert 'ocbot_daily_20260517.pptx' in html
        assert '圖表被切掉' in html
    finally:
        mod._PPT_AIDER_HEAL_ACTIVE.clear()


def test_ppt_pipeline_view_regenerates_audit_failure_with_inferred_report_type():
    """QA 失敗不能硬編 daily；重跑要回到原本的簡報類型。"""
    from routes import admin_observability_routes as mod

    view = mod._build_ppt_pipeline_view(
        files=[],
        auto_generation={'items': [], 'ready_count': 0, 'total': 0, 'missing_count': 0},
        audit_stats={'total': 1, 'total_issues': 2, 'pass_rate': 0},
        generation_runs=[],
        vision_status={'ready': True},
        audit_records=[{
            'pptx_filename': 'ocbot_ttm_8fd2ae22.pptx',
            'audited_at': '2026-05-18 14:42',
            'audit_status': 'failed',
            'issues_count': 2,
            'confidence': 0.71,
            'issue_summary': 'S1: footer clipped',
        }],
    )

    triage_entry = view['action_lanes'][0]['entries'][0]
    audit_entry = view['action_lanes'][3]['entries'][0]

    assert triage_entry['report_type'] == 'ttm'
    assert triage_entry['can_regenerate'] is True
    assert audit_entry['report_type'] == 'ttm'
    assert audit_entry['can_regenerate'] is True


# ──────────────────────────────────────────────────────────────────────────
# /observability/host_health
# ──────────────────────────────────────────────────────────────────────────

def test_host_health_200(client, monkeypatch):
    """全 mock 三主機 + MCP，避免實際連線。"""
    import services.ollama_service as ollama_mod
    monkeypatch.setattr(ollama_mod, '_is_unhealthy', lambda _h: False, raising=False)
    monkeypatch.setattr(ollama_mod, '_unhealthy_marks', {}, raising=False)

    # mock requests.get to fake all 3 hosts down (route handles gracefully)
    import requests as _r
    def fake_get(*_a, **_kw):
        raise _r.exceptions.ConnectionError('mocked')
    monkeypatch.setattr(_r, 'get', fake_get)

    r = client.get('/observability/host_health')
    assert r.status_code == 200


def test_host_health_skips_probe_persistence_on_sqlite(client, monkeypatch):
    """Local visual QA uses SQLite; GET page should not warn on BIGINT autoincrement drift."""
    from routes import admin_observability_routes as mod
    import services.ollama_service as ollama_mod
    import requests as _r

    class _Dialect:
        name = 'sqlite'

    class _Bind:
        dialect = _Dialect()

    session = _fake_session([])
    session.get_bind.return_value = _Bind()
    monkeypatch.setattr(mod, 'get_session', lambda: session)
    monkeypatch.setattr(ollama_mod, '_is_unhealthy', lambda _h: False, raising=False)
    monkeypatch.setattr(ollama_mod, '_unhealthy_marks', {}, raising=False)

    class FakeResponse:
        status_code = 200

        def json(self):
            return {'models': [{'name': 'bge-m3'}]}

    monkeypatch.setattr(_r, 'get', lambda *_a, **_kw: FakeResponse())

    r = client.get('/observability/host_health')

    assert r.status_code == 200
    insert_calls = [
        call for call in session.execute.call_args_list
        if 'INSERT INTO host_health_probes' in str(call.args[0])
    ]
    assert insert_calls == []


# ──────────────────────────────────────────────────────────────────────────
# Phase 33 Auth Hardening — 未登入必 302 redirect 到 /login
# ──────────────────────────────────────────────────────────────────────────

def test_anon_get_redirects_to_login(anon_client):
    """未登入打 GET 路由 → @login_required 必 302 redirect 到 /login。"""
    for path in [
        '/observability/',
        '/observability/overview',
        '/observability/rag_queries',
        '/observability/business_intel',
        '/observability/agent_orchestration',
        '/observability/ai_calls',
        '/observability/promotion_review',
        '/observability/quality_trend',
        '/observability/host_health',
        '/observability/budget',
        '/observability/ppt_audit_history',
        '/observability/api/health_indicator',
    ]:
        r = anon_client.get(path)
        # 308（permanent redirect for trailing slash）或 302（login redirect）皆視為阻擋
        assert r.status_code in (302, 308), f'{path} 未強制 login (got {r.status_code})'


def test_anon_post_blocked(anon_client):
    """未登入 POST mutation 端點 → 必 302 redirect（防 anon 執行任何 mutation）。"""
    posts = [
        ('/observability/promotion_review/approve/1', None),
        ('/observability/promotion_review/reject/1', None),
        ('/observability/budget/update/1', {'budget_usd': 99, 'alert_pct': 80}),
        ('/observability/ai_calls/trigger_code_review', None),
        ('/observability/ppt_audit/trigger_aider_heal', None),
        ('/observability/playbooks/toggle/1', None),
        ('/observability/host_health/trigger_autoheal', None),
        ('/observability/budget/force_throttle', None),
    ]
    for path, body in posts:
        r = anon_client.post(path, json=body) if body else anon_client.post(path)
        assert r.status_code in (302, 308), f'{path} POST 未強制 login (got {r.status_code})'


# ──────────────────────────────────────────────────────────────────────────
# Phase 38+ 新增 GET 路由 smoke
# ──────────────────────────────────────────────────────────────────────────

def test_overview_index_200(client, monkeypatch):
    """/observability/ (root index) — 觀測台總覽。"""
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.get('/observability/')
    assert r.status_code in (200, 308)


def test_overview_dashboard_200(client, monkeypatch):
    """/observability/overview — Phase 45 總覽頁。"""
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    # mock requests for 三主機 sparkline
    import requests as _r
    def fake_get(*_a, **_kw):
        raise _r.exceptions.ConnectionError('mocked')
    monkeypatch.setattr(_r, 'get', fake_get)
    r = client.get('/observability/overview')
    assert r.status_code == 200


def test_rag_queries_200(client, monkeypatch):
    """/observability/rag_queries — Phase 51 RAG 召回詳情頁。"""
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.get('/observability/rag_queries')
    assert r.status_code == 200


def test_business_intel_200(client, monkeypatch):
    """/observability/business_intel — Phase 48 商業面 × AI 編排。"""
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.get('/observability/business_intel')
    assert r.status_code == 200


def test_agent_orchestration_200(client, monkeypatch):
    """/observability/agent_orchestration — Phase 46 Agent 編排矩陣。"""
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.get('/observability/agent_orchestration')
    assert r.status_code == 200


def test_health_indicator_api_returns_json(client, monkeypatch):
    """/observability/api/health_indicator — Phase 52 topbar 健康指示燈 JSON API。"""
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.get('/observability/api/health_indicator')
    assert r.status_code == 200
    assert r.content_type.startswith('application/json'), \
        f'expected JSON, got {r.content_type}'


# ──────────────────────────────────────────────────────────────────────────
# Phase 50/40 mutation endpoints — logged-in 成功路徑驗 JSON 結構
# ──────────────────────────────────────────────────────────────────────────

def test_playbook_toggle_404_on_missing(client, monkeypatch):
    """playbook_toggle 對不存在 id 必回 404 + ok=false。"""
    from routes import admin_observability_routes as mod
    from unittest.mock import MagicMock
    # 自訂 mock：fetchone 回 None 表示「playbook 不存在」
    sess = MagicMock()
    result = MagicMock()
    result.fetchone.return_value = None
    sess.execute.return_value = result
    sess.close = MagicMock()
    monkeypatch.setattr(mod, 'get_session', lambda: sess)

    r = client.post('/observability/playbooks/toggle/9999')
    assert r.status_code == 404
    body = r.get_json()
    assert body['ok'] is False
    assert '9999' in body['error']


def test_playbook_toggle_flips_active_flag(client, monkeypatch):
    """playbook_toggle 應翻轉 is_active 並回新狀態。"""
    from routes import admin_observability_routes as mod
    # 模擬現有 playbook (id=5, name='test', is_active=False)
    monkeypatch.setattr(
        mod, 'get_session',
        lambda: _fake_session([(5, 'test_playbook', False)]),
    )
    r = client.post('/observability/playbooks/toggle/5')
    assert r.status_code == 200
    body = r.get_json()
    assert body['ok'] is True
    assert body['is_active'] is True   # 從 False 翻 True
    assert body['name'] == 'test_playbook'
    assert '啟用' in body['message']


def test_budget_force_throttle_invokes_evaluate(client, monkeypatch):
    """budget_force_throttle 應呼叫 cost_throttle.evaluate() 且回 ok=true。"""
    from routes import admin_observability_routes as mod
    invoked = {'count': 0}

    class FakeThrottle:
        def evaluate(self):
            invoked['count'] += 1
            return {'gemini': {'throttled': False, 'ratio': 0.5}}

    # patch import 路徑（route 內 from services.cost_throttle_service import ...）
    import services.cost_throttle_service as cts_mod
    monkeypatch.setattr(cts_mod, 'cost_throttle_service', FakeThrottle(), raising=False)

    r = client.post('/observability/budget/force_throttle')
    # 路由不爆即可（auth 通 + JSON 回）；status code 視 service 實作可為
    # 200（正常）/ 400（驗證失敗）/ 500（service 異常）
    assert r.status_code in (200, 400, 500)
    assert r.is_json


def test_ai_calls_trigger_code_review_returns_json(client, monkeypatch):
    """trigger_code_review 至少要回 JSON（success or service unavailable 都 ok）。"""
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.post('/observability/ai_calls/trigger_code_review')
    assert r.is_json
    assert r.status_code in (200, 500, 503)


def test_host_health_trigger_autoheal_returns_json(client, monkeypatch):
    """trigger_autoheal 至少要回 JSON。"""
    from routes import admin_observability_routes as mod
    monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
    r = client.post(
        '/observability/host_health/trigger_autoheal',
        json={'host': 'http://192.168.0.111:11434'},
    )
    assert r.is_json
    assert r.status_code in (200, 400, 500)
