#!/usr/bin/env python3
"""
将剧本表(Q8AyX5)中互动组件的文本配置转为结构化JSON并写回组件配置列。
"""
import json, subprocess, sys, re

# --- Bot Token ---
def get_token():
    APP_ID = "cli_a931175d41799cc7"
    import os
    with open(os.path.expanduser('/root/.openclaw/credentials/xiaoyan/config.json')) as f:
        cfg = json.load(f)
    APP_SECRET = cfg['apps'][0]['appSecret']
    r = subprocess.run([
        'curl', '-s', '-X', 'POST',
        'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
        '-H', 'Content-Type: application/json',
        '-d', json.dumps({"app_id": APP_ID, "app_secret": APP_SECRET})
    ], capture_output=True, text=True)
    return json.loads(r.stdout)['tenant_access_token']

TOKEN = get_token()
SPREADSHEET_TOKEN = "VBozs8u24h4KgdtSSiFc9vHEnBd"
SHEET_ID = "Q8AyX5"

# ============================================================
# Component configs (raw text from sheet column I)
# ============================================================
CONFIGS = {
    "1217201": {
        "type": "图片单选",
        "text": """【任务标题】
为包裹找到正确的日期牌子

【情境引入】
Jay : Help me find the right place!

【互动内容】
Find the "Days Ago" sign.（音频）
选项：
00
01
02
答案：
00
辅助信息：days ago 指"几天前"。

【互动反馈】
正确 Lin : Bingo!
错误 Jay : No, look! This package is from 3 days ago!

【后置对话】
无""",
    },
    "1217202": {
        "type": "对话朗读",
        "text": """【任务标题】
朗读90天前的包裹信息

【资源配置】
图片时机：互动内容

【情境引入】
无

【互动内容】
User: It's 90 days ago...（朗读）

【后置对话】
无""",
    },
    "1217203": {
        "type": "对话朗读",
        "text": """【任务标题】
理解天数和月数的换算

【资源配置】
图片时机：互动内容

【情境引入】
User: No! 90 days! It is about...

【互动内容】
User: 3 months!（朗读）

【后置对话】
User: This is from months ago!""",
    },
    "1217204": {
        "type": "对话组句",
        "text": """【任务标题】
用单词组句描述包裹信息

【资源配置】
图片时机：互动内容

【情境引入】
无

【互动内容】
要求：用所给单词或短语组句
It says 24 months ago.（音频）
选项1：it says
选项2：months ago
选项3：24 

答案：It says 24 months ago.

【互动反馈】
正确 无
错误 Jay : Try again! Read what the package says.

【后置对话】
无""",
    },
    "1217205": {
        "type": "对话朗读",
        "text": """【任务标题】
理解月数和年数的换算

【资源配置】
图片时机：互动内容

【情境引入】
无

【互动内容】
User: That's 2 years!（朗读）

【后置对话】
无""",
    },
    "1217206": {
        "type": "对话选读",
        "text": """【任务标题】
选择你想表达的感受

【资源配置】
无

【情境引入】
无

【互动内容】
要求：选择一个你想表达的观点
选项：（音频）
选项1：That's a long time ago!
- 反馈 Jay: You are right.
选项2：That is long, long ago!
- 反馈 Jay: You are right.

【后置对话】
无""",
    },
    "1217207": {
        "type": "图片多选",
        "text": """【任务标题】
找出写有months ago的包裹

【情境引入】
空
【互动内容】
Find the "months ago" packages in the picture.（音频）
选项：
00
01
02
答案：
01
02
辅助信息：months ago 指"几个月前"。

【互动反馈】
正确 User : Those two are months ago!
错误 Jay : Look again! Which ones say "months ago"?

【后置对话】
无""",
    },
    "1217208": {
        "type": "图片单选",
        "text": """【任务标题】
找出写有一年前的包裹

【情境引入】
空

【互动内容】
Find the "year ago" package in the picture.（音频）
选项：
00
01
02
答案：
02
辅助信息：a year ago 指"一年前"。

【互动反馈】
正确 User : This one is a year ago!
错误 Jay : No, that's not right. Look again!

【后置对话】
无""",
    },
    "1217209": {
        "type": "对话挖空",
        "text": """【任务标题】
补全对Sunny说的句子

【资源配置】
无

【情境引入】
空

【互动内容】
You must ___ it!（音频）
选项1：be happy with（正确）
选项2：happy with

【互动反馈】
正确 User : You must be happy with it!
错误 Sunny : That doesn't sound quite right...

【后置对话】
无""",
    },
    "1217210": {
        "type": "对话选读",
        "text": """【任务标题】
选择帮Grace拿包裹的说法

【资源配置】
无

【情境引入】
空

【互动内容】
要求：选择一个你想表达的观点
选项：（音频）
选项1：Let me get it!
- 反馈 Grace: Thank you, kid.
选项2：I will get it!
- 反馈 Grace: Thank you, kid.

【后置对话】
无""",
    },
    "1217211": {
        "type": "对话朗读",
        "text": """【任务标题】
对Anna的提醒

【资源配置】
图片时机：无

【情境引入】
无

【互动内容】
User: You will not be happy with it.（朗读）

【后置对话】
无""",
    },
    "1217212": {
        "type": "对话组句",
        "text": """【任务标题】
用单词组句主动帮忙

【资源配置】
无

【情境引入】
空

【互动内容】
要求：用所给单词或短语组句
Can I get it for you?（音频）
选项1：for you
选项2：can I
选项3：get it

答案：Can I get it for you?

【互动反馈】
正确 Jack : You are very kind. But I just want to say...
错误 Jack: I beg your pardon?

【后置对话】
无""",
    },
    "1217213": {
        "type": "对话挖空",
        "text": """【任务标题】
补全对Jack说的话

【资源配置】
图片时机：互动内容互动反馈

【情境引入】
无

【互动内容】
But this meat is from 2 ___ !（音频）
选项1：years ago（正确）
选项2：days ago

【互动反馈】
正确 Jack : Perfect!
错误 Jack : No, look at the sign on it!

【后置对话】
无""",
    },
    "1217214": {
        "type": "听力拖拽",
        "text": """【任务标题】
告诉 Lin 你们分发包裹的事迹

【任务背景】
包裹大作战！你和 Jay 热火朝天地干了半天，把很多包裹带给了他们的主人。快来回顾一下你们的战果吧！

【通关知识】

get        v.        收到
month        n.       月
year        n.        年
ago        adv.        以前
... month(s)/year(s) ago.     
           
【开场语】
Lin: Come on, tell me what you did!

【听力文本】
# 文本 1
Jay: Well, well, well! Listen up!
Jay: Tom gets a pen!
User: It is from 5 days ago!
Jay: And Sunny gets a dress.
Jay: It is from 6 months ago. 
Jay: Jack gets some meat from 2 years ago!

【题目信息】

#单空选择
选项图片编号：00,01,02
答案图片编号：
01,00,02


【学习过程】
句子 1
It is from 5 days ago!
【ago】

句子 2
It is from 6 months ago. 【month】


句子 3
Jack gets some meat from 2 years ago! 【year】""",
    },
    "1217215": {
        "type": "对话选读",
        "text": """【任务标题】
选择表达满意的方式

【资源配置】
无

【情境引入】
无

【互动内容】
要求：选择一个你想表达的观点
选项：（音频）
选项1：I am happy with it!
- 反馈 Jay: That's good!
选项2：I am happy with the result!
- 反馈 Jay: That's good!

【后置对话】
无""",
    },
    "1217216": {
        "type": "对话挖空",
        "text": """【任务标题】
补全User想要包裹的句子

【资源配置】
无

【情境引入】
空

【互动内容】
I want to ___ one for myself!（音频）
选项1：get（正确）
选项2：get up

【互动反馈】
正确 无
错误 Jay : Hmm, that's not how we say it. Try again!

【后置对话】
无""",
    },
    "1217217": {
        "type": "对话朗读",
        "text": """【任务标题】
朗读收到帽子的喜悦

【资源配置】
图片时机：无

【情境引入】
无

【互动内容】
User: Now I get my own hat!（朗读）

【后置对话】
无""",
    },
}


