#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""公開系統頁面與健康檢查路由。

此 blueprint 無 url_prefix，保留外部監控與既有前端使用的公開 URL。
"""

import os
import hmac
import mimetypes
import posixpath
import re
import zipfile
from datetime import datetime, timezone, timedelta
from urllib.parse import quote

from flask import Blueprint, Response, jsonify, render_template, request, send_from_directory, session, url_for
import requests
from sqlalchemy import text

from auth import login_required
from config import (
    BASE_DIR,
    DATABASE_TYPE,
    SYSTEM_VERSION,
    WEBCRUMBS_ASSET_UPSTREAM_URL,
    WEBCRUMBS_ENABLED,
)
from database.manager import DatabaseManager
from database.models import Product, PriceRecord
from services.external_tool_payload_service import (
    build_external_tool_payload,
    build_webcrumbs_seed_data,
)
from services.json_storage import load_categories
from services.logger_manager import SystemLogger
from utils.security import safe_join


system_public_bp = Blueprint('system_public', __name__)

sys_log = SystemLogger("SystemPublicRoutes").get_logger()
TAIPEI_TZ = timezone(timedelta(hours=8))
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = os.getenv('PUBLIC_URL', '服務啟動中...')
STATIC_DIR = os.path.join(BASE_DIR, 'web/static')
WEBCRUMBS_ASSET_ALLOWED_PREFIXES = ('loader/', 'plugins/', 'demo/')
WEBCRUMBS_COMPATIBLE_LOADER_PATH = 'loader/webcrumbs-compatible-loader.js'
WEBCRUMBS_FALLBACK_LOADER = """
(() => {
  const state = { status: 'fallback', reason: 'upstream_unavailable' };
  window.WebcrumbsRuntime = Object.assign(window.WebcrumbsRuntime || {}, state);
  if (!customElements.get('stock-platform-plugin')) {
    customElements.define('stock-platform-plugin', class extends HTMLElement {
      connectedCallback() {
        if (this.dataset.webcrumbsFallbackRendered === '1') return;
        this.dataset.webcrumbsFallbackRendered = '1';
        const uri = this.getAttribute('uri') || '';
        const box = document.createElement('div');
        box.setAttribute('role', 'status');
        box.style.cssText = 'border:1px solid rgba(42,37,32,.14);border-radius:8px;padding:12px;background:#fffaf0;color:#3b332b;font:600 13px/1.5 system-ui,sans-serif;';
        const title = document.createElement('strong');
        title.textContent = 'Webcrumbs runtime temporarily offline';
        const detail = document.createElement('div');
        detail.textContent = uri ? `Plugin waiting for runtime: ${uri}` : 'Plugin waiting for runtime.';
        box.append(title, detail);
        this.replaceChildren(box);
      }
    });
  }
  window.dispatchEvent(new CustomEvent('webcrumbs:runtime-fallback', { detail: state }));
})();
""".strip()

_PUBLIC_LOG_REPLACEMENTS = (
    ('OpenClawBot', 'AI 自動化服務'),
    ('OpenClaw', 'AI 架構檢查'),
    ('Hermes', 'AI 掃描服務'),
    ('NemoTron', 'AI 派工服務'),
    ('Ollama', 'AI 模型服務'),
    ('ollama', 'AI 模型服務'),
    ('fallback', '備援'),
    ('Fallback', '備援'),
    ('MCP', '工具服務'),
    ('RAG', '知識檢索'),
    ('autoheal_id_ed25519', '部署金鑰（已隱藏）'),
    ('id_ed25519', '部署金鑰（已隱藏）'),
    ('google_token.json', '雲端授權檔'),
)
_PUBLIC_LOG_RAW_ERROR_MARKERS = (
    'httpconnectionpool',
    'all 3 hosts failed',
    'multimodal data provided',
    'traceback',
    'secret',
    'api_key',
)


def _sanitize_public_log_line(line):
    text = str(line or '').rstrip('\n')
    if not text:
        return ''
    prefix = ''
    match = re.match(r'^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:,\d+)?)\s+(\w+)\s*(.*)$', text)
    if match:
        prefix = f"{match.group(1)} {match.group(2)} "
        body = match.group(3)
    else:
        body = text
    lowered = body.lower()
    if any(marker in lowered for marker in _PUBLIC_LOG_RAW_ERROR_MARKERS):
        body = '內部服務暫時異常，已保留完整診斷於伺服器端。'
    for raw, label in _PUBLIC_LOG_REPLACEMENTS:
        body = body.replace(raw, label)
    body = re.sub(r'\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b', '內部主機', body)
    body = re.sub(
        r'(?i)(?:/[\w.\-~]+)+/(?:[^/\s]*(?:token|secret|key|credential|id_ed25519)[^/\s]*)',
        '敏感設定路徑已隱藏',
        body,
    )
    if len(body) > 260:
        body = body[:260].rstrip() + '…'
    return prefix + body


def _sanitize_public_logs(lines):
    return '\n'.join(_sanitize_public_log_line(line) for line in lines if str(line or '').strip())


def _has_sensitive_webcrumbs_access():
    if session.get('logged_in'):
        return True
    internal_key = os.getenv('INTERNAL_API_KEY', '').strip()
    provided = request.headers.get('X-Internal-Key', '').strip()
    return bool(internal_key and provided and hmac.compare_digest(provided, internal_key))


@system_public_bp.route('/favicon.ico')
def favicon():
    """使用既有品牌圖示回應瀏覽器預設 favicon 探測，避免全站 404 噪音。"""
    return send_from_directory(
        os.path.join(STATIC_DIR, 'images'),
        'logo_circle.svg',
        mimetype='image/svg+xml',
        max_age=604800,
    )


@system_public_bp.route('/health')
def health_check():
    """健康檢查端點 - 供 Nginx 和 Docker healthcheck 使用"""
    try:
        return jsonify({
            'status': 'healthy',
            'database': DATABASE_TYPE,
            'version': SYSTEM_VERSION
        }), 200
    except Exception as e:
        return jsonify({
            'status': 'unhealthy',
            'error': str(e)
        }), 500


@system_public_bp.route('/metabase')
@system_public_bp.route('/metabase/')
@login_required
def metabase_status():
    """Internal status page for the BI entrypoint when the public proxy is not attached."""
    return render_template(
        'external_tool_status.html',
        active_page='metabase',
        system_version=SYSTEM_VERSION,
        tool=build_external_tool_payload('metabase'),
    )


@system_public_bp.route('/grist')
@system_public_bp.route('/grist/')
@login_required
def grist_status():
    """Internal status page for the data collaboration entrypoint."""
    return render_template(
        'external_tool_status.html',
        active_page='grist',
        system_version=SYSTEM_VERSION,
        tool=build_external_tool_payload('grist'),
    )


@system_public_bp.route('/webcrumbs')
@system_public_bp.route('/webcrumbs/')
@login_required
def webcrumbs_status():
    """Internal status page for the shared Webcrumbs microfrontend runtime."""
    return render_template(
        'external_tool_status.html',
        active_page='webcrumbs',
        system_version=SYSTEM_VERSION,
        tool=build_external_tool_payload(
            'webcrumbs',
            include_host_data=_has_sensitive_webcrumbs_access(),
        ),
    )


@system_public_bp.route('/api/webcrumbs/marketplace-host-data')
@login_required
def webcrumbs_marketplace_host_data_api():
    """Read-only host data contract for Webcrumbs shared-ui plugins."""
    if not _has_sensitive_webcrumbs_access():
        return jsonify({
            'success': False,
            'error': 'auth_required',
            'message': 'MOMO/PChome host data requires a logged-in session or X-Internal-Key.',
            'boundary': {
                'auth_required': True,
                'writes_database': False,
                'calls_llm': False,
                'fetches_external': False,
            },
        }), 401

    limit = request.args.get('limit', 5, type=int) or 5
    limit = max(1, min(limit, 8))
    payload = build_webcrumbs_seed_data(limit=limit)
    return jsonify({
        'success': True,
        'data': payload,
        'metadata': payload.get('metadata') or {},
        'boundary': {
            'auth_required': True,
            'writes_database': False,
            'calls_llm': False,
            'fetches_external': False,
            'allowed_match_contract': 'exact/total_price/price_alert_exact',
        },
    })


def _normalize_webcrumbs_asset_path(asset_path):
    candidate = (asset_path or '').strip().replace('\\', '/')
    normalized = posixpath.normpath(candidate).lstrip('/')
    if normalized in {'', '.'} or normalized != candidate.lstrip('/'):
        return ''
    if not normalized.startswith(WEBCRUMBS_ASSET_ALLOWED_PREFIXES):
        return ''
    return normalized


def _webcrumbs_fallback_loader_response(reason):
    response = Response(WEBCRUMBS_FALLBACK_LOADER, status=200, mimetype='application/javascript')
    response.headers['Cache-Control'] = 'no-store'
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['Referrer-Policy'] = 'no-referrer'
    response.headers['X-Webcrumbs-Fallback'] = reason
    return response


@system_public_bp.route('/webcrumbs-assets/<path:asset_path>')
def webcrumbs_asset_proxy(asset_path):
    """Serve allowlisted shared-ui assets through momo-pro's own origin."""
    normalized_path = _normalize_webcrumbs_asset_path(asset_path)
    if not normalized_path or not WEBCRUMBS_ENABLED:
        return Response('Webcrumbs asset not available', status=404, mimetype='text/plain')
    if not WEBCRUMBS_ASSET_UPSTREAM_URL:
        if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
            return _webcrumbs_fallback_loader_response('missing-upstream')
        return Response('Webcrumbs asset not available', status=404, mimetype='text/plain')

    upstream_url = f"{WEBCRUMBS_ASSET_UPSTREAM_URL}/{quote(normalized_path, safe='/@._-')}"
    try:
        upstream_response = requests.get(upstream_url, timeout=(2, 8))
    except requests.RequestException as exc:
        if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
            sys_log.info("[Webcrumbs] Loader upstream unavailable; serving local fallback")
            return _webcrumbs_fallback_loader_response('upstream-unavailable')
        sys_log.warning(f"[Webcrumbs] Asset proxy failed: {normalized_path} -> {exc}")
        return Response('Webcrumbs asset upstream unavailable', status=502, mimetype='text/plain')

    if upstream_response.status_code >= 400:
        if normalized_path == WEBCRUMBS_COMPATIBLE_LOADER_PATH:
            sys_log.info(
                "[Webcrumbs] Loader upstream returned %s; serving local fallback",
                upstream_response.status_code,
            )
            return _webcrumbs_fallback_loader_response(f'upstream-{upstream_response.status_code}')
        sys_log.warning(f"[Webcrumbs] Asset proxy returned {upstream_response.status_code}: {normalized_path}")
        return Response('Webcrumbs asset upstream returned error', status=upstream_response.status_code, mimetype='text/plain')

    content_type = upstream_response.headers.get('Content-Type')
    if not content_type:
        content_type = mimetypes.guess_type(normalized_path)[0] or 'application/octet-stream'
    response = Response(upstream_response.content, status=upstream_response.status_code)
    response.headers['Content-Type'] = content_type
    response.headers['Cache-Control'] = 'public, max-age=300'
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['Referrer-Policy'] = 'no-referrer'
    return response


