"""Generate the proposed incremental contract. Does not import or call business APIs."""
import json
from pathlib import Path

OUT = Path(__file__).resolve().parent
def ref(name): return {'$ref': '#/components/schemas/' + name}
def obj(props, required=()):
    result = {'type': 'object', 'properties': props, 'additionalProperties': False}
    if required:
        result['required'] = list(required)
    return result
def arr(items): return {'type': 'array', 'items': items}
def text(description='', **extra): return {'type': 'string', 'description': description, **extra}
def enum(*values): return {'type': 'string', 'enum': list(values)}
number = {'type': 'number', 'minimum': 0, 'nullable': True, 'description': '有限非负数；未知为null，不可用0代替'}
identifier = text('稳定UUID；须在当前租户和授权数据范围内', minLength=1, maxLength=64)
revision = {'type': 'integer', 'minimum': 0}
date = {'type': 'string', 'format': 'date'}
stamp = {'type': 'string', 'format': 'date-time', 'description': 'RFC3339且必须带时区；用餐日按业务时区Asia/Shanghai解释，上传时间不可编辑'}
reason = text('人工操作理由；trim后必填', minLength=1, maxLength=500)
meal = {'type': 'integer', 'nullable': True, 'enum': [1, 2, 3, 4, None], 'description': '1早餐及早加餐、2午餐及午加餐、3晚餐及晚加餐、4夜宵；未知null，禁止4归晚餐'}
kind = enum('regular', 'extra', 'snack', 'night_snack', 'unassigned')
nutrients = obj({k: dict(number, description=unit+'；未知null') for k, unit in [('energy','kcal'),('protein','g'),('zinc','mg'),('magnesium','mg')]}, ['energy','protein','zinc','magnesium'])
schemas = {
 'Nutrients': nutrients,
 'Ingredient': obj({'food_id':identifier, 'composition_id':identifier, 'name':text(), 'weight_g':number, 'composition_version':text(), 'basis':text('生/熟、可食部与基准单位'), 'match_status':enum('matched','partial','missing'), 'nutrients':ref('Nutrients')}, ['name','weight_g','match_status','nutrients']),
 'Food': obj({'item_id':identifier, 'dish_id':dict(identifier,nullable=True), 'name':text(minLength=1,maxLength=100), 'weight_g':number, 'portion_estimated':{'type':'boolean'}, 'nutrients':ref('Nutrients'), 'nutrient_status':obj({k:enum('matched','partial','missing','manual') for k in ['energy','protein','zinc','magnesium']}), 'ingredients':arr(ref('Ingredient'))}, ['item_id','name','weight_g','portion_estimated','nutrients','nutrient_status','ingredients']),
 'FoodEdit': obj({'item_id':dict(identifier,nullable=True), 'name':text(minLength=1,maxLength=100), 'weight_g':number, 'nutrients':ref('Nutrients')}, ['name','weight_g','nutrients']),
 'Photo': obj({'photo_id':identifier, 'seq':{'type':'integer','minimum':1}, 'view_path':text('需当前会话授权的原图路径，不是公共永久URL'), 'status':enum('available','missing','pending'), 'file_ext':text()}, ['photo_id','seq','view_path','status','file_ext']),
 'Policy': obj({'configured':enum('inherit','library','web'), 'effective':enum('library','web','policy_conflict'), 'source_type':enum('person','department','default','conflict'), 'source_uuid':dict(identifier,nullable=True), 'revision':revision}, ['configured','effective','source_type','source_uuid','revision']),
 'Record': obj({'record_id':identifier, 'staff_uuid':identifier, 'revision':revision, 'uploaded_at':stamp, 'meal_date':date, 'meal_times':meal, 'meal_kind':kind, 'meal_time':dict(stamp,nullable=True), 'photos':arr(ref('Photo')), 'foods':arr(ref('Food')), 'current_origin':enum('ai','manual','legacy','none'), 'status':enum('pending','queued','running','completed','partial','not_in_library','failed','policy_conflict'), 'suspected_error':{'type':'boolean'}, 'policy':ref('Policy'), 'candidate_attempt_id':dict(identifier,nullable=True), 'totals':ref('Nutrients')}, ['record_id','staff_uuid','revision','uploaded_at','meal_date','meal_times','meal_kind','meal_time','photos','foods','current_origin','status','suspected_error','policy','candidate_attempt_id','totals']),
 'Attempt': obj({'attempt_id':identifier, 'record_id':identifier, 'status':enum('queued','running','completed','partial','failed','not_in_library','policy_conflict'), 'candidate':{'type':'boolean'}, 'started_revision':revision, 'policy':ref('Policy'), 'error_code':text(), 'foods':arr(ref('Food'))}, ['attempt_id','record_id','status','candidate','started_revision','policy','error_code','foods']),
 'Export': obj({'export_id':identifier, 'status':enum('queued','running','complete','partial','failed','expired'), 'progress':{'type':'integer','minimum':0,'maximum':100}, 'person_count':{'type':'integer','minimum':0}, 'record_count':{'type':'integer','minimum':0}, 'image_count':{'type':'integer','minimum':0}, 'missing_images':{'type':'integer','minimum':0}, 'download_path':dict(text(),nullable=True), 'expires_at':dict(stamp,nullable=True), 'error_code':text()}, ['export_id','status','progress','person_count','record_count','image_count','missing_images','download_path','expires_at','error_code']),
 'Audit': obj({'audit_id':identifier,'operation':text(),'operator_label':text(),'operated_at':stamp,'reason':text(),'before_revision':revision,'after_revision':revision,'before':{'type':'object'},'after':{'type':'object'}}, ['audit_id','operation','operator_label','operated_at','reason','before_revision','after_revision','before','after']),
 'Error': obj({'code':{'type':'integer','enum':[1]},'message':text(),'error_code':enum('VALIDATION_FAILED','AUTH_REQUIRED','FORBIDDEN','NOT_FOUND','REVISION_CONFLICT','IDEMPOTENCY_CONFLICT','POLICY_CONFLICT','RECOGNIZER_UNAVAILABLE','EXPORT_NOT_READY','EXPORT_EXPIRED','RATE_LIMITED'),'trace_id':text(),'data':{'type':'object'}}, ['code','message','error_code','trace_id','data']),
}
nutrient_keys = ['energy', 'protein', 'zinc', 'magnesium']
schemas['NutrientStatus'] = obj({k:enum('matched','partial','missing','manual') for k in nutrient_keys}, nutrient_keys)
schemas['Food']['properties']['nutrient_status'] = ref('NutrientStatus')
schemas['Record']['properties']['totals_status'] = ref('NutrientStatus')
schemas['Record']['required'].append('totals_status')
ingredient = schemas['Ingredient']
schemas['Ingredient'] = {'oneOf': [
    dict(ingredient, properties=dict(ingredient['properties'], match_status=enum('matched')),
         required=list(ingredient['properties'])),
    dict(ingredient, properties=dict(ingredient['properties'], match_status=enum('partial','missing')))
]}
schemas['Ingredient']['oneOf'][0]['properties']['composition_version'] = text('可追溯成分版本', minLength=1)
schemas['Ingredient']['oneOf'][0]['properties']['basis'] = text('生/熟、可食部及基准单位，缺失时不能判matched', minLength=1)
for variant in schemas['Ingredient']['oneOf']:
    variant['properties'] = dict(variant['properties'], basis_amount_g={'type':'number','exclusiveMinimum':True,'minimum':0},
        food_state=enum('raw','cooked'), edible_ratio={'type':'number','minimum':0,'maximum':1},
        cooking_ratio={'type':'number','minimum':0,'exclusiveMinimum':True})