# ============================================================
# Parsers for each component type
# ============================================================

def extract_section(text, key):
    """Extract content between 【key】and next section header 【...】."""
    # Match 【key】 then capture everything until next 【XXX】 header on its own line or end
    pattern = rf'【{re.escape(key)}】\s*\n?(.*?)(?=\n(?:【[^】]+】)\s*\n|\Z)'
    m = re.search(pattern, text, re.DOTALL)
    if m:
        return m.group(1).strip()
    return None

def parse_context(text):
    """Parse context intro: '角色 : 台词' or '空' or '无'"""
    if not text or text in ('无', '空', ''):
        return None
    lines = [l.strip() for l in text.strip().split('\n') if l.strip()]
    result = []
    for line in lines:
        if ':' in line:
            parts = line.split(':', 1)
            result.append({"character": parts[0].strip(), "line": parts[1].strip()})
        elif '：' in line:
            parts = line.split('：', 1)
            result.append({"character": parts[0].strip(), "line": parts[1].strip()})
        else:
            result.append(line)
    return result if result else None

def parse_feedback(text):
    """Parse feedback: 正确/错误 角色 : 台词"""
    if not text or text == '无':
        return {"correct": None, "incorrect": None}
    
    result = {"correct": None, "incorrect": None}
    lines = text.strip().split('\n')
    
    current_type = None
    for line in lines:
        line = line.strip()
        if not line:
            continue
        if line.startswith('正确'):
            current_type = 'correct'
            content = line[2:].strip()
            if ':' in content:
                parts = content.split(':', 1)
                char = parts[0].strip()
                msg = parts[1].strip()
                if msg == '无':
                    result['correct'] = None
                else:
                    result['correct'] = {"character": char, "line": msg}
            elif content == '无':
                result['correct'] = None
        elif line.startswith('错误'):
            current_type = 'incorrect'
            content = line[2:].strip()
            if ':' in content:
                parts = content.split(':', 1)
                result['incorrect'] = {"character": parts[0].strip(), "line": parts[1].strip()}
            elif '：' in content:
                parts = content.split('：', 1)
                result['incorrect'] = {"character": parts[0].strip(), "line": parts[1].strip()}
    return result

