#!/usr/bin/env python3
"""Create/update D01/D02 public screen scenarios and the one-click suite.

D01 proves the page's date boundary, pagination and empty-result semantics.
D02 verifies camera/device/stream metadata only; it never downloads or uploads
video content, so the regression remains small and CPU-safe.
"""

from __future__ import annotations

import copy
import importlib.util
import json
from datetime import datetime
from pathlib import Path
from typing import Any


HERE = Path(__file__).resolve().parent
SHARED_PATH = HERE / "sync_school_s00_s06_scenarios.py"
SPEC = importlib.util.spec_from_file_location("school_sync", SHARED_PATH)
if SPEC is None or SPEC.loader is None:
    raise RuntimeError(f"cannot load shared Apifox helper: {SHARED_PATH}")
SHARED = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(SHARED)

Apifox = SHARED.Apifox
MYSQL_BASE_URL = SHARED.MYSQL_BASE_URL
DM_BASE_URL = SHARED.DM_BASE_URL
PROJECT_ID = SHARED.PROJECT_ID
make_request = SHARED.make_request
new_id = SHARED.new_id

FOLDER_NAME = "大屏端-双库兼容"
D00_NAME = "D00-数据大屏公共读取（双库真实数据）"
D01_NAME = "D01-大屏页面聚合与日期边界（双库）"
D02_NAME = "D02-明厨亮灶配置与小型测试流（双库）"
SUITE_NAME = "数据大屏-双库一键回归"
BACKUP_DIR = HERE.parent / "backups"


def test(name: str, statement: str) -> tuple[str, str]:
    return name, statement