schemas['Ingredient']['oneOf'][0]['required'] += ['basis_amount_g','food_state','edible_ratio','cooking_ratio']
schemas['Record']['properties']['current_result_status'] = enum('none','valid','partial','needs_review')
schemas['Record']['properties']['current_origin'] = enum('ai','user','manual','legacy','none')
schemas['Record']['properties']['status']['description'] = '最新识别进度；不能覆盖或否定仍有效的人工当前版'
schemas['Record']['required'].append('current_result_status')
schemas['Audit']['properties']['operator_uuid'] = identifier
schemas['Audit']['required'].append('operator_uuid')
schemas['Export']['properties'].update({
    'expected_images':{'type':'integer','minimum':0},
    'manifest_path':dict(text('受同任务权限和有效期控制'),nullable=True),
    'failure_summary':arr(obj({'photo_id':dict(identifier,nullable=True),'dataset':dict(text(),nullable=True),'reason':text()},['photo_id','dataset','reason'])),
    'department_uuid':identifier,'date_from':date,'date_to':date,
    'snapshot_at':dict(stamp,nullable=True)
})
schemas['Export']['required'] += ['expected_images','manifest_path','failure_summary','department_uuid','date_from','date_to','snapshot_at']
schemas['Export']['properties']['image_count']['description'] = '实际打包图片数；expected_images为预期数，两者不能混用'