def parse_selective_options(text):
    """Parse 对话选读 options: 选项N：text - 反馈 X: line"""
    options = []
    lines = text.strip().split('\n')
    i = 0
    while i < len(lines):
        line = lines[i].strip()
        m = re.match(r'选项(\d+)[：:]\s*(.+)', line)
        if m:
            idx = int(m.group(1))
            opt_text = m.group(2).strip()
            feedback = None
            # Check next line for feedback
            if i + 1 < len(lines):
                next_line = lines[i + 1].strip()
                fm = re.match(r'[-–]\s*反馈\s*([^:：]+)[：:]\s*(.+)', next_line)
                if fm:
                    feedback = {"character": fm.group(1).strip(), "line": fm.group(2).strip()}
                    i += 1
            options.append({"index": idx, "text": opt_text, "feedback": feedback})
        i += 1
    return options

def parse_image_options(text):
    """Parse image choice options: 00, 01, 02 etc."""
    options = []
    lines = text.strip().split('\n')
    in_options = False
    for line in lines:
        line = line.strip()
        if line == '选项：':
            in_options = True
            continue
        if in_options:
            if re.match(r'^\d{2}$', line):
                options.append(line)
            else:
                break
    return options

def parse_fill_options(text):
    """Parse fill-in-blanks options: 选项N：text（正确）"""
    options = []
    lines = text.strip().split('\n')
    for line in lines:
        line = line.strip()
        m = re.match(r'选项(\d+)[：:]\s*(.+)', line)
        if m:
            idx = int(m.group(1))
            opt_text = m.group(2).strip()
            correct = False
            if '（正确）' in opt_text:
                correct = True
                opt_text = opt_text.replace('（正确）', '').strip()
            options.append({"index": idx, "text": opt_text, "correct": correct})
    return options

def parse_sentence_options(text):
    """Parse sentence building options: 选项N：text"""
    options = []
    lines = text.strip().split('\n')
    for line in lines:
        line = line.strip()
        m = re.match(r'选项(\d+)[：:]\s*(.+)', line)
        if m:
            idx = int(m.group(1))
            opt_text = m.group(2).strip()
            options.append({"index": idx, "text": opt_text})
    return options


# ============================================================
# Converters
# ============================================================