D01_OPERATIONS: list[dict[str, Any]] = [
    {
        "name": "网关健康检查",
        "method": "GET",
        "path": "/health",
        "tests": [
            test("服务健康", "pm.expect(__json.data.ok).to.equal(true)"),
            test("网关服务标识正确", "pm.expect(__json.data.service).to.equal('gateway-service')"),
        ],
    },
    {
        "name": "AI预警按日汇总-页面周区间",
        "method": "GET",
        "path": "/school/ai/events/summary?schoolId=1&canteenId=1&dim=day&start=2026-09-20&end=2026-09-27",
        "tests": [
            test("汇总返回数组", "pm.expect(__json.data).to.be.an('array')"),
            test("汇总字段完整", "__json.data.forEach(function(x){ pm.expect(x).to.have.all.keys('date','type','count','status'); pm.expect(Number(x.count)).to.be.at.least(0); })"),
        ],
    },
    {
        "name": "留样日期边界-短日期",
        "method": "GET",
        "path": "/screen/analytics/sampling/records?schoolId=1&canteenId=1&start=2026-09-26&end=2026-09-26&page=1&pageSize=100000",
        "tests": [
            test("留样结果非空", "pm.expect(__json.data.items).to.be.an('array').that.is.not.empty"),
            test("保存短日期留样ID", "const ids=__json.data.items.map(function(x){return String(x.id)}).sort(); pm.variables.set('d01_sampling_ids',JSON.stringify(ids)); pm.variables.set('d01_sampling_total',String(__json.data.total))"),
        ],
    },
    {
        "name": "留样日期边界-北京时间偏移",
        "method": "GET",
        "path": "/screen/analytics/sampling/records?schoolId=1&canteenId=1&start=2026-09-26T00%3A00%3A00%2B08%3A00&end=2026-09-26T23%3A59%3A59%2B08%3A00&page=1&pageSize=100000",
        "tests": [
            test("偏移格式与短日期命中相同留样", "const ids=__json.data.items.map(function(x){return String(x.id)}).sort(); pm.expect(JSON.stringify(ids)).to.equal(pm.variables.get('d01_sampling_ids')); pm.expect(String(__json.data.total)).to.equal(pm.variables.get('d01_sampling_total'))"),
        ],
    },
    {
        "name": "农残日期边界-短日期",
        "method": "GET",
        "path": "/screen/analytics/pesticide/records?schoolId=1&canteenId=1&start=2026-09-26&end=2026-09-26&page=1&pageSize=100000&sort=at",
        "tests": [
            test("农残结果非空", "pm.expect(__json.data.items).to.be.an('array').that.is.not.empty"),
            test("保存短日期农残ID", "const ids=__json.data.items.map(function(x){return String(x.id)}).sort(); pm.variables.set('d01_pesticide_ids',JSON.stringify(ids)); pm.variables.set('d01_pesticide_total',String(__json.data.total))"),
        ],
    },
    {
        "name": "农残日期边界-北京时间偏移",
        "method": "GET",
        "path": "/screen/analytics/pesticide/records?schoolId=1&canteenId=1&start=2026-09-26T00%3A00%3A00%2B08%3A00&end=2026-09-26T23%3A59%3A59%2B08%3A00&page=1&pageSize=100000&sort=at",
        "tests": [
            test("偏移格式与短日期命中相同农残", "const ids=__json.data.items.map(function(x){return String(x.id)}).sort(); pm.expect(JSON.stringify(ids)).to.equal(pm.variables.get('d01_pesticide_ids')); pm.expect(String(__json.data.total)).to.equal(pm.variables.get('d01_pesticide_total'))"),
        ],
    },
    {
        "name": "晨检日期边界-短日期",
        "method": "GET",
        "path": "/screen/analytics/morning-checks?schoolId=1&canteenId=1&start=2026-09-26&end=2026-09-26&page=1&pageSize=100000",
        "tests": [
            test("晨检结构与计数一致", "pm.expect(__json.data.items).to.be.an('array'); pm.expect(Number(__json.data.total)).to.be.at.least(__json.data.items.length)"),
            test("保存短日期晨检ID", "const ids=__json.data.items.map(function(x){return String(x.id)}).sort(); pm.variables.set('d01_morning_ids',JSON.stringify(ids)); pm.variables.set('d01_morning_total',String(__json.data.total))"),
        ],
    },
    {
        "name": "晨检日期边界-北京时间偏移",
        "method": "GET",
        "path": "/screen/analytics/morning-checks?schoolId=1&canteenId=1&start=2026-09-26T00%3A00%3A00%2B08%3A00&end=2026-09-26T23%3A59%3A59%2B08%3A00&page=1&pageSize=100000",
        "tests": [
            test("偏移格式与短日期命中相同晨检", "const ids=__json.data.items.map(function(x){return String(x.id)}).sort(); pm.expect(JSON.stringify(ids)).to.equal(pm.variables.get('d01_morning_ids')); pm.expect(String(__json.data.total)).to.equal(pm.variables.get('d01_morning_total'))"),
        ],
    },
    {
        "name": "AI明细日期边界-短日期",
        "method": "GET",
        "path": "/screen/analytics/ai/events?schoolId=1&canteenId=1&start=2026-09-26&end=2026-09-26&page=1&pageSize=100000",
        "tests": [
            test("AI明细非空", "pm.expect(__json.data.items).to.be.an('array').that.is.not.empty"),
            test("保存短日期AI事件ID", "const ids=__json.data.items.map(function(x){return String(x.id)}).sort(); pm.variables.set('d01_ai_ids',JSON.stringify(ids)); pm.variables.set('d01_ai_total',String(__json.data.total))"),
        ],
    },
    {
        "name": "AI明细日期边界-北京时间偏移",
        "method": "GET",
        "path": "/screen/analytics/ai/events?schoolId=1&canteenId=1&start=2026-09-26T00%3A00%3A00%2B08%3A00&end=2026-09-26T23%3A59%3A59%2B08%3A00&page=1&pageSize=100000",
        "tests": [
            test("偏移格式与短日期命中相同AI事件", "const ids=__json.data.items.map(function(x){return String(x.id)}).sort(); pm.expect(JSON.stringify(ids)).to.equal(pm.variables.get('d01_ai_ids')); pm.expect(String(__json.data.total)).to.equal(pm.variables.get('d01_ai_total'))"),
        ],
    },
    {
        "name": "AI类型占比日期边界-短日期",
        "method": "GET",
        "path": "/screen/analytics/ai/pie?schoolId=1&canteenId=1&start=2026-09-26&end=2026-09-26",
        "tests": [
            test("AI类型占比结构完整", "pm.expect(Number(__json.data.total)).to.be.at.least(0); pm.expect(__json.data.items).to.be.an('array')"),
            test("保存短日期AI占比签名", "const rows=__json.data.items.map(function(x){return [String(x.code),String(x.name),Number(x.count)]}).sort(); pm.variables.set('d01_ai_pie_signature',JSON.stringify({total:Number(__json.data.total),items:rows}))"),
        ],
    },
    {
        "name": "AI类型占比日期边界-北京时间偏移",
        "method": "GET",
        "path": "/screen/analytics/ai/pie?schoolId=1&canteenId=1&start=2026-09-26T00%3A00%3A00%2B08%3A00&end=2026-09-26T23%3A59%3A59%2B08%3A00",
        "tests": [
            test("偏移格式与短日期AI占比一致", "const rows=__json.data.items.map(function(x){return [String(x.code),String(x.name),Number(x.count)]}).sort(); pm.expect(JSON.stringify({total:Number(__json.data.total),items:rows})).to.equal(pm.variables.get('d01_ai_pie_signature'))"),
        ],
    },
    {
        "name": "人员健康日期边界-短日期",
        "method": "GET",
        "path": "/screen/analytics/personnel-health?schoolId=1&canteenId=1&start=2026-09-26&end=2026-09-26",
        "tests": [
            test("人员健康结构完整", "['expectedTotal','checkedUnique','abnormalTotal'].forEach(function(k){pm.expect(Number(__json.data[k])).to.be.at.least(0)})"),
            test("保存短日期人员健康签名", "pm.variables.set('d01_health_signature',JSON.stringify({schoolId:Number(__json.data.schoolId),canteenId:String(__json.data.canteenId),window:__json.data.window,expectedTotal:Number(__json.data.expectedTotal),checkedUnique:Number(__json.data.checkedUnique),abnormalTotal:Number(__json.data.abnormalTotal)}))"),
        ],
    },
    {
        "name": "人员健康日期边界-北京时间偏移",
        "method": "GET",
        "path": "/screen/analytics/personnel-health?schoolId=1&canteenId=1&start=2026-09-26T00%3A00%3A00%2B08%3A00&end=2026-09-26T23%3A59%3A59%2B08%3A00",
        "tests": [
            test("偏移格式与短日期人员健康一致", "const sig=JSON.stringify({schoolId:Number(__json.data.schoolId),canteenId:String(__json.data.canteenId),window:__json.data.window,expectedTotal:Number(__json.data.expectedTotal),checkedUnique:Number(__json.data.checkedUnique),abnormalTotal:Number(__json.data.abnormalTotal)}); pm.expect(sig).to.equal(pm.variables.get('d01_health_signature'))"),
        ],
    },
    {
        "name": "AI明细分页第一页",
        "method": "GET",
        "path": "/screen/analytics/ai/events?schoolId=1&canteenId=1&start=2026-09-26&end=2026-09-27&page=1&pageSize=2",
        "tests": [
            test("第一页最多2条", "pm.expect(__json.data.items.length).to.be.at.most(2)"),
            test("保存第一页分页证据", "pm.variables.set('d01_ai_page1_ids',JSON.stringify(__json.data.items.map(function(x){return String(x.id)}))); pm.variables.set('d01_ai_page_total',String(__json.data.total))"),
        ],
    },
    {
        "name": "AI明细分页第二页",
        "method": "GET",
        "path": "/screen/analytics/ai/events?schoolId=1&canteenId=1&start=2026-09-26&end=2026-09-27&page=2&pageSize=2",
        "tests": [
            test("两页total一致", "pm.expect(String(__json.data.total)).to.equal(pm.variables.get('d01_ai_page_total'))"),
            test("两页数据不重叠", "const first=JSON.parse(pm.variables.get('d01_ai_page1_ids')||'[]'); const second=__json.data.items.map(function(x){return String(x.id)}); pm.expect(second.some(function(id){return first.indexOf(id)>=0})).to.equal(false)"),
        ],
    },
]