def sample(schema):
    if '$ref' in schema:
        return sample(schemas[schema['$ref'].split('/')[-1]])
    if 'oneOf' in schema:
        return sample(schema['oneOf'][0])
    if schema.get('nullable'):
        return None
    if 'enum' in schema:
        if schema['enum'] == ['matched','partial','missing','manual']:
            return 'missing'
        return schema['enum'][0]
    if schema.get('type') == 'object':
        return {k:sample(schema['properties'][k]) for k in schema.get('required', [])}
    if schema.get('type') == 'array':
        return []
    if schema.get('type') == 'boolean':
        return False
    if schema.get('type') in ('integer','number'):
        return schema.get('minimum',0)
    return {'date':'2026-09-11','date-time':'2026-09-11T07:42:00+08:00'}.get(schema.get('format'),'example-value')
paths = {}
def add(action, summary, props, required, output, flow, method='post', binary=False):
    path = '/p/DietPhoto/' + action
    request = obj(props, required)
    response = obj({'code':{'type':'integer','enum':[0]},'message':text(),'trace_id':text(),'data':output}, ['code','message','trace_id','data'])
    description = '\n\n'.join([
        '## 接口概述\n'+summary+'。本接口为VWCG-1354技术审核草案，未实现、未导入Apifox。',
        '## 接口信息\n'+method.upper()+' '+path+'；store拥有，沿用PC后台会话。',
        '## 请求头（Header）\n沿用token请求头及现有PC会话；服务端验证原菜单、动作、人员/部门与租户范围，不依赖前端隐藏。',
        '## 请求参数\n'+('\n'.join('- '+k+('（必填）' if k in required else '（可选）')+'：'+v.get('description',v.get('format',v.get('type','见Schema'))) for k,v in props.items()) or '无。'),
        '## 响应格式\n成功为code=0的JSON，任务状态独立在data.status；失败code=1并有error_code。'+('二进制成功响应见application/zip或图片内容。' if binary else '')+'\n'+flow,
        '## 状态码或业务状态说明\n兼容原PC业务码；HTTP401/403/409/422/429/503均按失败处理，不显示成功。版本冲突刷新数据后人工重新提交，不自动覆盖；识别或导出失败保留原图与当前人工结果。',
        '## 响应字段说明\n结构化Schema为完整字段定义。null表示未知，不代表0。上传时间/原图/可信身份只读；列表和统计使用同一当前revision。',
        '## trace_id排查\n服务端生成trace_id贯穿请求/record/attempt/export，日志仅记录必要标识，不写token、原图base64或人员敏感明细。',
    ])
    success_content = {'application/json': {'schema':{'oneOf':[response,ref('Error')]}, 'examples':{'success':{'value':sample(response)},'businessFailure':{'value':{'code':1,'message':'版本已更新，请刷新','error_code':'REVISION_CONFLICT','trace_id':'trace-example','data':{}}}}}}
    if binary:
        success_content = {'application/zip' if action=='exportDownload' else 'application/octet-stream': {'schema':{'type':'string','format':'binary'}}}
        success_content['application/json'] = {'schema':ref('Error'),'examples':{'legacyFailure':{'value':{'code':1,'message':'无权下载','error_code':'FORBIDDEN','trace_id':'trace-example','data':{}}}}}
    error_example={'code':1,'message':'无权访问该记录','error_code':'FORBIDDEN','trace_id':'trace-example','data':{}}
    op={'summary':summary,'operationId':'dietPhoto'+action[0].upper()+action[1:], 'description':description,
        'tags':['膳食监控/饮食照片'],'x-apifox-folder':'膳食监控/饮食照片','security':[{'pcToken':[]}],
        'responses':{'200':{'description':'成功；异步动作只表示任务已接受，须读状态；旧风格业务错误需检查code','content':success_content}}}
    for status,label in [('401','未登录'),('403','越权'),('404','记录或原图不存在'),('409','版本/幂等冲突'),('422','参数校验失败'),('429','请求过多'),('503','服务不可用')]:
        ex=dict(error_example,error_code={'401':'AUTH_REQUIRED','403':'FORBIDDEN','404':'NOT_FOUND','409':'REVISION_CONFLICT','422':'VALIDATION_FAILED','429':'RATE_LIMITED','503':'RECOGNIZER_UNAVAILABLE'}[status],message=label)
        op['responses'][status]={'description':label,'content':{'application/json':{'schema':ref('Error'),'examples':{'failure':{'value':ex}}}}}
    if method=='get':
        op['parameters']=[{'name':k,'in':'query','required':k in required,'schema':v} for k,v in props.items()]
    else:
        op['requestBody']={'required':True,'content':{'application/json':{'schema':request,'examples':{'request':{'value':sample(request)}}}}}
    paths[path]={method:op}