def convert_choice_image(cid, text):
    """图片单选 / 图片多选"""
    is_multi = CONFIGS[cid]['type'] == '图片多选'
    
    raw_interaction = extract_section(text, '互动内容')
    context = extract_section(text, '情境引入')
    feedback = extract_section(text, '互动反馈')
    post = extract_section(text, '后置对话')
    
    # Parse interaction
    interaction_lines = raw_interaction.strip().split('\n')
    instruction = interaction_lines[0].strip()
    audio = '（音频）' in instruction
    instruction = instruction.replace('（音频）', '').strip()
    
    options = []
    answers = []
    hint = None
    in_options = False
    in_answer = False
    in_hint = False
    
    for line in interaction_lines[1:]:
        line = line.strip()
        if line == '选项：':
            in_options = True
            continue
        if in_options and re.match(r'^\d{2}$', line):
            options.append(line)
            continue
        if '答案：' in line:
            in_options = False
            in_answer = True
            ans_text = line.replace('答案：', '').strip()
            if ans_text:
                answers.append(ans_text)
            continue
        if in_answer:
            if re.match(r'^\d{2}$', line):
                answers.append(line)
            elif '辅助信息' in line:
                in_answer = False
                in_hint = True
                hint = line.replace('辅助信息：', '').strip()
            else:
                in_answer = False
            continue
        if in_hint:
            continue
        if '辅助信息' in line:
            hint = line.replace('辅助信息：', '').strip()
            continue
    
    result = {
        "componentType": CONFIGS[cid]['type'],
        "taskTitle": extract_section(text, '任务标题'),
        "contextIntro": parse_context(context),
        "interaction": {
            "instruction": instruction,
            "audio": audio,
            "options": options,
            "answers": answers,
            "hint": hint
        },
        "feedback": parse_feedback(feedback),
        "postDialogue": parse_context(post)
    }
    return result

def convert_reading(cid, text):
    """对话朗读"""
    return {
        "componentType": "对话朗读",
        "taskTitle": extract_section(text, '任务标题'),
        "resourceConfig": extract_section(text, '资源配置') or None,
        "contextIntro": parse_context(extract_section(text, '情境引入')),
        "interaction": {
            "sentence": (extract_section(text, '互动内容') or '').replace('（朗读）', '').strip(),
            "audio": True
        },
        "postDialogue": parse_context(extract_section(text, '后置对话'))
    }

def convert_sentence_building(cid, text):
    """对话组句"""
    raw_interaction = extract_section(text, '互动内容')
    resource = extract_section(text, '资源配置') or None
    context = extract_section(text, '情境引入')
    feedback = extract_section(text, '互动反馈')
    post = extract_section(text, '后置对话')
    
    interaction_lines = raw_interaction.strip().split('\n')
    requirement = None
    sentence = None
    audio = True
    options = []
    answer = None
    hint = None
    
    # Parse interaction
    i = 0
    if interaction_lines[0].startswith('要求：'):
        requirement = interaction_lines[0].replace('要求：', '').strip()
        i = 1
    
    # Find sentence line (ends with （音频）)
    for j in range(i, len(interaction_lines)):
        line = interaction_lines[j].strip()
        if '（音频）' in line:
            sentence = line.replace('（音频）', '').strip()
            i = j + 1
            break
        elif re.match(r'选项\d+', line):
            # No sentence line found, use requirement as sentence
            i = j
            break
    
    # Parse options
    for j in range(i, len(interaction_lines)):
        line = interaction_lines[j].strip()
        m = re.match(r'选项(\d+)[：:]\s*(.+)', line)
        if m:
            options.append({"index": int(m.group(1)), "text": m.group(2).strip()})
        elif '答案：' in line:
            answer = line.replace('答案：', '').strip()
        elif '辅助信息：' in line:
            hint = line.replace('辅助信息：', '').strip()
    
    return {
        "componentType": "对话组句",
        "taskTitle": extract_section(text, '任务标题'),
        "resourceConfig": resource,
        "contextIntro": parse_context(context),
        "interaction": {
            "requirement": requirement,
            "sentence": sentence,
            "audio": audio,
            "options": options,
            "answer": answer,
            "hint": hint
        },
        "feedback": parse_feedback(feedback),
        "postDialogue": parse_context(post)
    }