for resource, path in [
    ("留样", "/screen/analytics/sampling/records"),
    ("农残", "/screen/analytics/pesticide/records"),
    ("晨检", "/screen/analytics/morning-checks"),
    ("AI预警", "/screen/analytics/ai/events"),
]:
    D01_OPERATIONS.append(
        {
            "name": f"{resource}未来空日期结构",
            "method": "GET",
            "path": f"{path}?schoolId=1&canteenId=1&start=2030-01-01&end=2030-01-02&page=1&pageSize=20",
            "tests": [
                test(f"{resource}空日期items为空", "pm.expect(__json.data.items).to.be.an('array').that.is.empty"),
                test(f"{resource}空日期total为0", "pm.expect(Number(__json.data.total)).to.equal(0)"),
            ],
        }
    )


D01_OPERATIONS.extend(
    [
        {
            "name": "人员健康汇总-公开大屏",
            "method": "GET",
            "path": "/screen/analytics/personnel-health?schoolId=1&canteenId=1&start=2026-09-26&end=2026-09-27",
            "tests": [
                test("人员健康学校食堂归属正确", "pm.expect(Number(__json.data.schoolId)).to.equal(1); pm.expect(String(__json.data.canteenId)).to.equal('1')"),
                test("人员健康统计非负", "['expectedTotal','checkedUnique','abnormalTotal'].forEach(function(k){pm.expect(Number(__json.data[k])).to.be.at.least(0)})"),
            ],
        },
        {
            "name": "首页人员健康概览",
            "method": "GET",
            "path": "/school/analytics/health?schoolId=1&canteenId=1",
            "tests": [
                test("健康概览结构完整", "pm.expect(Number(__json.data.total)).to.be.at.least(0); pm.expect(Number(__json.data.abnormal)).to.be.at.least(0); pm.expect(__json.data.items).to.be.an('array')"),
            ],
        },
        {
            "name": "首页人员与晨检聚合",
            "method": "GET",
            "path": "/school/analytics/personnel-health?schoolId=1&canteenId=1",
            "tests": [
                test("人员晨检聚合结构完整", "pm.expect(Number(__json.data.total)).to.be.at.least(0); pm.expect(Number(__json.data.abnormal)).to.be.at.least(0); pm.expect(__json.data.items).to.be.an('array')"),
            ],
        },
    ]
)