add('records','查询人员饮食照片及默认展开明细',{'staff_uuid':identifier,'date_from':date,'date_to':date,'meal_times':meal,'meal_kind':kind,'filter':enum('all','pending','suspected','manual'),'page':{'type':'integer','minimum':1,'default':1},'page_size':{'type':'integer','minimum':1,'maximum':100,'default':20}},['staff_uuid','date_from','date_to'],obj({'records':arr(ref('Record')),'total':{'type':'integer'},'totals':ref('Nutrients')},['records','total','totals']),'按用餐日期闭区间查询，同餐多次及多图逐条保留；合计覆盖全筛选集而非仅本页。旧FoodRecord接口不改响应结构。')
add('save','人工修改识别内容与餐次',{'record_id':identifier,'expected_revision':revision,'reason':reason,'meal_date':date,'meal_times':meal,'meal_kind':kind,'meal_time':dict(stamp,nullable=True),'foods':arr(ref('FoodEdit'))},['record_id','expected_revision','reason','meal_date','meal_times','meal_kind','meal_time','foods'],ref('Record'),'事务内验证revision，原子写当前结果、人工审计和投影版本。原图、上传时间、身份和原AI不可修改。空食物列表代表人工清空，保留照片和审计，非删除事件。')
add('mark','标记或取消疑似误识别',{'record_id':identifier,'expected_revision':revision,'suspected_error':{'type':'boolean'},'reason':reason},['record_id','expected_revision','suspected_error','reason'],ref('Record'),'标记不修改AI原结果，改变revision并审计；与识别执行状态独立。')
add('recognize','后台发起识别或重新识别',{'record_id':identifier,'expected_revision':revision,'request_id':identifier},['record_id','expected_revision','request_id'],ref('Attempt'),'同request_id同请求返回原attempt，不重复入队；不同请求同ID拒绝。新attempt冻结当前策略；同attempt技术重试保持快照。')
add('attempt','读取识别任务及候选',{'attempt_id':identifier},['attempt_id'],ref('Attempt'),'任务回读仍验证record归属；AI运行中人工保存后，AI返回只形成候选。')
add('adopt','明确采用新的AI候选',{'record_id':identifier,'attempt_id':identifier,'expected_revision':revision,'reason':reason},['record_id','attempt_id','expected_revision','reason'],ref('Record'),'验证attempt属于此记录且已完成、有可用候选；原子替换当前值与投影，保留修改前后及原AI。')
add('history','读取人工修改历史',{'record_id':identifier,'page':{'type':'integer','minimum':1,'default':1}},['record_id'],obj({'rows':arr(ref('Audit')),'total':{'type':'integer'}},['rows','total']),'仅返回授权记录审计；历史不可修改。')
target={'target_type':enum('person','department'),'target_uuid':identifier}
add('policy','读取来源配置及继承结果',target,list(target),ref('Policy'),'个人覆盖→所属组织→祖先→library；多部门模式冲突不随机选，返回policy_conflict。')
add('savePolicy','设置组织或人员识别来源',dict(target,mode=enum('inherit','library','web'),expected_revision=revision),list(target)+['mode','expected_revision'],ref('Policy'),'无显式配置时revision=0；以唯一目标及revision防冲突。修改仅影响下一次attempt，历史快照不变。')
add('exportCreate','按部门与日期创建图片数据ZIP',{'department_uuid':identifier,'date_from':date,'date_to':date,'request_id':identifier},['department_uuid','date_from','date_to','request_id'],ref('Export'),'范围固定包含下级部门所有有效人员，无上传人员仍入清单；与当前查看人无关。冻结数据快照后异步生成，包含全部约定CSV、原图和manifest。')
add('exportStatus','查询部门导出进度',{'export_id':identifier},['export_id'],ref('Export'),'仅请求人及受权角色可读；缺图为partial，有缺图清单；不能把部分失败标为complete。')
add('exportDownload','下载已完成的部门ZIP',{'export_id':identifier},['export_id'],{},'下载时再次校验登录、发起人/任务范围、部门权限和有效期；撤权拒绝；Content-Disposition使用安全文件名；不返回永久公开URL。','get',True)
add('photo','查看授权记录原图',{'record_id':identifier,'photo_id':identifier},['record_id','photo_id'],{},'只从记录已验证的storage_ref取图；禁止任意URL参数和内网抓取。缺图明确404；授权后流式返回原图。','get',True)
add('exportManifest','回读导出快照与缺失清单',{'export_id':identifier},['export_id'],obj({'schema_version':{'type':'integer','enum':[1]},'export':ref('Export'),'datasets':arr(obj({'name':text(),'rows':{'type':'integer','minimum':0},'status':enum('complete','failed'),'error':dict(text(),nullable=True)},['name','rows','status','error'])),'images':arr(obj({'photo_id':identifier,'path':text(),'status':enum('packed','missing','failed'),'reason':dict(text(),nullable=True)},['photo_id','path','status','reason']))},['schema_version','export','datasets','images']),'complete/partial允许下载；必需数据集失败使任务failed。整个快照范围必须仍被授权；过期拒绝。')
download = paths['/p/DietPhoto/exportDownload']['get']
download['responses']['410'] = {'description':'导出已过期','content':{'application/json':{'schema':ref('Error'),'examples':{'expired':{'value':{'code':1,'message':'导出已过期','error_code':'EXPORT_EXPIRED','trace_id':'trace-example','data':{}}}}}}}
save = paths['/p/DietPhoto/save']['post']['requestBody']['content']['application/json']['schema']
save['oneOf'] = [
    {'properties':{'meal_times':{'enum':[1,2,3]},'meal_kind':enum('regular','extra','snack','unassigned')}},
    {'properties':{'meal_times':{'enum':[4]},'meal_kind':enum('night_snack','unassigned')}},
    {'properties':{'meal_times':{'nullable':True,'enum':[None]},'meal_kind':enum('unassigned')}}
]
paths['/p/DietPhoto/save']['post']['requestBody']['content']['application/json']['examples']['request']['value']['meal_kind'] = 'unassigned'

aggregate = paths['/p/DietPhoto/records']['post']['responses']['200']['content']['application/json']
aggregate_data = aggregate['schema']['oneOf'][0]['properties']['data']
aggregate_data['properties']['totals_status'] = ref('NutrientStatus')
aggregate_data['required'].append('totals_status')
aggregate['examples']['success']['value'] = sample(aggregate['schema']['oneOf'][0])

doc={'openapi':'3.0.3','info':{'title':'VWCG-1354 store饮食照片接口草案','version':'0.1.0-draft','description':'原型已确认；本文件待技术/API审核，未实现、未导入。拟属Apifox项目5737553，目标目录ID/AI分支待恢复授权后核验。'},'paths':paths,'components':{'securitySchemes':{'pcToken':{'type':'apiKey','in':'header','name':'token'}},'schemas':schemas}}
(OUT/'store-contract.openapi.json').write_text(json.dumps(doc,ensure_ascii=False,indent=2)+'\n')
print('generated',len(paths),'proposed endpoints')
