#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
P0 v0.2 前端改造：工具/活动录入.html
====================================
【本脚本解决的核心问题】
新字段有【四个】静默丢弃点，漏任何一个 → 销售填的数据当场消失且无报错：
  ① 后端 _clean_block 硬编码白名单        （已由 apply_p0v02_backend.py 修）
  ② 后端 _block_empty 只查旧字段          （已修）
  ③ 前端 syncFromDom() 硬编码 key 清单    ← 本脚本
  ④ 前端 回填 硬编码 key 清单             ← 本脚本
③④ 不逐个加 key，改成【通用处理】，从根上消灭这类陷阱。

【设计决策·不造重复字段】
  · P0 的 influence_tier  ≡ 现有 role     （枚举一致，只是多了「影响者」）
  · P0 的 comm_pref/focus_point ≈ 现有 note（placeholder 本就是「沟通偏好 / 关注点」）
  → 保留现有 role / note，新增 4 个真正缺的：reports_to / attitude / comm_pref / focus_point
    其中 comm_pref、focus_point 独立输入（note 降级为自由备注，老数据不丢）

用法：
    /usr/bin/python3 脚本/apply_p0v02_frontend.py --dry-run
    /usr/bin/python3 脚本/apply_p0v02_frontend.py --apply
"""
import io
import os
import shutil
import sys
import datetime

BASE = os.path.expanduser('~/Desktop/Hermes输出-工作类')
TARGETS = ['工具/活动录入.html']
MARK = 'P0 v0.2 前端改造'

# ────────────────────────── 字段块生成器 ──────────────────────────
def _sel(label, key, enums, span=3, hint=''):
    h = '<div class="hint">%s</div>' % hint if hint else ''
    opts = ''.join('<option>%s</option>' % e for e in enums)
    return ('<div class="f span%d"><label>%s</label>'
            '<select data-f="%s"><option value="">请选择…</option>%s</select></div>'
            % (span, label, key, opts), h)


def _txt(label, key, ph='', span=3):
    return ('<div class="f span%d"><label>%s</label>'
            '<input type="text" data-f="%s" placeholder="%s" autocomplete="off"></div>'
            % (span, label, key, ph))


def _area(label, key, ph='', span=6, rows=3):
    return ('<div class="f span%d"><label>%s</label>'
            '<textarea data-f="%s" rows="%d" placeholder="%s"></textarea></div>'
            % (span, label, key, rows, ph))


def _multi(label, key, enums, span=6, hint=''):
    h = '<div class="hint">%s</div>' % hint if hint else ''
    chips = ''.join('<label class="chip"><input type="checkbox" value="%s">%s</label>' % (e, e)
                    for e in enums)
    return ('<div class="f span%d"><label>%s</label>%s'
            '<div class="chips" data-fm="%s">%s</div></div>' % (span, label, h, key, chips))


def _q(s):
    """把裸 HTML 变成 JS 单引号字符串字面量。
    【坑】忘了这一步 → 裸 HTML 直接拼进 JS，node --check 报 Unexpected token '<'。"""
    return "'" + s.replace('\\', '\\\\').replace("'", "\\'") + "'"


def card(n, title, sub, parts):
    """parts = 裸 HTML 字符串列表；引号由本函数统一加，调用方不要自己加。"""
    s = (' <span style="font-weight:400;color:var(--grey);font-size:12px">（%s）</span>' % sub) if sub else ''
    body = ' +\n    '.join(_q(p) for p in parts)
    return ("'<div class=\"card\"><h3><span class=\"n\">%d</span>%s%s</h3>' +\n    %s +\n"
            % (n, title, s, body))


# ────────────────────────── 三张新卡片 ──────────────────────────
def _flat(parts):
    """把 [HTML 或 (HTML, hint)] 展平成裸 HTML 串列表。hint 紧随其后。"""
    out = []
    for p in parts:
        if isinstance(p, tuple):
            out.append(p[0])
            if p[1]:
                out.append(p[1])
        else:
            out.append(p)
    return out


def reg_card():
    """工商与资质 12 字段 —— 挂 custBlockHtml"""
    parts = [
        _txt('成立时间', 'founded_year', '如 2015-06', 2),
        _txt('注册资本', 'registered_capital_wan', '如 2000（万元）', 2),
        _sel('融资 / 上市情况', 'financing_status',
             ['未融资', '天使 ~ A 轮', 'B 轮及以后', '已上市（科创板 / 创业板）', '已上市（主板 / 新三板）', '其他'], 2),
        _sel('人员规模', 'headcount_scale', ['< 50 人', '50 ~ 200 人', '200 ~ 500 人', '500 ~ 1000 人', '> 1000 人'], 3),
        _txt('注册证数', 'license_count', '如 12（张）', 3),
        _sel('客户行业', 'corp_nature', ['人医', '动保', 'NGS', '科研服务', '其他'], 3, '客户【属于哪个行业】'),
        _sel('公司类型', 'biz_type', ['生产型', '贸易型', '服务型', '合资外资'], 3, '经营形态（生产／贸易这类区分）'),
        _sel('资质情况', 'license_status', ['已有注册证', '注册检 · 临床中', '研发阶段', '不涉及报证'], 3),
        _multi('市场场景', 'strategy_focus',
               ['呼吸道', '肿瘤', 'HPV', '法医', '宠物', '科研高校', '其他'], 6,
               '客户【主营业务在哪个市场场景经营】· 多选'),
        _area('在研 / 在注册产品', 'registering_products', '项目名 · 靶点 · 当前阶段；多条分行', 6),
        _area('资金链 / 经营风险', 'risk_notes', '公开渠道可见的经营风险；无则留空', 4),
        _txt('风险来源', 'risk_note_source', '如 年报 / 客户访谈 / 行业新闻', 2),
    ]
    return card(5, '工商与资质',
                '公开工商信息 + 资质；能自动获取的会自动带出，带不出的手填',
                _flat(parts))


def mode_card():
    """决策模式 9 字段 —— 挂 custBlockHtml"""
    parts = [
        _sel('选择倾向', 'price_priority', ['性能优先', '价格优先', '性能优先，但价格是门槛'], 6),
        _sel('必须有备选供应商', 'require_backup_vendor', ['是', '否', '不确定'], 2),
        _sel('谁是定标人', 'vendor_decider', ['研发决定', '采购决定', '老板决定', '多方博弈'], 2),
        _sel('砍价轮次', 'negotiation_rounds',
             ['1 轮到底', '2 轮（研发谈价 + 采购再砍）', '3 轮及以上', '不确定'], 2),
        _sel('首轮是否需留空间', 'quote_buffer', ['需要留空间', '可直接报底价'], 3),
        _sel('注册与优化是否分开', 'phase_split_reg_opt', ['是（先注册，后优化）', '否（一次到位）'], 3),
        _multi('痛点 · 人员不足', 'pain_labor_shortage',
               ['研发/技术人手不足', '采购人手不足', '无此痛点'], 6),
        _multi('痛点 · 降本诉求', 'pain_import_cost',
               ['现用进口原料成本高想替换', '竞品涨价 / 批间差不稳', '货期长 / 断供风险'], 6),
        _area('痛点补充说明', 'pain_notes', '客户原话优先；写清对采购决策的影响', 6),
    ]
    return card(6, '决策模式', '决定「怎么谈、报多少、留不留空间」，不看个人性格只看客观行为',
                _flat(parts))


def proj_card():
    """项目画像 11 字段 —— 挂 projBlockHtml"""
    parts = [
        _sel('板块归属', 'segment', ['IVD 注册', '公卫', '国外', '宠物', '动物', '科研'], 3),
        _txt('产品线', 'product_line', '如 分子诊断原料', 3),
        _txt('年出货量', 'annual_volume_t', '如 200', 2),
        _sel('出货量单位', 'volume_unit', ['T / 测试数', '人份', '万人份', 'kg', 'L'], 2),
        _txt('反应体系', 'reaction_system', '如 TaqMan qPCR / 一步法 RT-qPCR', 2),
        _txt('现用原料品牌', 'incumbent_brand', '客户现在用的是谁家', 3),
        ('<div class="f span3"><label>关联测试反馈 '
         '<span style="font-weight:400;color:var(--grey)">（自动带出）</span></label>'
         '<input type="text" data-f="test_ref" placeholder="由测试反馈模块自动关联" '
         'readonly style="background:#f3f5f9;color:#66708a"></div>'),
        _area('机会', 'opportunity', '这个项目我司能拿下的点', 3),
        _area('挑战', 'challenge', '挡在面前的具体障碍（技术 / 商务 / 关系）', 3),
        _area('需要的支持', 'support_needed', '需要公司/研发/领导给什么支持', 3),
        _area('突破方向', 'breakthrough', '下一步从哪突破', 3),
    ]
    return card(8, '项目画像', '这个项目对我司是什么机会、还差什么',
                _flat(parts))


# ────────────────────────── 改造执行 ──────────────────────────
def patch_file(path, apply):
    s = io.open(path, encoding='utf-8').read()
    orig = s
    if MARK in orig:
        print('  ⚠️ %s 已应用过，跳过' % os.path.basename(path))
        return True

    changed = []

    # ① syncFromDom 通用化
    old1 = """    Array.prototype.forEach.call(el.querySelectorAll('[data-f]'), function(f){
      var key = f.getAttribute('data-f');
      if (key === 'need' || key === 'issue' || key === 'feedback' || key === 'visit_goal' || key === 'visit_content') b[key] = f.value;
    });"""
    new1 = """    /* P0 v0.2 前端改造：通用化 —— 原来这里是硬编码 5 个 key，新增字段一律静默丢弃 */
    Array.prototype.forEach.call(el.querySelectorAll('[data-f]'), function(f){
      b[f.getAttribute('data-f')] = f.value;
    });
    Array.prototype.forEach.call(el.querySelectorAll('[data-fm]'), function(c){
      b[c.getAttribute('data-fm')] = Array.prototype.filter.call(
        c.querySelectorAll('input[type=checkbox]'), function(x){ return x.checked; })
        .map(function(x){ return x.value; });
    });"""
    assert s.count(old1) == 1, '① syncFromDom 锚点命中 %d 次' % s.count(old1)
    s = s.replace(old1, new1, 1); changed.append('① syncFromDom 通用化')

    # ② 回填通用化
    old2 = """      var key = f.getAttribute('data-f');
      if (key === 'need' || key === 'issue' || key === 'feedback' || key === 'visit_goal' || key === 'visit_content'
          || key === 'biz_desc' || key === 'self_share' || key === 'main_rival') {
        f.value = b[key] || '';
        f.addEventListener('input', function(){ b[key] = this.value; saveDraftSoon(); validate(); });
      }
    });"""
    new2 = """      var key = f.getAttribute('data-f');
      /* P0 v0.2 前端改造：通用化 —— 原来这里是硬编码 8 个 key */
      f.value = b[key] || '';
      if (!f.readOnly) f.addEventListener('input', function(){ b[key] = this.value; saveDraftSoon(); validate(); });
    });
    /* 多选 chips 回填 */
    Array.prototype.forEach.call(el.querySelectorAll('[data-fm]'), function(c){
      var key = c.getAttribute('data-fm');
      var vals = b[key] || [];
      Array.prototype.forEach.call(c.querySelectorAll('input[type=checkbox]'), function(x){
        x.checked = vals.indexOf(x.value) >= 0;
        x.addEventListener('change', function(){
          b[key] = Array.prototype.filter.call(c.querySelectorAll('input[type=checkbox]'),
            function(y){ return y.checked; }).map(function(y){ return y.value; });
          saveDraftSoon(); validate();
        });
      });
    });"""
    assert s.count(old2) == 1, '② 回填锚点命中 %d 次' % s.count(old2)
    s = s.replace(old2, new2, 1); changed.append('② 回填通用化 + 多选 chips')

    # ③ custBlockHtml 插入两张新卡（插在「下一步计划」卡之前）
    old3 = """  '<div class="card"><h3><span class="n">5</span>下一步计划 <span style="font-weight:400;color:var(--grey);font-size:12px">（事项 · 负责人 · 时间节点）</span></h3>' +
    '<div class="rows" data-rows="plan"></div>' +
    '<div class="addrow"><button class="btn sm" onclick="addPlan(\\''+b.k+'\\')">+ 增加一条</button></div></div>';
}
function purBlockHtml(b){"""
    new3 = (reg_card() + mode_card() +            # card() 已以 " +\n" 结尾，此处不能再加 "+"
            """  /* P0 v0.2 前端改造：原「5 下一步计划」顺延为 7 */
  '<div class="card"><h3><span class="n">7</span>下一步计划 <span style="font-weight:400;color:var(--grey);font-size:12px">（事项 · 负责人 · 时间节点）</span></h3>' +
    '<div class="rows" data-rows="plan"></div>' +
    '<div class="addrow"><button class="btn sm" onclick="addPlan(\\''+b.k+'\\')">+ 增加一条</button></div></div>';
}
function purBlockHtml(b){""")
    assert s.count(old3) == 1, '③ custBlockHtml 锚点命中 %d 次' % s.count(old3)
    s = s.replace(old3, new3, 1); changed.append('③ custBlockHtml 新增「工商与资质」「决策模式」两卡')

    # ④ projBlockHtml 插入项目画像（插在「8 下一步计划」之前，并把 8 顺延为 9）
    old4 = """  '<div class="card"><h3><span class="n">8</span>下一步计划 <span style="font-weight:400;color:var(--grey);font-size:12px">（事项 · 负责人 · 时间节点）</span></h3>' +
    '<div class="rows" data-rows="plan"></div>' +
    '<div class="addrow"><button class="btn sm" onclick="addPlan(\\''+b.k+'\\')">+ 增加一条</button></div></div>';
}"""
    new4 = (proj_card() +                          # card() 已以 " +\n" 结尾
            """  /* P0 v0.2 前端改造：原「8 下一步计划」顺延为 9 */
  '<div class="card"><h3><span class="n">9</span>下一步计划 <span style="font-weight:400;color:var(--grey);font-size:12px">（事项 · 负责人 · 时间节点）</span></h3>' +
    '<div class="rows" data-rows="plan"></div>' +
    '<div class="addrow"><button class="btn sm" onclick="addPlan(\\''+b.k+'\\')">+ 增加一条</button></div></div>';
}""")
    assert s.count(old4) == 1, '④ projBlockHtml 锚点命中 %d 次' % s.count(old4)
    s = s.replace(old4, new4, 1); changed.append('④ projBlockHtml 新增「项目画像」卡')

    # ⓪ 守卫：禁止出现「裸 + 行」
    # 成因：card() 本身已以 " +\n" 结尾，外层再加 " +\n" → 生成 "A +\n +\n B"
    # JS 解析成 A + (+B)，一元 + 把字符串变 NaN → 整张卡内容凭空消失且不报错。
    import re as _re
    for _nm, _chunk in (('new3', new3), ('new4', new4)):
        _bad = _re.findall(r'^[ \t]*\+[ \t]*$', _chunk, _re.M)
        assert not _bad, ('%s 出现「裸 + 行」→ 会生成 A + (+B) = NaN，整张卡静默消失' % _nm)

    # ⓪b 守卫：卡片编号必须接上原序号（工商=5 / 决策模式=6 / 项目画像=8）
    # 成因：新卡若沿用 card(1,..) 会与「1 决策链」撞号，用户看编号就乱了。
    assert '<span class="n">5</span>工商与资质' in new3, '工商与资质编号应为 5'
    assert '<span class="n">6</span>决策模式' in new3, '决策模式编号应为 6'
    assert '<span class="n">7</span>下一步计划' in new3, 'cust 下一步计划编号应为 7'
    assert '<span class="n">8</span>项目画像' in new4, '项目画像编号应为 8'
    assert '<span class="n">9</span>下一步计划' in new4, 'proj 下一步计划编号应为 9'

    # ⑤ role 枚举补「影响者」（对齐 P0 定版）
    old5 = '<option>采购执行</option><option>财务影响</option>'
    assert s.count(old5) == 1, '⑤ role 枚举动锚点命中 %d 次' % s.count(old5)
    s = s.replace(old5, '<option>采购执行</option><option>财务影响</option><option>影响者</option>', 1)
    changed.append('⑤ role 枚举补「影响者」')

    # ⑥ addDecision 新增 4 个输入 + 绑定
    old6 = """      '<input type="text" class="c2" placeholder="沟通偏好 / 关注点（如 重视交期、技术背景强）" value="'+esc(x.note||'')+'">' +
    '</div><button class="del" title="删除">×</button>';
  var ins = d.querySelectorAll('input[type=text]');
  ['name','title','phone','note'].forEach(function(key, n){"""
    new6 = """      '<input type="text" class="c2" placeholder="沟通偏好 / 关注点（如 重视交期、技术背景强）" value="'+esc(x.note||'')+'">' +
      /* P0 v0.2 前端改造：新增 4 个 */
      '<input type="text" placeholder="汇报对象（如 直接汇报总经理）" value="'+esc(x.reports_to||'')+'">' +
      '<select class="attitude"><option value="">态度…</option><option>支持</option><option>中立</option><option>抵触</option></select>' +
      '<input type="text" class="c2" placeholder="沟通偏好（如 微信 / 邮件 / 当面）" value="'+esc(x.comm_pref||'')+'">' +
      '<input type="text" class="c2" placeholder="关注点（如 批间差、注册支持）" value="'+esc(x.focus_point||'')+'">' +
    '</div><button class="del" title="删除">×</button>';
  var ins = d.querySelectorAll('input[type=text]');
  ['name','title','phone','note','reports_to','comm_pref','focus_point'].forEach(function(key, n){"""
    assert s.count(old6) == 1, '⑥ addDecision 锚点命中 %d 次' % s.count(old6)
    s = s.replace(old6, new6, 1)
    # push 时带上新字段
    old6b = """  if (typeof bindIdx !== 'number') b.decision.push({name:x.name||'', title:x.title||'', role:x.role||'',
                                                    power:x.power||'', phone:x.phone||'', note:x.note||''});"""
    new6b = """  if (typeof bindIdx !== 'number') b.decision.push({name:x.name||'', title:x.title||'', role:x.role||'',
                                                    power:x.power||'', phone:x.phone||'', note:x.note||'',
                                                    reports_to:x.reports_to||'', attitude:x.attitude||'',
                                                    comm_pref:x.comm_pref||'', focus_point:x.focus_point||''});"""
    assert s.count(old6b) == 1, '⑥b decision.push 锚点命中 %d 次' % s.count(old6b)
    s = s.replace(old6b, new6b, 1)
    changed.append('⑥ addDecision +4 字段（reports_to/attitude/comm_pref/focus_point）')

    # ⑥c 新增的下拉必须绑监听器
    # 坑：文本输入走 forEach 批量绑，下拉是单独绑的 —— 新加 select 极易漏绑，
    #     症状是「填了但 syncFromDom/提交时收集不到」，且不报错。
    old6c = """  var sr = d.querySelector('select.role'), sp = d.querySelector('select.power');
  sr.value = x.role || ''; sp.value = x.power || '';
  b.decision[i].role = sr.value; b.decision[i].power = sp.value;
  sr.addEventListener('change', function(){ b.decision[i].role = this.value; saveDraftSoon(); validate(); });
  sp.addEventListener('change', function(){ b.decision[i].power = this.value; saveDraftSoon(); });"""
    new6c = """  var sr = d.querySelector('select.role'), sp = d.querySelector('select.power'),
      sa = d.querySelector('select.attitude');   // P0 v0.2：新增态度下拉
  sr.value = x.role || ''; sp.value = x.power || '';
  if (sa) sa.value = x.attitude || '';
  b.decision[i].role = sr.value; b.decision[i].power = sp.value;
  if (sa) b.decision[i].attitude = sa.value;
  sr.addEventListener('change', function(){ b.decision[i].role = this.value; saveDraftSoon(); validate(); });
  sp.addEventListener('change', function(){ b.decision[i].power = this.value; saveDraftSoon(); });
  if (sa) sa.addEventListener('change', function(){ b.decision[i].attitude = this.value; saveDraftSoon(); validate(); });"""
    assert s.count(old6c) == 1, '⑥c attitude 锚点命中 %d 次' % s.count(old6c)
    s = s.replace(old6c, new6c, 1)
    assert "sa.addEventListener('change'" in s, 'attitude 未绑监听器 —— 填了会收集不到'
    changed.append('⑥c attitude 下拉绑监听器')

    # ⑦ chips / 只读 样式
    old7 = '\n<style>\n'
    assert s.count(old7) >= 1, '⑦ <style> 未找到'
    css = """\n<style>
    /* P0 v0.2 前端改造 */
    .chips{display:flex;flex-wrap:wrap;gap:6px}
    .chip{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border:1px solid #d6dced;
      border-radius:14px;font-size:12px;cursor:pointer;background:#fff;user-select:none}
    .chip:hover{border-color:#2f6bd8;color:#2f6bd8}
    .chip input{margin:0}
    .chip:has(input:checked){background:#2f6bd8;border-color:#2f6bd8;color:#fff}"""
    s = s.replace(old7, css, 1)
    changed.append('⑦ chips 样式')

    # 语法自检用标记
    s = s.replace('/* ════════════ 渲染：按活动类型 + 商机分块 ════════════ */',
                  '/* ════════════ 渲染：按活动类型 + 商机分块 ════════════ */\n/* %s */' % MARK, 1)

    print('  改动：')
    for c in changed:
        print('    %s' % c)

    if not apply:
        print('  [dry-run] 未写入')
        return True

    ts = datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
    bak = '%s.bak-p0v02fe-%s' % (path, ts)
    shutil.copy2(path, bak)
    io.open(path, 'w', encoding='utf-8').write(s)
    print('  ✅ 已写入 %s\n     备份 %s' % (path, bak))
    return True


def main():
    apply = '--apply' in sys.argv
    for t in TARGETS:
        p = os.path.join(BASE, t)
        print('══ %s' % t)
        if not os.path.exists(p):
            print('  ❌ 不存在')
            continue
        patch_file(p, apply)
        print()


if __name__ == '__main__':
    main()