D02_OPERATIONS: list[dict[str, Any]] = [
    {
        "name": "第一食堂大屏摄像头",
        "method": "GET",
        "path": "/screen/analytics/cameras?schoolId=1&canteenId=1",
        "tests": [
            test("第一食堂摄像头非空", "pm.expect(__json.data).to.be.an('array').that.is.not.empty"),
            test("摄像头中文名称与通道完整", "pm.expect(__json.data.some(function(x){return x.name==='后厨监控通道'&&x.channelId==='CH-DEMO-001'&&Number(x.canteenId)===1})).to.equal(true)"),
            test("只核验流地址元数据不传输视频", "__json.data.forEach(function(x){ if(x.hlsUrl!==undefined) pm.expect(String(x.hlsUrl)).to.match(/^https?:\/\//); })"),
        ],
    },
    {
        "name": "学校全部大屏摄像头",
        "method": "GET",
        "path": "/screen/analytics/cameras?schoolId=1",
        "tests": [
            test("学校摄像头非空", "pm.expect(__json.data).to.be.an('array').that.is.not.empty"),
            test("学校摄像头均属于学校1", "__json.data.forEach(function(x){pm.expect(String(x.schoolId)).to.equal('1')})"),
        ],
    },
    {
        "name": "不存在食堂摄像头为空",
        "method": "GET",
        "path": "/screen/analytics/cameras?schoolId=1&canteenId=999999",
        "tests": [test("不存在食堂返回空数组", "pm.expect(__json.data).to.be.an('array').that.is.empty")],
    },
    {
        "name": "大屏设备列表",
        "method": "GET",
        "path": "/screen/analytics/devices?schoolId=1",
        "tests": [test("设备列表结构稳定", "pm.expect(__json.data).to.be.an('array')")],
    },
    {
        "name": "大屏设备类型字典",
        "method": "GET",
        "path": "/screen/analytics/devices/types",
        "tests": [test("设备类型包含核心类型", "['CAMERA','SENSOR','SMOKE'].forEach(function(x){pm.expect(__json.data).to.include(x)})")],
    },
    {
        "name": "大屏设备状态字典",
        "method": "GET",
        "path": "/screen/analytics/devices/statuses",
        "tests": [test("设备状态包含在线离线故障", "['ONLINE','OFFLINE','FAULT'].forEach(function(x){pm.expect(__json.data).to.include(x)})")],
    },
    {
        "name": "监管明厨亮灶摄像头元数据",
        "method": "GET",
        "path": "/bright/reg/cameras?schoolId=1",
        "tests": [
            test("监管摄像头列表结构稳定", "pm.expect(__json.data).to.be.an('array')"),
            test("监管摄像头通道标识存在", "__json.data.forEach(function(x){pm.expect(String(x.channelId||'').length).to.be.greaterThan(0)})"),
        ],
    },
    {
        "name": "本地小型测试流地址生成",
        "method": "GET",
        "path": "/bright/start/1/CH-DEMO-001",
        "tests": [
            test("HLS测试流地址为本地元数据", "pm.expect(__json.data.hlsUrl).to.equal('http://localhost:18080/live/1-CH-DEMO-001.m3u8')"),
            test("FLV测试流地址为本地元数据", "pm.expect(__json.data.flvUrl).to.equal('http://localhost:18080/live/1-CH-DEMO-001.flv')"),
        ],
    },
    {
        "name": "小型测试抓拍地址元数据",
        "method": "GET",
        "path": "/iot/streams/snapshot?cameraId=CH-DEMO-001",
        "tests": [
            test("抓拍通道标识正确", "pm.expect(__json.data.cameraId).to.equal('CH-DEMO-001')"),
            test("抓拍仅返回小图地址不上传视频", "pm.expect(__json.data.url).to.match(/^https:\/\/dummyimage\.com\//)"),
        ],
    },
    {
        "name": "物联网摄像头列表结构",
        "method": "GET",
        "path": "/iot/cameras?company=1",
        "tests": [test("物联网摄像头列表为数组", "pm.expect(__json.data).to.be.an('array')")],
    },
]