@system_public_bp.route('/metrics')
def prometheus_metrics():
    """Prometheus 指標端點 - 供 Prometheus 抓取監控資料"""
    try:
        from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Gauge, CollectorRegistry
        from services.ai_automation_metrics import snapshot as ai_metrics_snapshot

        registry = CollectorRegistry()

        app_info = Gauge('momo_app_info', '應用程式資訊', ['version', 'database_type'], registry=registry)
        app_info.labels(version=SYSTEM_VERSION, database_type=DATABASE_TYPE).set(1)

        app_health = Gauge('momo_app_health', '應用程式健康狀態', registry=registry)
        db_status = Gauge('momo_database_up', '資料庫連線狀態', registry=registry)

        try:
            db = DatabaseManager()
            with db.engine.connect() as conn:
                conn.execute(text("SELECT 1"))
            db_status.set(1)
            app_health.set(1)
        except Exception:
            db_status.set(0)
            app_health.set(0)

        session = None
        try:
            db = DatabaseManager()
            session = db.get_session()
            _set_database_record_counts(registry, Gauge, session)
        except Exception as e:
            sys_log.warning(f"[Metrics] 無法取得資料庫統計: {e}")
        finally:
            if session is not None:
                session.close()

        try:
            _register_ai_automation_metrics(registry, Gauge, ai_metrics_snapshot())
        except Exception as e:
            sys_log.warning(f"[Metrics] 無法取得 AI 自動化指標: {e}")

        return Response(generate_latest(registry), mimetype=CONTENT_TYPE_LATEST)

    except ImportError:
        metrics_text = f"""# HELP momo_app_health 應用程式健康狀態
# TYPE momo_app_health gauge
momo_app_health 1
# HELP momo_app_info 應用程式資訊
# TYPE momo_app_info gauge
momo_app_info{{version="{SYSTEM_VERSION}",database_type="{DATABASE_TYPE}"}} 1
"""
        return Response(metrics_text, mimetype='text/plain; charset=utf-8')
    except Exception as e:
        sys_log.error(f"[Metrics] 指標生成錯誤: {e}")
        return Response(f"# Error: {e}\n", mimetype='text/plain; charset=utf-8'), 500


