#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
services/ppt_vision_service.py
Operation Ollama-First v5.0 / Phase 14 — PPT 視覺自審

設計原則：
- 用 minicpm-v（GCP Primary 已拉，5.5GB）對 PPT 截圖做品質檢查
- 替代 qwen2-vl:7b（Ollama registry 暫無）
- 用途：PPT 生成後自動跑視覺檢查，找：
    1. 圖表 layout 異常（被切掉、重疊）
    2. 文字溢出框
    3. 空白區塊（資料未填滿）
    4. 配色衝突
- feature flag 由部署環境控制；正式 compose 預設 ON，程式本身仍 fail-safe
- 失敗自動 skip（不阻擋 PPT 生成主流程）
"""

from __future__ import annotations
import os
import time
import base64
import json
import logging
import shutil
import threading
from io import BytesIO
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional, Dict, Any, List, Sequence

logger = logging.getLogger(__name__)

# ─────────────────────────────────────────────────────────────────────────────
# Feature flag + 配置
# ─────────────────────────────────────────────────────────────────────────────
PPT_VISION_MODEL = os.getenv('PPT_VISION_MODEL', 'minicpm-v:latest')
PPT_VISION_TIMEOUT = int(os.getenv('PPT_VISION_TIMEOUT', '120'))
PPT_VISION_MAX_SLIDES = int(os.getenv('PPT_VISION_MAX_SLIDES', '1'))
PPT_VISION_IMAGE_MAX_EDGE = int(os.getenv('PPT_VISION_IMAGE_MAX_EDGE', '1280'))
PPT_VISION_IMAGE_QUALITY = int(os.getenv('PPT_VISION_IMAGE_QUALITY', '82'))
_AUDIT_LOCK = threading.Lock()
_LAST_AUDIT_RUN: Dict[str, Any] | None = None
_ACTIVE_AUDIT_TTL_SECONDS = int(os.getenv('PPT_VISION_ACTIVE_TTL_SECONDS', '7200'))


def is_ppt_vision_enabled() -> bool:
    """Runtime check（避免 import-time freeze）"""
    return os.getenv('PPT_VISION_ENABLED', 'false').strip().lower() in ('true', '1', 'yes', 'on')


def get_ppt_vision_runtime_status() -> Dict[str, Any]:
    """Expose why the PPT vision pipeline is or is not ready."""
    env_value = os.getenv('PPT_VISION_ENABLED')
    enabled = is_ppt_vision_enabled()
    model = os.getenv('PPT_VISION_MODEL', PPT_VISION_MODEL)
    converter = shutil.which('libreoffice') or shutil.which('soffice')
    blockers = []
    if not enabled:
        blockers.append('PPT_VISION_ENABLED 未設定為 true')
    if not converter:
        blockers.append('容器內缺少 LibreOffice，無法轉換 PPT 做視覺審核')
    ready = enabled and bool(converter)
    readiness_checks = [
        {
            'key': 'feature_flag',
            'label': '功能開關',
            'value': f"PPT_VISION_ENABLED={env_value if env_value is not None else '未設定'}",
            'status': 'ready' if enabled else 'error',
            'detail': '已允許背景視覺 QA 排程與手動補跑。' if enabled else '目前會阻擋立即視覺 QA 按鈕。',
        },
        {
            'key': 'converter',
            'label': '轉檔器',
            'value': converter or 'not found',
            'status': 'ready' if converter else 'error',
            'detail': '可將 PPTX 轉成投影片截圖。' if converter else '缺轉檔器時無法建立視覺模型輸入。',
        },
        {
            'key': 'vision_model',
            'label': '視覺模型',
            'value': model,
            'status': 'ready' if model else 'planned',
            'detail': '推理時走 Ollama-first 三主機 fallback；頁面載入不同步打模型。',
        },
    ]
    next_actions = []
    if not enabled:
        next_actions.append('在 momo-app / scheduler 環境設定 PPT_VISION_ENABLED=true，重新 recreate 相關 app 容器。')
    if not converter:
        next_actions.append('確認映像已安裝 LibreOffice Impress，完成 rebuild 後再重啟 momo-app / scheduler。')
    if ready:
        next_actions.append('環境已就緒，可在本頁對最近 PPTX 立即補跑視覺 QA。')
    return {
        'enabled': enabled,
        'env_value': env_value if env_value is not None else '未設定（預設 false）',
        'model': model,
        'converter': converter,
        'converter_ready': bool(converter),
        'blockers': blockers,
        'ready': ready,
        'ready_count': sum(1 for item in readiness_checks if item['status'] == 'ready'),
        'check_count': len(readiness_checks),
        'status_label': '可執行' if ready else '環境未就緒',
        'summary': '視覺 QA runtime 已具備功能開關、轉檔器與模型設定。' if ready else '視覺 QA runtime 仍有必要條件未通過。',
        'checked_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
        'readiness_checks': readiness_checks,
        'next_actions': next_actions,
    }


def _audit_state_path() -> str:
    return os.getenv(
        'PPT_VISION_STATE_PATH',
        os.path.join(os.getenv('DATA_DIR', os.path.join(os.getcwd(), 'data')), 'ppt_vision_audit_status.json'),
    )


def _now_label() -> str:
    return time.strftime('%Y-%m-%d %H:%M:%S')


def _read_persisted_audit_run() -> Dict[str, Any] | None:
    path = _audit_state_path()
    try:
        if not os.path.isfile(path):
            return None
        with open(path, 'r', encoding='utf-8') as handle:
            payload = json.load(handle)
        return payload if isinstance(payload, dict) else None
    except Exception:
        logger.debug("[PPTVision] read audit state failed", exc_info=True)
        return None


def _write_persisted_audit_run(run: Dict[str, Any]) -> None:
    path = _audit_state_path()
    directory = os.path.dirname(path)
    try:
        os.makedirs(directory, exist_ok=True)
        tmp_path = f"{path}.tmp"
        with open(tmp_path, 'w', encoding='utf-8') as handle:
            json.dump(run, handle, ensure_ascii=False)
        os.replace(tmp_path, path)
    except Exception:
        logger.debug("[PPTVision] write audit state failed", exc_info=True)


def _record_audit_run(run: Dict[str, Any]) -> Dict[str, Any]:
    global _LAST_AUDIT_RUN
    payload = dict(run)
    payload['updated_at'] = payload.get('updated_at') or _now_label()
    payload['pid'] = payload.get('pid') or os.getpid()
    _LAST_AUDIT_RUN = payload
    _write_persisted_audit_run(payload)
    return payload


def _load_last_audit_run() -> Dict[str, Any] | None:
    persisted = _read_persisted_audit_run()
    if not _LAST_AUDIT_RUN:
        return persisted
    if not persisted:
        return _LAST_AUDIT_RUN
    if str(persisted.get('updated_at') or '') >= str(_LAST_AUDIT_RUN.get('updated_at') or ''):
        return persisted
    return _LAST_AUDIT_RUN


def _timestamp_age_seconds(value: str | None) -> float | None:
    if not value:
        return None
    try:
        parsed = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
        return max(0.0, (datetime.now() - parsed).total_seconds())
    except Exception:
        return None


def _pid_exists(pid: Any) -> bool:
    try:
        pid_int = int(pid or 0)
    except Exception:
        return False
    if pid_int <= 0:
        return False
    try:
        os.kill(pid_int, 0)
        return True
    except OSError:
        return False


def _is_recent_active_audit_run(run: Dict[str, Any] | None) -> bool:
    if not run or run.get('status') not in {'queued', 'running'}:
        return False
    if run.get('pid') and not _pid_exists(run.get('pid')):
        return False
    age = _timestamp_age_seconds(run.get('updated_at') or run.get('started_at') or run.get('queued_at'))
    return age is None or age < _ACTIVE_AUDIT_TTL_SECONDS


def _mark_stale_audit_run(run: Dict[str, Any]) -> Dict[str, Any]:
    payload = dict(run)
    payload.update({
        'ok': False,
        'status': 'error',
        'finished_at': payload.get('finished_at') or _now_label(),
        'error': 'background worker no longer running; audit state marked stale',
    })
    return _record_audit_run(payload)


def _is_vision_infra_error(error: str | None) -> bool:
    text = (error or '').lower()
    return any(marker in text for marker in (
        'all 3 hosts failed',
        'connection',
        'ollama vision failed',
        'timeout',
    ))


def _public_audit_run_payload(run: Dict[str, Any] | None) -> Dict[str, Any] | None:
    if not run:
        return None
    summary = run.get('summary') or {}
    audited_files = []
    for item in summary.get('audited_files') or []:
        path = item.get('path') or ''
        audited_files.append({
            'filename': os.path.basename(path) if path else '',
            'slides_checked': int(item.get('slides_checked') or 0),
            'issues': int(item.get('issues') or 0),
            'error': item.get('error') or '',
        })
    errors = [str(error)[:160] for error in (summary.get('errors') or [])[:3]]
    payload = {
        'ok': bool(run.get('ok')),
        'status': run.get('status') or 'unknown',
        'queued_at': run.get('queued_at') or '',
        'started_at': run.get('started_at') or '',
        'finished_at': run.get('finished_at') or '',
        'updated_at': run.get('updated_at') or '',
        'pid': run.get('pid') or None,
        'filenames': [
            os.path.basename(str(name))
            for name in (run.get('filenames') or [])
            if str(name).lower().endswith('.pptx')
        ],
        'max_files': run.get('max_files'),
        'error': run.get('error') or '',
        'summary': {
            'audited_count': len(audited_files),
            'total_issues': int(summary.get('total_issues') or 0),
            'error_count': len(summary.get('errors') or []),
            'errors': errors,
            'files': audited_files[:5],
        },
    }
    return payload


def get_ppt_vision_audit_status() -> Dict[str, Any]:
    """Return the current/last background visual QA run without touching DB."""
    raw_run = _load_last_audit_run()
    if raw_run and raw_run.get('status') in {'queued', 'running'} and not _is_recent_active_audit_run(raw_run):
        raw_run = _mark_stale_audit_run(raw_run)
    running = _AUDIT_LOCK.locked() or _is_recent_active_audit_run(raw_run)
    last_run = _public_audit_run_payload(raw_run)
    if running:
        status = 'running'
        status_label = '執行中'
        message = '視覺 QA 正在背景審核簡報。'
    elif last_run:
        status = last_run.get('status') or 'unknown'
        status_label = {
            'queued': '已排入',
            'running': '執行中',
            'completed': '已完成',
            'error': '錯誤',
        }.get(status, status)
        message = '最近一次視覺 QA 已完成。' if status == 'completed' else '最近一次視覺 QA 狀態可查。'
    else:
        status = 'idle'
        status_label = '待命'
        message = '尚未有背景視覺 QA 執行紀錄。'
    return {
        'ok': True,
        'running': running,
        'status': status,
        'status_label': status_label,
        'message': message,
        'last_run': last_run,
    }


# ─────────────────────────────────────────────────────────────────────────────
# 結果容器
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class VisionResult:
    success: bool
    issues_found: List[str] = field(default_factory=list)  # 問題清單
    confidence: float = 0.0                                 # 0-1，模型自評
    raw_response: str = ''
    duration_ms: int = 0
    error: Optional[str] = None


# ─────────────────────────────────────────────────────────────────────────────
# Vision 檢查 prompt（繁中強制）
# ─────────────────────────────────────────────────────────────────────────────
PPT_VISION_SYSTEM_PROMPT = """你是 momo 電商 PPT 排版品質審核員。