def folder_id(api: Any) -> int:
    tree = api.call(f"/api/v1/projects/{PROJECT_ID}/test-scenario/tree-list")["data"]
    folder = next((x for x in tree.get("testScenarioFolders", []) if x.get("name") == FOLDER_NAME), None)
    if folder is None:
        raise RuntimeError(f"Apifox 缺少场景目录：{FOLDER_NAME}")
    return int(folder["id"])


def backup(api: Any) -> Path:
    BACKUP_DIR.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    target = BACKUP_DIR / f"screen-d01-d02-before-sync_{stamp}.json"
    payload = {
        "generatedAt": datetime.now().astimezone().isoformat(),
        "projectId": PROJECT_ID,
        "scenarios": api.scenarios(),
        "testSuites": api.call(f"/api/v1/projects/{PROJECT_ID}/api-test/test-suites")["data"],
    }
    target.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    return target


def save_scenario(api: Any, source: dict[str, Any], name: str, ordering: int, description: str, operations: list[dict[str, Any]], screen_folder: int) -> int:
    by_name = {x["name"]: x for x in api.scenarios()}
    current = by_name.get(name)
    scenario_id = int(current["id"]) if current else api.duplicate(int(source["id"]))
    scenario = api.scenario(scenario_id)
    source_data = api.scenario(int(source["id"]))
    loop_template = source_data["steps"][0]
    request_template = loop_template["children"][0]
    scenario.update({"name": name, "description": description, "folderId": screen_folder, "ordering": ordering})
    loop = copy.deepcopy(loop_template)
    loop["id"] = new_id()
    loop["number"] = 1
    loop.setdefault("parameters", {})["array"] = json.dumps([MYSQL_BASE_URL, DM_BASE_URL], ensure_ascii=False, separators=(",", ":"))
    loop["parameters"]["disableOnError"] = False
    loop["children"] = []
    for number, operation in enumerate(operations, start=1):
        step = make_request(request_template, no_auth=True, **operation)
        step["number"] = number
        step["customHttpRequest"]["preProcessors"] = [
            {
                "type": "customScript",
                "data": "pm.variables.set('base_url', pm.variables.get('$.1.element'));",
                "defaultEnable": False,
                "enable": True,
                "executionTiming": "prerequest",
            },
            SHARED.placeholder(),
        ]
        loop["children"].append(step)
    scenario["steps"] = [loop]
    api.save(scenario)
    saved = api.scenario(scenario_id)
    requests = SHARED.flatten_requests(saved["steps"])
    if len(requests) != len(operations):
        raise RuntimeError(f"{name}: expected {len(operations)} requests, got {len(requests)}")
    if not all(SHARED.request_has_auditable_evidence(r) for r in requests):
        raise RuntimeError(f"{name}: request lacks auditable response evidence")
    if any(r.get("auth") for r in requests):
        raise RuntimeError(f"{name}: public request unexpectedly contains auth")
    return scenario_id