def _labels_to_dict(labels):
    return dict(labels)


def _set_database_record_counts(registry, gauge_cls, session):
    """Register DB counts without selecting drift-prone sales columns."""
    product_count = gauge_cls('momo_products_total', '商品總數', registry=registry)
    product_count.set(session.query(Product).count())

    price_record_count = gauge_cls('momo_price_records_total', '價格記錄總數', registry=registry)
    price_record_count.set(session.query(PriceRecord).count())

    # V-Fix: realtime_sales_monthly 線上欄位曾與 ORM 不同步，metrics 只需要總筆數。
    sales_count = gauge_cls('momo_sales_records_total', '業績資料總數', registry=registry)
    sales_total = session.execute(text("SELECT COUNT(*) FROM realtime_sales_monthly")).scalar() or 0
    sales_count.set(sales_total)


def _register_ai_automation_metrics(registry, gauge_cls, metrics_snapshot):
    """Export dependency-free AI metrics into a per-request Prometheus registry."""
    gauges = {}

    def get_gauge(name, help_text, label_names):
        if name not in gauges:
            gauges[name] = gauge_cls(name, help_text, label_names, registry=registry)
        return gauges[name]

    definitions = {
        "event_router_dispatch_total": (
            "momo_ai_event_router_dispatch_total",
            "EventRouter dispatch count",
            ["event_type", "outcome", "tier"],
        ),
        "event_router_safe_action_total": (
            "momo_ai_event_router_safe_action_total",
            "EventRouter L2 safe action count",
            ["action", "status"],
        ),
        "event_router_replay_total": (
            "momo_ai_event_router_replay_total",
            "EventRouter queued Telegram replay count",
            ["status"],
        ),
        "autoheal_action_total": (
            "momo_ai_autoheal_action_total",
            "AutoHeal action count",
            ["action", "error_type", "result"],
        ),
    }

    counter_samples = {
        ("event_router_dispatch_total", (
            ("event_type", "baseline"),
            ("outcome", "none"),
            ("tier", "baseline"),
        )): 0,
        ("event_router_safe_action_total", (
            ("action", "baseline"),
            ("status", "none"),
        )): 0,
        ("event_router_replay_total", (
            ("status", "none"),
        )): 0,
        ("autoheal_action_total", (
            ("action", "baseline"),
            ("error_type", "none"),
            ("result", "none"),
        )): 0,
    }
    counter_samples.update(metrics_snapshot.get("counters", {}))

    for (metric, labels), value in counter_samples.items():
        if metric not in definitions:
            continue
        name, help_text, label_names = definitions[metric]
        gauge = get_gauge(name, help_text, label_names)
        label_values = _labels_to_dict(labels)
        gauge.labels(**{name: label_values.get(name, "unknown") for name in label_names}).set(value)

    latency_defs = {
        "event_router_latency_ms": (
            "momo_ai_event_router_latency_ms",
            "EventRouter dispatch latency in milliseconds",
            ["event_type", "tier"],
        ),
        "autoheal_duration_ms": (
            "momo_ai_autoheal_duration_ms",
            "AutoHeal action duration in milliseconds",
            ["action", "error_type"],
        ),
    }

    latency_samples = {
        ("event_router_latency_ms", (
            ("event_type", "baseline"),
            ("tier", "baseline"),
        )): {"count": 0, "sum": 0, "max": 0},
        ("autoheal_duration_ms", (
            ("action", "baseline"),
            ("error_type", "none"),
        )): {"count": 0, "sum": 0, "max": 0},
    }
    latency_samples.update(metrics_snapshot.get("latency", {}))

    for (metric, labels), values in latency_samples.items():
        if metric not in latency_defs:
            continue
        name, help_text, label_names = latency_defs[metric]
        label_values = _labels_to_dict(labels)
        for suffix in ("count", "sum", "max"):
            gauge = get_gauge(f"{name}_{suffix}", f"{help_text} ({suffix})", label_names)
            gauge.labels(**{name: label_values.get(name, "unknown") for name in label_names}).set(values.get(suffix, 0))