【任務】檢查截圖找出視覺異常，回繁中清單格式：
- 圖表被切掉 / 元素重疊 / 文字溢出框 / 空白區塊（資料未填滿）/ 配色衝突
- 商品名稱顯示不完整 / 數字單位錯誤 / 標題遮擋

【輸出格式】
若無問題：回「✅ 無視覺異常」
若有問題：每行一個問題，格式「⚠️ <問題類型>：<具體描述>」

【限制】
- 只檢查視覺，不評估內容對錯
- 用繁體中文（台灣用語），絕對禁止簡體字
- 不要寫過多解釋，每個問題一行精簡描述
"""


class PPTVisionService:
    """minicpm-v 視覺檢查服務."""

    def __init__(self, model: str = PPT_VISION_MODEL):
        self.model = model

    def is_available(self) -> bool:
        return is_ppt_vision_enabled()

    def _encode_image_for_vision(self, image_path: str) -> str:
        """Compress slide screenshots before sending to Ollama vision."""
        try:
            from PIL import Image

            with Image.open(image_path) as im:
                image = im.convert('RGB')
                image.thumbnail((PPT_VISION_IMAGE_MAX_EDGE, PPT_VISION_IMAGE_MAX_EDGE))
                buffer = BytesIO()
                image.save(
                    buffer,
                    format='JPEG',
                    quality=max(50, min(PPT_VISION_IMAGE_QUALITY, 95)),
                    optimize=True,
                )
                return base64.b64encode(buffer.getvalue()).decode('ascii')
        except Exception:
            # Pillow is an optimization, not a hard dependency for the vision path.
            with open(image_path, 'rb') as f:
                return base64.b64encode(f.read()).decode('ascii')

    def check_ppt_file(self, pptx_path: str, max_slides: int | None = None) -> Dict[str, Any]:
        """檢查整份 .pptx — Phase 26 整合到 PPT 生成流程。

        流程：
          1. LibreOffice headless 轉 png（每張 slide 一張）
          2. 對前 N 張跑 check_image
          3. 彙總 issues + 平均 confidence
          4. fail-safe：LibreOffice 不在 / 轉檔失敗 → 回 skip 不阻擋主流程

        Returns:
            {
                'success': bool,
                'slides_checked': int,
                'total_issues': int,
                'issues_by_slide': [(slide_num, [issues...]), ...],
                'error': str | None,
            }
        """
        import os
        import subprocess
        import tempfile

        max_slides = max(1, int(max_slides or PPT_VISION_MAX_SLIDES))
        result = {
            'success': False, 'slides_checked': 0, 'total_issues': 0,
            'issues_by_slide': [], 'error': None,
        }

        if not self.is_available():
            result['error'] = 'PPT_VISION_ENABLED=false'
            return result

        if not os.path.isfile(pptx_path):
            result['error'] = f'pptx not found: {pptx_path}'
            return result

        converter = shutil.which('libreoffice') or shutil.which('soffice')
        if not converter:
            result['error'] = 'libreoffice not installed (skip vision check)'
            return result

        def _finish_with_error(message: str, duration_ms: int = 0) -> Dict[str, Any]:
            result['error'] = message
            try:
                self._persist_audit_result(
                    pptx_path=pptx_path,
                    result=result,
                    avg_confidence=0.0,
                    duration_ms=duration_ms,
                )
            except Exception as e:
                logger.warning(f"[PPTVision] persist audit result failed: {e}")
            return result

        # 1. LibreOffice 轉 png
        with tempfile.TemporaryDirectory() as tmpdir:
            convert_started = time.monotonic()
            try:
                proc = subprocess.run(
                    [converter, '--headless', '--convert-to', 'png',
                     '--outdir', tmpdir, pptx_path],
                    capture_output=True, timeout=60,
                )
                if proc.returncode != 0:
                    return _finish_with_error(
                        f'libreoffice convert failed: {proc.stderr.decode()[:200]}',
                        int((time.monotonic() - convert_started) * 1000),
                    )
            except FileNotFoundError:
                result['error'] = 'libreoffice not installed (skip vision check)'
                return result
            except subprocess.TimeoutExpired:
                return _finish_with_error(
                    'libreoffice convert timeout (60s)',
                    int((time.monotonic() - convert_started) * 1000),
                )
            except Exception as e:
                return _finish_with_error(
                    f'{type(e).__name__}: {str(e)[:200]}',
                    int((time.monotonic() - convert_started) * 1000),
                )

            # LibreOffice 對 .pptx 預設只輸出第一頁；多頁需 --convert-to png:impress_png_Export
            png_files = sorted([
                os.path.join(tmpdir, f) for f in os.listdir(tmpdir)
                if f.lower().endswith('.png')
            ])

            if not png_files:
                return _finish_with_error(
                    'libreoffice 未產出 png (可能需要 --convert-to png:impress_png_Export)',
                    int((time.monotonic() - convert_started) * 1000),
                )

            # 2. 對前 N 張跑 check_image
            import time as _time
            t0 = _time.monotonic()
            confidences = []
            slide_errors = []
            for idx, png in enumerate(png_files[:max_slides]):
                try:
                    vr = self.check_image(png)
                    if vr.success:
                        result['slides_checked'] += 1
                        confidences.append(vr.confidence)
                        if vr.issues_found:
                            result['total_issues'] += len(vr.issues_found)
                            result['issues_by_slide'].append((idx + 1, vr.issues_found))
                    else:
                        message = f"slide {idx + 1}: {vr.error or 'vision model failed'}"
                        slide_errors.append(message)
                        if _is_vision_infra_error(vr.error):
                            break
                except Exception as exc:
                    message = f"slide {idx + 1}: {type(exc).__name__}: {str(exc)[:160]}"
                    slide_errors.append(message)
                    logger.warning(f"[PPTVision] slide {idx+1} check failed: {exc}")
                    if _is_vision_infra_error(message):
                        break

            result['success'] = result['slides_checked'] > 0
            if not result['success'] and slide_errors:
                result['error'] = '；'.join(slide_errors[:3])
            if slide_errors:
                result['slide_errors'] = slide_errors
            duration_ms = int((_time.monotonic() - t0) * 1000)

            # Phase 38：寫入 ppt_audit_results 留歷史（失敗安全）
            try:
                self._persist_audit_result(
                    pptx_path=pptx_path,
                    result=result,
                    avg_confidence=(sum(confidences) / len(confidences)) if confidences else 0.0,
                    duration_ms=duration_ms,
                )
            except Exception as e:
                logger.warning(f"[PPTVision] persist audit result failed: {e}")

            return result

    def _persist_audit_result(self, pptx_path: str, result: Dict[str, Any],
                              avg_confidence: float, duration_ms: int) -> None:
        """Phase 38: 把每次 audit 結果寫入 ppt_audit_results 表。

        失敗安全：DB 寫入失敗只 log warning，不擋主流程。
        """
        import os
        from datetime import datetime as _dt
        from sqlalchemy import text as _sa_text
        from database.manager import get_session

        # 推論 audit_status
        if result.get('error'):
            err = result['error']
            if 'libreoffice not installed' in err or 'PPT_VISION_ENABLED' in err:
                status = 'skipped'
            else:
                status = 'error'
        elif result.get('total_issues', 0) > 0:
            status = 'failed'
        elif result.get('success'):
            status = 'passed'
        else:
            status = 'error'

        # issues_found JSONB 序列化
        import json as _json
        issues_json = _json.dumps([
            {'slide': slide_num, 'issues': issues}
            for slide_num, issues in result.get('issues_by_slide', [])
        ], ensure_ascii=False)

        try:
            size_kb = round(os.path.getsize(pptx_path) / 1024, 1) if os.path.isfile(pptx_path) else None
            mtime = _dt.fromtimestamp(os.path.getmtime(pptx_path)) if os.path.isfile(pptx_path) else None
        except OSError:
            size_kb = None
            mtime = None

        session = get_session()
        try:
            session.execute(
                _sa_text("""
                    INSERT INTO ppt_audit_results
                        (pptx_filename, pptx_size_kb, pptx_mtime, vision_enabled,
                         audit_status, issues_count, issues_found, confidence,
                         duration_ms, error_msg)
                    VALUES
                        (:fname, :sz, :mt, :ve, :st, :ic, CAST(:if AS JSONB),
                         :cf, :du, :em)
                """),
                {
                    'fname': os.path.basename(pptx_path),
                    'sz': size_kb,
                    'mt': mtime,
                    've': True,  # 進到這裡代表 vision 已 enabled
                    'st': status,
                    'ic': result.get('total_issues', 0),
                    'if': issues_json,
                    'cf': round(avg_confidence, 3),
                    'du': duration_ms,
                    'em': result.get('error', None),
                },
            )
            session.commit()
        finally:
            session.close()

    def check_image(self, image_path: str) -> VisionResult:
        """檢查單張 PPT 截圖。

        Args:
            image_path: 本地檔案路徑（jpg/png）

        Returns:
            VisionResult.issues_found 含問題清單；無問題則空 list + confidence=1.0
        """
        start = time.monotonic()

        if not self.is_available():
            return VisionResult(
                success=False,
                error='PPT_VISION_ENABLED=false (Phase 14 預設 OFF)',
            )

        if not os.path.isfile(image_path):
            return VisionResult(
                success=False,
                error=f'image not found: {image_path}',
            )

        # 讀檔並 base64 編碼；可用時先壓縮縮圖，避免 Ollama vision 被大圖拖慢。
        try:
            img_b64 = self._encode_image_for_vision(image_path)
        except Exception as e:
            return VisionResult(
                success=False,
                error=f'read image failed: {type(e).__name__}: {str(e)[:200]}',
            )

        try:
            from services.ollama_service import OllamaService

            ollama = OllamaService(model=self.model)
            resp = ollama.generate(
                prompt='請檢查這張 momo 電商 PPT 截圖，找出視覺異常。',
                model=self.model,
                system_prompt=PPT_VISION_SYSTEM_PROMPT,
                temperature=0.2,
                timeout=PPT_VISION_TIMEOUT,
                keep_alive='5m',
                options={'num_predict': 256},
                images=[img_b64],
            )
            duration_ms = int((time.monotonic() - start) * 1000)

            if not resp.success:
                return VisionResult(
                    success=False, duration_ms=duration_ms,
                    error=resp.error or 'ollama vision failed',
                )

            raw = (resp.content or '').strip()

            # 解析輸出：每行一個 ⚠️ 開頭的視為 issue；✅ 無視覺異常則空 list
            issues = []
            for line in raw.split('\n'):
                line = line.strip()
                if line.startswith('⚠️') or line.startswith('warning:') or line.startswith('警告'):
                    issues.append(line)

            if '✅' in raw and '無視覺異常' in raw and not issues:
                # 確認是 OK
                return VisionResult(
                    success=True, issues_found=[],
                    confidence=1.0, raw_response=raw,
                    duration_ms=duration_ms,
                )

            return VisionResult(
                success=True, issues_found=issues,
                confidence=0.85 if issues else 0.5,
                raw_response=raw,
                duration_ms=duration_ms,
            )

        except Exception as e:
            duration_ms = int((time.monotonic() - start) * 1000)
            return VisionResult(
                success=False, duration_ms=duration_ms,
                error=f'{type(e).__name__}: {str(e)[:200]}',
            )


# 全域單例
ppt_vision_service = PPTVisionService()


def audit_recent_ppts(reports_dir: str | None = None, hours: int = 24,
                      max_files: int = 10,
                      filenames: Sequence[str] | None = None) -> Dict[str, Any]:
    """Phase 26 整合 hook — 每日 22:00 cron 跑：掃 reports/ 當天新增 .pptx 跑視覺檢查。

    Args:
        reports_dir: PPT 輸出目錄，未提供時改用 REPORTS_DIR 環境變數
        hours: 掃過去 N 小時內的檔
        max_files: 一次最多查 N 個檔（避免一次跑太久）

    Returns:
        {
            'audited_files': [...],
            'total_issues': int,
            'errors': [...],
        }
    """
    import os
    import time

    summary = {'audited_files': [], 'total_issues': 0, 'errors': []}

    if reports_dir is None:
        reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports')

    if not is_ppt_vision_enabled():
        summary['errors'].append('PPT_VISION_ENABLED=false')
        return summary

    if not os.path.isdir(reports_dir):
        summary['errors'].append(f'{reports_dir} not found')
        return summary

    requested_names = {
        os.path.basename(str(name))
        for name in (filenames or [])
        if str(name).lower().endswith('.pptx')
    }

    # 掃當天新增 .pptx；若指定 filenames，直接審指定檔，不受 hours 視窗限制。
    cutoff = time.time() - hours * 3600
    pptx_files = []
    for f in os.listdir(reports_dir):
        if not f.lower().endswith('.pptx'):
            continue
        if requested_names and f not in requested_names:
            continue
        full = os.path.join(reports_dir, f)
        try:
            if requested_names or os.path.getmtime(full) >= cutoff:
                pptx_files.append((os.path.getmtime(full), full))
        except OSError:
            continue
    if requested_names:
        found_names = {os.path.basename(path) for _mtime, path in pptx_files}
        for missing in sorted(requested_names - found_names):
            summary['errors'].append(f'{missing}: file not found')
    pptx_files.sort(reverse=True)
    pptx_files = pptx_files[:max_files]

    svc = PPTVisionService()
    for mtime, path in pptx_files:
        try:
            result = svc.check_ppt_file(path)
            entry = {
                'path': path,
                'slides_checked': result.get('slides_checked', 0),
                'issues': result.get('total_issues', 0),
                'issues_by_slide': result.get('issues_by_slide', []),
                'error': result.get('error'),
            }
            summary['audited_files'].append(entry)
            summary['total_issues'] += entry['issues']
            if entry['error']:
                summary['errors'].append(f"{path}: {entry['error']}")
        except Exception as exc:
            summary['errors'].append(f'{path}: {type(exc).__name__}: {str(exc)[:150]}')

    return summary


def start_ppt_vision_audit_background(
    *,
    reports_dir: str | None = None,
    hours: int = 24,
    max_files: int = 10,
    filenames: Sequence[str] | None = None,
) -> Dict[str, Any]:
    """Queue a non-blocking PPT vision audit run for the admin UI."""
    current_run = _load_last_audit_run()
    if _AUDIT_LOCK.locked() or _is_recent_active_audit_run(current_run):
        return {
            'ok': True,
            'status': 'already_running',
            'message': 'PPT vision audit is already running.',
            'last_run': _public_audit_run_payload(current_run),
        }

    clean_filenames = [
        os.path.basename(str(name))
        for name in (filenames or [])
        if str(name).lower().endswith('.pptx')
    ]
    queued_at = _now_label()
    _record_audit_run({
        'ok': True,
        'status': 'queued',
        'queued_at': queued_at,
        'filenames': clean_filenames,
        'max_files': max_files,
    })

    def _run():
        with _AUDIT_LOCK:
            started_at = _now_label()
            _record_audit_run({
                'ok': True,
                'status': 'running',
                'queued_at': queued_at,
                'started_at': started_at,
                'filenames': clean_filenames,
                'max_files': max_files,
            })
            try:
                summary = audit_recent_ppts(
                    reports_dir=reports_dir,
                    hours=hours,
                    max_files=max_files,
                    filenames=clean_filenames or None,
                )
                _record_audit_run({
                    'ok': True,
                    'status': 'completed',
                    'queued_at': queued_at,
                    'started_at': started_at,
                    'finished_at': _now_label(),
                    'filenames': clean_filenames,
                    'max_files': max_files,
                    'summary': summary,
                })
            except Exception as exc:
                _record_audit_run({
                    'ok': False,
                    'status': 'error',
                    'queued_at': queued_at,
                    'started_at': started_at,
                    'finished_at': _now_label(),
                    'filenames': clean_filenames,
                    'max_files': max_files,
                    'error': f'{type(exc).__name__}: {str(exc)[:200]}',
                })
                logger.error("[PPTVision] background audit failed: %s", exc, exc_info=True)

    thread = threading.Thread(target=_run, name='ppt-vision-audit', daemon=True)
    thread.start()
    return {
        'ok': True,
        'status': 'queued',
        'message': 'PPT vision audit queued.',
        'filenames': clean_filenames,
        'max_files': max_files,
    }


def push_ppt_audit_to_telegram(summary: Dict[str, Any]) -> bool:
    """有 issues 才推 Telegram（避免靜默報「無問題」洗版）"""
    if summary['total_issues'] <= 0:
        return False
    try:
        from services.telegram_templates import _send_telegram_raw
    except Exception:
        return False

    lines = [f"🔍 <b>PPT 視覺審核（{len(summary['audited_files'])} 份）</b>"]
    lines.append('━' * 18)
    for entry in summary['audited_files']:
        if entry['issues'] > 0:
            fname = os.path.basename(entry['path']) if hasattr(__import__('os'), 'path') else entry['path']
            import os as _os
            fname = _os.path.basename(entry['path'])
            lines.append(f"\n📊 <code>{fname}</code> ({entry['slides_checked']} slides, "
                          f"<b>{entry['issues']} issues</b>)")
            for slide_num, issues in entry['issues_by_slide'][:3]:  # 每檔最多列 3 張
                for iss in issues[:2]:  # 每張 slide 最多列 2 個 issue
                    lines.append(f"  Slide {slide_num}: {iss[:120]}")

    msg = '\n'.join(lines)
    try:
        _send_telegram_raw(msg)
        return True
    except Exception:
        return False


__all__ = [
    'PPTVisionService',
    'VisionResult',
    'ppt_vision_service',
    'is_ppt_vision_enabled',
    'get_ppt_vision_runtime_status',
    'get_ppt_vision_audit_status',
    'PPT_VISION_SYSTEM_PROMPT',
    'audit_recent_ppts',
    'start_ppt_vision_audit_background',
    'push_ppt_audit_to_telegram',
]