def convert_selective_reading(cid, text):
    """对话选读"""
    raw_interaction = extract_section(text, '互动内容')
    resource = extract_section(text, '资源配置') or None
    context = extract_section(text, '情境引入')
    post = extract_section(text, '后置对话')
    
    interaction_lines = raw_interaction.strip().split('\n')
    requirement = interaction_lines[0].strip()
    audio = '（音频）' in raw_interaction
    if requirement.startswith('要求：'):
        requirement = requirement.replace('要求：', '').strip()
    
    options = parse_selective_options(raw_interaction)
    
    return {
        "componentType": "对话选读",
        "taskTitle": extract_section(text, '任务标题'),
        "resourceConfig": resource,
        "contextIntro": parse_context(context),
        "interaction": {
            "requirement": requirement,
            "audio": audio,
            "options": options
        },
        "postDialogue": parse_context(post)
    }

def convert_fill_blanks(cid, text):
    """对话挖空"""
    raw_interaction = extract_section(text, '互动内容')
    resource = extract_section(text, '资源配置') or None
    context = extract_section(text, '情境引入')
    feedback = extract_section(text, '互动反馈')
    post = extract_section(text, '后置对话')
    
    interaction_lines = raw_interaction.strip().split('\n')
    sentence = None
    options = []
    
    for line in interaction_lines:
        line = line.strip()
        if '（音频）' in line and not line.startswith('选项'):
            sentence = line.replace('（音频）', '').strip()
        m = re.match(r'选项(\d+)[：:]\s*(.+)', line)
        if m:
            opt_text = m.group(2).strip()
            correct = '（正确）' in opt_text
            opt_text = opt_text.replace('（正确）', '').strip()
            options.append({"index": int(m.group(1)), "text": opt_text, "correct": correct})
    
    return {
        "componentType": "对话挖空",
        "taskTitle": extract_section(text, '任务标题'),
        "resourceConfig": resource,
        "contextIntro": parse_context(context),
        "interaction": {
            "sentence": sentence,
            "audio": True,
            "options": options
        },
        "feedback": parse_feedback(feedback),
        "postDialogue": parse_context(post)
    }

def convert_listening_drag(cid, text):
    """听力拖拽 → core_listening_drag JSON"""
    task_title = extract_section(text, '任务标题')
    task_bg = extract_section(text, '任务背景') or ''
    knowledge = extract_section(text, '通关知识') or ''
    opening = extract_section(text, '开场语') or ''
    audio_text = extract_section(text, '听力文本') or ''
    question_info = extract_section(text, '题目信息') or ''
    learning = extract_section(text, '学习过程') or ''
    
    # Parse audio text into dialogs
    dialog_list = []
    for line in audio_text.strip().split('\n'):
        line = line.strip()
        if not line or line.startswith('#'):
            continue
        if ':' in line or '：' in line:
            sep = ':' if ':' in line else '：'
            parts = line.split(sep, 1)
            dialog_list.append({"character": parts[0].strip(), "line": parts[1].strip()})
    
    # Parse question info
    option_images = []
    answer_images = []
    for line in question_info.strip().split('\n'):
        line = line.strip()
        if '选项图片编号' in line:
            option_images = [x.strip() for x in line.split('：', 1)[1].split(',') if x.strip()]
        if '答案图片编号' in line:
            # Answer on next line
            pass
    
    # Get answer from question info - look for answer after 答案图片编号
    q_lines = question_info.strip().split('\n')
    for i, line in enumerate(q_lines):
        if '答案图片编号' in line and i + 1 < len(q_lines):
            answer_images = [x.strip() for x in q_lines[i+1].split(',') if x.strip() and x.strip() != '答案图片编号']
    
    # For 听力拖拽, 【学习过程】 is the LAST section and contains inline 【kp】.
    # Extract it manually: everything after 【学习过程】
    lp_match = re.search(r'【学习过程】\s*\n(.*)', text, re.DOTALL)
    learning = lp_match.group(1).strip() if lp_match else ''
    
    learning_steps = []
    # Split by sentence markers
    blocks = re.split(r'\n(?=句子\s*\d*)', learning)
    for block in blocks:
        block = block.strip()
        if not block:
            continue
        # Remove the 句子 N header
        block = re.sub(r'^句子\s*\d*\s*\n?', '', block).strip()
        if not block:
            continue
        # Extract kp from 【...】
        kp_match = re.search(r'【(.+?)】', block)
        kp = kp_match.group(1).strip() if kp_match else ""
        # Extract sentence (text before 【 or whole block if no 【)
        if kp_match:
            sentence = block[:kp_match.start()].strip()
        else:
            sentence = block.strip()
        if sentence or kp:
            learning_steps.append({"sentence": sentence, "knowledgePoint": kp})
    
    # Scene description
    scene_desc = f"{task_bg}\n\n{opening}"
    
    return {
        "componentType": "听力拖拽",
        "cType": "core_listening_drag",
        "taskData": {
            "cType": "core_listening_drag",
            "cId": cid,
            "title": task_title,
            "sceneDesc": scene_desc,
            "key": "ago, month, year, get"
        },
        "dialogList": dialog_list,
        "preDialog": [],
        "questionList": [
            {
                "type": "drag_match",
                "optionImages": option_images,
                "answerImages": answer_images,
                "itemCount": len(option_images)
            }
        ],
        "learningData": {
            "learningPart": learning_steps,
            "closing": ""
        },
        "audioText": audio_text,
        "knowledgeSummary": knowledge
    }