def update_suite(api: Any, scenario_ids: list[int]) -> int:
    suites = api.call(f"/api/v1/projects/{PROJECT_ID}/api-test/test-suites")["data"]
    current = next((x for x in suites if x.get("name") == SUITE_NAME), None)
    if not current:
        raise RuntimeError(f"Apifox 缺少测试套件：{SUITE_NAME}")
    suite = api.call(f"/api/v1/projects/{PROJECT_ID}/api-test/test-suites/{int(current['id'])}")["data"]
    names = [D00_NAME, D01_NAME, D02_NAME]
    suite["description"] = (
        "数据大屏无登录公共读取双库一键回归：D00真实数据基线、D01日期/分页/空数据、"
        "D02摄像头和小型测试流元数据。仅访问 MySQL foodsafe_test 与达梦 WWWWEB；"
        "不上传、不下载、不解码大视频。每步均包含字段断言、可见返回摘要和脱敏 APIFOX_EVIDENCE。"
    )
    suite["items"] = [
        {
            "id": new_id(),
            "name": name,
            "type": "STATIC_TEST_SCENARIO",
            "testScenarios": [{"id": scenario_id, "options": {}}],
            "options": {},
        }
        for name, scenario_id in zip(names, scenario_ids)
    ]
    suite.setdefault("options", {})["runMode"] = "serial"
    api.call(f"/api/v1/projects/{PROJECT_ID}/api-test/test-suites/{int(suite['id'])}", "PUT", suite)
    saved = api.call(f"/api/v1/projects/{PROJECT_ID}/api-test/test-suites/{int(suite['id'])}")["data"]
    linked = [int(x["testScenarios"][0]["id"]) for x in saved.get("items", [])]
    if linked != scenario_ids:
        raise RuntimeError(f"screen suite references do not match: {linked}")
    return int(saved["id"])


def main() -> None:
    api = Apifox()
    backup_path = backup(api)
    by_name = {x["name"]: x for x in api.scenarios()}
    d00 = by_name.get(D00_NAME)
    if d00 is None:
        raise RuntimeError(f"Apifox 缺少基线场景：{D00_NAME}")
    screen_folder = folder_id(api)
    d01_id = save_scenario(
        api,
        d00,
        D01_NAME,
        210,
        "大屏页面聚合和日期边界：比较短日期与北京时间+08:00、分页不重叠、未来空数据结构；无登录双库执行。",
        D01_OPERATIONS,
        screen_folder,
    )
    d02_id = save_scenario(
        api,
        d00,
        D02_NAME,
        220,
        "明厨亮灶摄像头、设备字典和本地小型测试流元数据；不上传、不下载、不解码视频。",
        D02_OPERATIONS,
        screen_folder,
    )
    suite_id = update_suite(api, [int(d00["id"]), d01_id, d02_id])
    print(f"backup={backup_path}")
    print(f"scenario={D01_NAME}, id={d01_id}, operations={len(D01_OPERATIONS)}, dualExecutions={len(D01_OPERATIONS)*2}")
    print(f"scenario={D02_NAME}, id={d02_id}, operations={len(D02_OPERATIONS)}, dualExecutions={len(D02_OPERATIONS)*2}")
    print(f"suite={SUITE_NAME}, id={suite_id}, scenarios=3, dualExecutions={(15+len(D01_OPERATIONS)+len(D02_OPERATIONS))*2}")


if __name__ == "__main__":
    main()