@system_public_bp.route('/settings')
def settings():
    """分類設定頁面"""
    categories = load_categories()
    return render_template('settings.html',
                           categories=categories,
                           public_url=public_url,
                           system_version=SYSTEM_VERSION)


@system_public_bp.route('/system_settings')
def system_settings_page():
    """系統設定與匯入頁面"""
    return render_template(
        'system_settings.html',
        system_version=SYSTEM_VERSION,
        active_page='system_settings',
    )


@system_public_bp.route('/ai_automation_smoke')
@login_required
def ai_automation_smoke_page():
    """AI 自動化閉環 smoke dashboard."""
    return render_template(
        'ai_automation_smoke.html',
        system_version=SYSTEM_VERSION,
        active_page='ai_automation_smoke',
    )


@system_public_bp.route('/api/ai-automation/smoke')
@login_required
def ai_automation_smoke_api():
    """Read-only smoke status for the four-agent AI automation control plane."""
    from services.ai_automation_smoke_service import collect_ai_automation_smoke
    return jsonify(collect_ai_automation_smoke())


@system_public_bp.route('/api/ai-automation/smoke/history/export')
@login_required
def ai_automation_smoke_history_export():
    """Export compact smoke history JSONL."""
    from services.ai_automation_smoke_service import export_smoke_history_jsonl
    export = export_smoke_history_jsonl()
    response = Response(export["content"], mimetype='application/x-ndjson; charset=utf-8')
    response.headers["Content-Disposition"] = "attachment; filename=ai_automation_smoke_history.jsonl"
    response.headers["X-Smoke-History-Count"] = str(export["count"])
    return response