# ============================================================
# Main conversion
# ============================================================

CONVERTERS = {
    "图片单选": convert_choice_image,
    "图片多选": convert_choice_image,
    "对话朗读": convert_reading,
    "对话组句": convert_sentence_building,
    "对话选读": convert_selective_reading,
    "对话挖空": convert_fill_blanks,
    "听力拖拽": convert_listening_drag,
}

# Component row mapping (from sheet data: which row has which component ID)
COMPONENT_ROWS = {
    "1217201": 33,
    "1217202": 40,
    "1217203": 43,
    "1217204": 50,
    "1217205": 51,
    "1217206": 53,
    "1217207": 68,
    "1217208": 70,
    "1217209": 89,
    "1217210": 95,
    "1217211": 107,
    "1217212": 120,
    "1217213": 123,
    "1217214": 157,
    "1217215": 164,
    "1217216": 168,
    "1217217": 173,
}


def write_cell(row, col_letter, value, token):
    """Write a single cell to the sheet."""
    cell_range = f"{SHEET_ID}!{col_letter}{row}:{col_letter}{row}"
    payload = {
        "valueRange": {
            "range": cell_range,
            "values": [[value]]
        }
    }
    r = subprocess.run([
        'curl', '-s', '-X', 'PUT',
        f'https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/values',
        '-H', f'Authorization: Bearer {token}',
        '-H', 'Content-Type: application/json',
        '-d', json.dumps(payload, ensure_ascii=False)
    ], capture_output=True, text=True)
    result = json.loads(r.stdout)
    if result.get('code') != 0:
        print(f"  ❌ Write failed: {result}", file=sys.stderr)
        return False
    return True


def main():
    results = {}
    for cid, cfg in CONFIGS.items():
        ctype = cfg['type']
        converter = CONVERTERS.get(ctype)
        if not converter:
            print(f"⚠️  Unknown type {ctype} for {cid}", file=sys.stderr)
            continue
        try:
            json_data = converter(cid, cfg['text'])
            results[cid] = json_data
            print(f"✅ {cid} ({ctype}) → JSON OK")
        except Exception as e:
            print(f"❌ {cid} ({ctype}) → ERROR: {e}", file=sys.stderr)
            import traceback
            traceback.print_exc()
    
    # Print all JSONs for review
    print("\n" + "=" * 60)
    print("GENERATED JSONS")
    print("=" * 60)
    for cid, data in results.items():
        print(f"\n--- {cid} ({data['componentType']}) ---")
        print(json.dumps(data, ensure_ascii=False, indent=2))
    
    # Save to file for inspection
    with open('/root/.openclaw/workspace-xiaoyan/output/component_jsons.json', 'w', encoding='utf-8') as f:
        json.dump(results, f, ensure_ascii=False, indent=2)
    print(f"\n📁 Saved to output/component_jsons.json")
    
    # Write back to sheet
    print("\n" + "=" * 60)
    print("WRITING BACK TO SHEET")
    print("=" * 60)
    
    # Re-fetch token (may have expired)
    token = get_token()
    
    success = 0
    fail = 0
    for cid, data in results.items():
        row = COMPONENT_ROWS.get(cid)
        if not row:
            print(f"⚠️  {cid}: no row mapping, skipped")
            continue
        json_str = json.dumps(data, ensure_ascii=False)
        if write_cell(row, 'I', json_str, token):
            print(f"✅ {cid} → row {row} written")
            success += 1
        else:
            fail += 1
    
    print(f"\nDone: {success} written, {fail} failed")


if __name__ == '__main__':
    main()