@system_public_bp.route('/api/ai-automation/smoke/history/clear', methods=['POST'])
@login_required
def ai_automation_smoke_history_clear():
    """Clear local compact smoke history JSONL."""
    from services.ai_automation_smoke_service import clear_smoke_history
    return jsonify(clear_smoke_history())


@system_public_bp.route('/api/ai-automation/smoke/daily-summary/send', methods=['POST'])
@login_required
def ai_automation_smoke_daily_summary_send():
    """Send compact smoke trend daily summary to Telegram."""
    from services.ai_automation_smoke_service import send_smoke_daily_summary
    result = send_smoke_daily_summary()
    status_code = 200 if result.get("status") == "sent" else 502
    return jsonify(result), status_code


@system_public_bp.route('/logs')
def show_logs():
    return render_template('logs.html', active_page='logs')


@system_public_bp.route('/api/logs')
def get_logs_api():
    if os.path.exists(LOG_FILE_PATH):
        try:
            with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f:
                return jsonify({"logs": _sanitize_public_logs(f.readlines()[-60:])})
        except Exception as e:
            sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}")
            return jsonify({"logs": "讀取日誌異常"})
    return jsonify({"logs": "等待系統啟動中..."})


@system_public_bp.route('/api/backup', methods=['POST'])
@login_required
def trigger_backup():
    """API: 觸發系統完整備份"""
    try:
        sys_log.info("[System] [Backup] 💾 開始執行系統完整備份...")
        backup_dir = os.path.join(BASE_DIR, 'backups')
        if not os.path.exists(backup_dir):
            os.makedirs(backup_dir)

        timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')
        zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip"
        zip_filepath = os.path.join(backup_dir, zip_filename)

        with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
            for root, dirs, files in os.walk(BASE_DIR):
                dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']]

                for file in files:
                    if file == zip_filename:
                        continue
                    if file.endswith('.pyc') or file.endswith('.DS_Store'):
                        continue

                    file_path = os.path.join(root, file)
                    arcname = os.path.relpath(file_path, BASE_DIR)
                    zipf.write(file_path, arcname)

        sys_log.info(f"[System] [Backup] ✅ 系統備份完成 | File: {zip_filename}")

        download_url = url_for('system_public.download_backup', filename=zip_filename)

        return jsonify({
            "status": "success",
            "message": f"備份成功！\n檔案已儲存為: {zip_filename}\n即將開始下載...",
            "download_url": download_url
        })
    except Exception as e:
        sys_log.error(f"[System] [Backup] ❌ 備份失敗 | Error: {e}")
        return jsonify({"status": "error", "message": str(e)}), 500


@system_public_bp.route('/api/backup/download/<path:filename>')
@login_required
def download_backup(filename):
    """API: 下載備份檔案（已加入路徑遍歷防護）"""
    try:
        backup_dir = os.path.join(BASE_DIR, 'backups')
        safe_path = safe_join(backup_dir, filename)

        if not safe_path.exists():
            sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}")
            return jsonify({'error': '檔案不存在'}), 404

        if not safe_path.is_file():
            sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}")
            return jsonify({'error': '非法路徑'}), 400

        return send_from_directory(backup_dir, safe_path.name, as_attachment=True)

    except ValueError as e:
        sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}")
        return jsonify({'error': '非法路徑'}), 400
    except Exception as e:
        sys_log.error(f"[System] 下載備份失敗 | Error: {e}")
        return jsonify({'error': '下載失敗'}), 500
