File size: 28,943 Bytes
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
ddb63ef
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
ddb63ef
 
d646dd7
 
 
 
ddb63ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddb63ef
 
d646dd7
ddb63ef
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddb63ef
 
 
d646dd7
 
 
ddb63ef
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
ddb63ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
ddb63ef
 
d646dd7
 
 
 
 
 
 
 
 
 
ddb63ef
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddb63ef
d646dd7
 
 
 
 
ddb63ef
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
"""
Task record persistence — data/tasks.json is the single source of truth.

- 进度分账(多电脑协同,后端聚合口径):
    local_count    本机累计完成段数(increment_task_completed 加它)
    synced_count   后端已确认的本机段数(水位,只前进不后退)
    backend_count  最近一次已知后端全局数(merge/flush 用后端值无条件替换)
    completed_count = backend_count + (local_count - synced_count)
      —— 派生显示值,字段名保留使 UI 零改动,所有写路径经 _recount() 重算。
- 迁移回填:旧数据没有上述字段时,local/synced/backend 一次性回填为
  旧 completed_count(目录扫描兜底)——历史不补报、显示连续;
  此后以持久化值为准,上传后删本地文件也不影响进度。
- Task metadata (id, name, description, total_required, etc.) is persisted.
- Backend tasks are merged into the local record, keeping local-only tasks.

- 本机任务(source="local"):
    任务页「+ 新建任务」创建,id 前缀 local-,不进后端任务表、不参与进度上报。
    * **仅创建者可见**:created_by 记创建时的身份("guest" 或用户名),
      filter_by_identity() 对本机任务按 created_by 过滤(缺失时对所有人可见,
      兼容手工写的数据);assigned_user 恒为 None,仍然算「公共任务」,
      这样 merge_backend_tasks 的账号撤销清除不会删到它(另有显式豁免)。
    * **进度只在本机**:local_count 累计,synced_count/backend_count 恒 0,
      pending_sync_tasks() 跳过本机任务 —— 服务端没有对应任务,上报必被拒。
    * **删除是真删记录**:remove_local_task() 整条移除(后端不会再推送它,
      不需要 hidden 墓碑);服务端任务仍走 mark_hidden()。
    * **名字即目录即上传项目名**:只允许字母/数字/汉字/下划线/连字符,
      保证 <RECORDING_DIR>/<name> 与 task_tag(name) 完全一致。
"""

from __future__ import annotations
import json
import os
import threading
import uuid
from datetime import datetime, timezone

from config import settings

_TASKS_FILE = os.path.join(settings.DATA_DIR, "tasks.json")

# ── 本机任务常量 ──────────────────────────────────────
LOCAL_TASK_PREFIX = "local-"
MAX_TASK_NAME_LEN = 64
MAX_TASK_DESCRIPTION_LEN = 500
MAX_TOTAL_REQUIRED = 100000

# Windows 保留设备名(不能作为目录名;大小写不敏感)
_WINDOWS_RESERVED_NAMES = frozenset(
    ["CON", "PRN", "AUX", "NUL"]
    + [f"COM{i}" for i in range(1, 10)]
    + [f"LPT{i}" for i in range(1, 10)]
)


class TaskValidationError(ValueError):
    """本机任务创建/改名被拒。code 供 UI 本地化,不把中文写进核心层。"""

    def __init__(self, code: str, detail: str = ""):
        super().__init__(code)
        self.code = code
        self.detail = detail

    def __str__(self) -> str:  # 日志/调试可读
        return f"{self.code}: {self.detail}" if self.detail else self.code


_lock = threading.RLock()  # reentrant — merge_backend_tasks calls save_tasks/load_tasks

_SEED_TASKS = [
    {
        "id": "task_001",
        "name": "cylinder_grasping",
        "description": "Grasp cylinder parts with right hand, record 16x16 tactile sensor data.",
        "status": "pending",
        "total_required": 1000,
        "assigned_at": "2026-08-07T09:00:00Z",
        "params": {"object": "cylinder", "hand": "right"},
    },
    {
        "id": "task_002",
        "name": "cube_placement",
        "description": "Place cube objects from point A to point B using both hands.",
        "status": "pending",
        "total_required": 500,
        "assigned_at": "2026-08-06T14:30:00Z",
        "params": {"object": "cube", "hand": "both"},
    },
    {
        "id": "task_003",
        "name": "valve_rotation",
        "description": "Rotate circular valve 90 degrees with single hand, record torque and tactile feedback.",
        "status": "pending",
        "total_required": 800,
        "assigned_at": "2026-08-05T11:00:00Z",
        "params": {"object": "valve", "hand": "right", "angle": 90},
    },
]


# ═══════════════════════════════════════════════════════════
#  Public API
# ═══════════════════════════════════════════════════════════

def load_tasks(identity: str | None = None) -> list[dict]:
    """Load all tasks. Filters out hidden tasks.

    completed_count 以持久化值为准(increment_task_completed 累计);
    文件中缺该字段(旧版本数据)时一次性按目录扫描回填并写回,
    此后删会话文件不再影响进度。

    Status is auto-computed: pending(0%), in_progress(>0%), completed(100%).

    排序: 按 assigned_at 从新到旧;null/空/解析失败排最后。

    identity(新增,默认 None 兼容旧调用):
      None   → 返回全部任务(含他人创建的本机任务,仅供内部/统计调用)
      "guest" → 仅公共任务(无 assigned_user 字段)+ 游客创建的本机任务
      用户名  → 公共任务 + assigned_user == 该用户的任务
               + 该用户创建的本机任务

    注意: 除迁移回填外不写回 tasks.json —— hidden 任务必须保留在文件中,
    否则后端再次推送时无法识别为已删除,任务会死而复生。
    """
    with _lock:
        tasks = _read_raw()
    need_backfill = False
    for t in tasks:
        if _ensure_sync_fields(t):
            need_backfill = True
        _recount(t)
        t["status"] = _compute_status(t)
    if need_backfill:
        with _lock:
            _write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
    visible = [t for t in tasks if not t.get("hidden")]
    visible = filter_by_identity(visible, identity)
    visible.sort(key=_assigned_at_key, reverse=True)  # 新→旧;null/空/非法排最后
    return visible


def filter_by_identity(tasks: list[dict], identity: str | None = None) -> list[dict]:
    """按身份过滤任务列表(纯函数,供 UI 对已有列表即时过滤)。

    identity 语义与 load_tasks 相同;返回新列表,不修改原列表。
    本机任务按 **created_by** 过滤(仅创建者可见);assigned_user 语义只
    用于服务端任务(本机任务 assigned_user 恒为 None)。created_by 缺失的
    本机任务视为「无主」,对所有身份可见(兼容手工写的 tasks.json)。
    """
    if identity is None:
        return list(tasks)
    out: list[dict] = []
    for t in tasks:
        if is_local_task(t):
            if _local_visible(t, identity):
                out.append(t)
            continue
        if identity == "guest":
            if not _assigned_user(t):
                out.append(t)
        elif not _assigned_user(t) or _assigned_user(t) == identity:
            out.append(t)
    return out


def save_tasks(tasks: list[dict]):
    """Persist task metadata,含 completed_count(录制完成次数持久化权威值)。"""
    clean = [dict(t) for t in tasks]
    with _lock:
        _write({"tasks": clean, "updated_at": datetime.now().isoformat()})


# ═══════════════════════════════════════════════════════════
#  本机任务(采集端自建)—— 创建 / 改名 / 删除 / 校验
# ═══════════════════════════════════════════════════════════

def is_local_task(task: dict) -> bool:
    """是否采集端自建任务。source 缺失时按 id 前缀兜底(双保险)。"""
    if not isinstance(task, dict):
        return False
    if task.get("source") == "local":
        return True
    return str(_tid(task) or "").startswith(LOCAL_TASK_PREFIX)


def new_local_task_id() -> str:
    """本机任务 id:local-YYYYmmdd-HHMMSS-<4hex>,与后端 UUID 不会相撞。"""
    return "local-{}-{:04x}".format(
        datetime.now().strftime("%Y%m%d-%H%M%S"), uuid.uuid4().int & 0xFFFF)


def validate_task_name(name: str, exclude_id: str | None = None
                       ) -> tuple[bool, str]:
    """校验本机任务名,返回 (ok, 错误码)。错误码由 UI 本地化。

    规则(保证 name == task_tag(name),即名字、录制目录、上传项目名一致):
      * 去首尾空白后非空、≤ 64 字符;
      * 只允许字母/数字/汉字/下划线/连字符(空格、点、斜杠等一律拒绝);
      * Windows 保留设备名(CON/PRN/AUX/NUL/COM1-9/LPT1-9)拒绝;
      * 与任何现有任务重名(大小写不敏感、含 hidden 与种子任务)拒绝。
    """
    with _lock:
        tasks = _read_raw()
    return _validate_name_locked(tasks, name, exclude_id)


def create_local_task(name: str, total_required: int, description: str = "",
                      created_by: str = "guest") -> dict:
    """新建本机任务并落盘,返回任务 dict。

    失败抛 TaskValidationError(code 见 validate_task_name / total_invalid /
    description_too_long)。任务仅创建者可见,进度只在本机累计。
    """
    total = _validate_total(total_required)
    desc = (description or "").strip()
    if len(desc) > MAX_TASK_DESCRIPTION_LEN:
        raise TaskValidationError("description_too_long")

    with _lock:
        tasks = _read_raw()
        ok, code = _validate_name_locked(tasks, name)
        if not ok:
            raise TaskValidationError(code)
        existing_ids = {_tid(t) for t in tasks}
        task_id = new_local_task_id()
        while task_id in existing_ids:
            task_id = new_local_task_id()
        task = {
            "id": task_id,
            "name": (name or "").strip(),
            "description": desc,
            "source": "local",
            "created_by": created_by or "guest",
            "status": "pending",
            "total_required": total,
            "assigned_at": datetime.now(timezone.utc).isoformat(),
            "assigned_user": None,
            # 进度分账三字段显式写 0:避开旧数据迁移回填(否则会去扫目录)
            "local_count": 0,
            "synced_count": 0,
            "backend_count": 0,
            "completed_count": 0,
        }
        tasks.append(task)
        _write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
    task["status"] = _compute_status(task)
    return dict(task)


def rename_local_task(task_id: str, new_name: str,
                      allow_with_data: bool = False) -> dict:
    """本机任务改名。只允许改本机任务,且默认拒绝「已有录制目录」的任务。

    改名不迁移录制目录:本地已有数据时改名字会让旧目录失去归属、且可能
    与上传队列里的路径脱节,因此默认拒绝(code="has_data");新名字的
    目录已存在(孤儿目录)也拒绝(code="name_dir_exists")。
    """
    with _lock:
        tasks = _read_raw()
        target = _find(tasks, task_id)
        if target is None:
            raise TaskValidationError("task_not_found")
        if not is_local_task(target):
            raise TaskValidationError("not_local")
        old_name = (target.get("name") or "").strip()
        clean = (new_name or "").strip()
        if clean == old_name:
            target["status"] = _compute_status(target)
            return dict(target)
        ok, code = _validate_name_locked(tasks, clean, exclude_id=task_id)
        if not ok:
            raise TaskValidationError(code)
        if not allow_with_data:
            old_dir = _local_task_dir(old_name)
            if old_dir and os.path.isdir(old_dir):
                raise TaskValidationError("has_data")
        new_dir = _local_task_dir(clean)
        if new_dir and os.path.isdir(new_dir):
            raise TaskValidationError("name_dir_exists")
        target["name"] = clean
        target["status"] = _compute_status(target)
        _write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
        return dict(target)


def remove_local_task(task_id: str) -> bool:
    """硬移除本机任务(不写 hidden 墓碑)。

    后端永远不会推送本机任务,不需要防复活;整条移除后同名任务可以重建。
    只对 source=local 的条目生效,服务端任务返回 False(仍走 mark_hidden)。
    """
    with _lock:
        tasks = _read_raw()
        target = _find(tasks, task_id)
        if target is None or not is_local_task(target):
            return False
        kept = [t for t in tasks if _tid(t) != task_id]
        _write({"tasks": kept, "updated_at": datetime.now().isoformat()})
        return True


def _local_visible(task: dict, identity: str | None) -> bool:
    """本机任务可见性:只有创建者身份可见;无 created_by 视为无主(所有人可见)。"""
    creator = task.get("created_by")
    if not creator:
        return True
    return creator == identity


def _local_task_dir(name: str) -> str:
    """本机任务录制目录(懒 import helpers,避免 task_record 顶层拉重依赖)。"""
    if not name:
        return ""
    from core.helpers import task_dir_of
    return task_dir_of(settings.RECORDING_DIR, name)


def _validate_name_locked(tasks: list[dict], name: str,
                          exclude_id: str | None = None) -> tuple[bool, str]:
    """名字校验(调用方持 _lock 并传入当前任务表)。"""
    clean = (name or "").strip()
    if not clean:
        return False, "name_empty"
    if len(clean) > MAX_TASK_NAME_LEN:
        return False, "name_too_long"
    if any(not (c.isalnum() or c in "_-") for c in clean):
        return False, "name_illegal"
    if clean.upper() in _WINDOWS_RESERVED_NAMES:
        return False, "name_reserved"
    key = clean.casefold()
    for t in tasks:
        if exclude_id and _tid(t) == exclude_id:
            continue
        if str(t.get("name") or "").casefold() == key:
            return False, "name_duplicate"
    return True, ""


def _validate_total(total_required) -> int:
    """目标段数:1 ~ MAX_TOTAL_REQUIRED 的整数(bool 不算)。"""
    if isinstance(total_required, bool) or not isinstance(total_required, int):
        raise TaskValidationError("total_invalid")
    if total_required < 1 or total_required > MAX_TOTAL_REQUIRED:
        raise TaskValidationError("total_invalid")
    return total_required


def merge_backend_tasks(backend_tasks: list[dict], view_scope: str | None = None) -> list[dict]:
    """Merge backend tasks into local record. Returns merged list.

    - Backend tasks update existing entries by id, new ones are added.
    - Local-only tasks are preserved.
    - Invalid/incomplete tasks are filtered out.
    - 同名去重: 同一 (name, assigned_user) 组出现多个可见任务时,只保留
      assigned_at 最新的一个(后端推送的优先胜出——"根据后台指令更新任务细节"),
      其余标 hidden。公共任务(均无 assigned_user)分组结果与旧版等价。
    - view_scope(新增): 当前会话身份。"guest"/None 不触发撤销清除;
      用户名时,本地 assigned_user == 该用户名、且本轮后端未返回的任务
      (已被后端撤销)从本地清除。游客轮询绝不触发撤销清除——防止把
      他人指派任务误删。本机任务(source=local)无论身份一律豁免:它们
      不在后端任务表里,按「后端没返回」清除会直接删掉自建任务。
    """
    with _lock:
        local = _read_raw()

    backend_ids = {_tid(t) for t in backend_tasks}

    # ── 阶段 0: 撤销清除(仅账号轮询;游客/无身份完全跳过)──
    if view_scope not in (None, "guest"):
        local = [
            lt for lt in local
            if is_local_task(lt)
            or not (_assigned_user(lt) == view_scope and _tid(lt) not in backend_ids)
        ]

    candidates: list[dict] = []

    # ── 阶段 1: 后端任务(按 id 合并,逻辑与现状一致)──
    for bt in backend_tasks:
        if not _is_valid(bt):
            continue
        bid = _tid(bt)
        local_match = _find(local, bid)
        backend_global = max(0, int(bt.get("completed_count") or 0))
        if local_match:
            # 保留本地覆盖字段
            for key in ("total_required", "params", "assigned_user"):
                if key in local_match and key not in bt:
                    bt[key] = local_match[key]
            # 进度分账:后端全局数权威;本机完成数/已同步水位沿用本地
            _ensure_sync_fields(local_match)
            bt["local_count"] = local_match["local_count"]
            bt["synced_count"] = local_match["synced_count"]
            bt["backend_count"] = backend_global
            # 保留删除标记:用户删过的任务不能被后端推送复活
            if local_match.get("hidden"):
                bt["hidden"] = True
        else:
            # 新任务:本机 0 段全同步;backend_count 取后端全局数
            # (保留 current_count 作新任务初值的旧语义)
            bt["local_count"] = 0
            bt["synced_count"] = 0
            bt["backend_count"] = backend_global
        candidates.append(bt)

    # ── 阶段 2: 本地独有任务 ──
    for lt in local:
        if not _is_valid(lt):
            continue
        if _tid(lt) not in backend_ids:
            candidates.append(dict(lt))

    # ── 阶段 3: 同名去重((name, assigned_user) 墓碑)──
    groups: dict[tuple, list[dict]] = {}
    for c in candidates:
        groups.setdefault((c["name"], _assigned_user(c)), []).append(c)
    for group in groups.values():
        visible = [g for g in group if not g.get("hidden")]
        if len(visible) <= 1:
            continue  # 无碰撞(含整组已 hidden)→ 不动
        backend_visible = [v for v in visible if _tid(v) in backend_ids]
        pool = backend_visible if backend_visible else visible
        winner = max(pool, key=_assigned_at_key)
        for g in visible:
            if g is not winner:
                g["hidden"] = True  # 持久化墓碑,防止复活

    for t in candidates:
        _ensure_sync_fields(t)
        _recount(t)
        t["status"] = _compute_status(t)
    save_tasks(candidates)
    return load_tasks()


def mark_hidden(task_id: str):
    """Mark a task as hidden so it no longer appears in the UI."""
    with _lock:
        tasks = _read_raw()
        for t in tasks:
            if _tid(t) == task_id:
                t["hidden"] = True
                break
        _write({"tasks": tasks, "updated_at": datetime.now().isoformat()})


def refresh_progress() -> list[dict]:
    """Reload task list with persisted completion counts."""
    return load_tasks()


def increment_task_completed(task_name: str, task_id: str | None = None,
                             assigned_user: str | None = None) -> dict | None:
    """一次录制完成:把对应任务的录制完成次数持久化 +1,返回更新后的任务。

    匹配优先级:
      1) task_id 提供时按 id 精确匹配(须 name 一致)
      2) (name, assigned_user) 精确匹配(assigned_user 为 None 时匹配公共任务)
      3) 同名条目唯一时回退唯一匹配(兼容旧数据/手动临时任务名)
      4) 多名同名且身份不匹配 → 返回 None 不计数(跨身份串扰防护)
    task_id/assigned_user 由调用方从所选任务(_current_task)提供,
    而非会话身份——避免"登录用户录制公共任务却按用户名匹配"的错配。
    任务不在 tasks.json(手动临时任务名)时不计数,返回 None。
    会话目录随后被删除也不影响该计数——进度以持久化值为准。
    """
    if not task_name:
        return None
    with _lock:
        tasks = _read_raw()
        target = None
        if task_id:
            for t in tasks:
                if _tid(t) == task_id and t.get("name") == task_name:
                    target = t
                    break
        if target is None:
            for t in tasks:
                if (t.get("name") == task_name
                        and _assigned_user(t) == (assigned_user or None)):
                    target = t
                    break
        if target is None:
            matches = [t for t in tasks if t.get("name") == task_name]
            if len(matches) == 1:
                target = matches[0]
        if target is None:
            return None
        _ensure_sync_fields(target)
        target["local_count"] = int(target.get("local_count", 0) or 0) + 1
        _recount(target)
        target["status"] = _compute_status(target)
        _write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
        return dict(target)


def pending_sync_tasks() -> list[dict]:
    """所有 local_count > synced_count 的任务快照(含 hidden,不按身份过滤——
    身份不符由服务端 401/403 处理,跳过本轮即可)。

    纯读路径:直接 _read_raw(),跳过未迁移记录(无 local_count),
    绝不触发迁移写盘(flush 只上报,不改变本地计数语义)。
    本机任务(source=local)显式跳过:后端没有对应任务,上报必被拒 400。
    """
    with _lock:
        tasks = _read_raw()
    out = []
    for t in tasks:
        if is_local_task(t):
            continue
        local = t.get("local_count")
        if not isinstance(local, int):
            continue
        synced = int(t.get("synced_count", 0) or 0)
        tid = _tid(t)
        if local > synced and tid:
            out.append({"id": tid, "name": t.get("name", ""),
                        "local_count": local, "synced_count": synced})
    return out


def mark_synced(task_id: str, synced_count: int, backend_count: int) -> dict | None:
    """flush 成功后回写同步状态。

    synced_count 必须是「快照水位 + 本次增量」(不是当前 local_count——
    快照与响应之间 UI 线程可能又录了一段);水位只前进不后退;
    backend_count 用响应值无条件替换(服务端权威,迁移基线可能高于真实
    全局数,不能 max-前向)。返回更新后的任务。
    """
    with _lock:
        tasks = _read_raw()
        target = _find(tasks, task_id)
        if target is None or not isinstance(target.get("local_count"), int):
            return None
        if synced_count > (int(target.get("synced_count", 0) or 0)):
            target["synced_count"] = synced_count
        target["backend_count"] = max(0, int(backend_count or 0))
        _recount(target)
        target["status"] = _compute_status(target)
        _write({"tasks": tasks, "updated_at": datetime.now().isoformat()})
        return dict(target)


def task_by_id(task_id: str) -> dict | None:
    tasks = load_tasks()
    return _find(tasks, task_id)


# ═══════════════════════════════════════════════════════════
#  Internal
# ═══════════════════════════════════════════════════════════

def _read_raw() -> list[dict]:
    if not os.path.isfile(_TASKS_FILE):
        _init_seed()
    try:
        with open(_TASKS_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data.get("tasks", [])
    except (json.JSONDecodeError, OSError):
        _init_seed()
        return [dict(t) for t in _SEED_TASKS]


def _write(data: dict):
    os.makedirs(os.path.dirname(_TASKS_FILE), exist_ok=True)
    tmp = _TASKS_FILE + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp, _TASKS_FILE)


def _init_seed():
    _write({"tasks": [dict(t) for t in _SEED_TASKS], "updated_at": datetime.now().isoformat()})


def _tid(task: dict) -> str:
    return task.get("id", task.get("task_id", ""))


def _assigned_user(task: dict):
    """任务的指派用户;null/空串/缺失统一归一化为 None(= 公共任务)。"""
    return task.get("assigned_user") or None


def _assigned_at_key(task: dict) -> tuple:
    """排序键: 带有效时间的排前,null/空/解析失败排后。
    (0, raw) 与 (1, ts) 首元素不同,跨类不会比较第二元素。"""
    raw = task.get("assigned_at") or ""
    if not raw:
        return (0, "")
    try:
        dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
        return (1, dt.timestamp())
    except ValueError:
        return (0, raw)  # 解析失败: 排最后,按原始字符串保序


def _find(tasks: list[dict], task_id: str) -> dict | None:
    for t in tasks:
        if _tid(t) == task_id:
            return t
    return None


def _is_valid(task: dict) -> bool:
    name = task.get("name", task.get("task_name", ""))
    tid = task.get("id", task.get("task_id", ""))
    return bool(name and name.strip()) and bool(tid and tid.strip()) and task.get("total_required", 0) > 0


def _compute_status(task: dict) -> str:
    total = task.get("total_required", 0)
    completed = task.get("completed_count", 0)
    if total <= 0:
        return "pending"
    if completed >= total:
        return "completed"
    if completed > 0:
        return "in_progress"
    return "pending"


def _count_with_fallback(task: dict) -> int:
    """任务的录制完成次数:优先持久化值,缺字段(旧版本数据)回退目录扫描。"""
    cur = task.get("completed_count")
    if isinstance(cur, int) and cur >= 0:
        return cur
    return _count_sessions(task.get("name", ""))


def _count_sessions(task_name: str) -> int:
    """扫描录制目录统计 episode 数 —— 仅用于旧数据迁移回填初值,非进度权威来源。

    v1.1.0 池化布局:list_task_episodes(videos/data 文件组扫描,与
    episodes 每段文件一致——删除时两者同步移除);旧格式任务目录
    回退按会话子目录计数。
    """
    if not task_name:
        return 0
    task_dir = os.path.join(settings.RECORDING_DIR, task_name)
    if not os.path.isdir(task_dir):
        return 0
    try:
        from core.helpers import detect_session_format, list_task_episodes
        if detect_session_format(task_dir) == "pooled":
            return len(list_task_episodes(task_dir))
    except ImportError:
        pass
    count = 0
    try:
        for entry in os.listdir(task_dir):
            ses_dir = os.path.join(task_dir, entry)
            if not os.path.isdir(ses_dir):
                continue
            if os.path.isfile(os.path.join(ses_dir, "meta", "info.json")):
                count += 1
    except OSError:
        pass
    return count


def _ensure_sync_fields(task: dict) -> bool:
    """补全 local/synced/backend 三字段(迁移);返回是否发生迁移(需写回)。

    迁移(幂等,字段存在且为合法 int 即跳过):
      local_count   = 旧 completed_count(目录扫描兜底)
      synced_count  = local_count(历史不补报,避免重复计数)
      backend_count = local_count(历史基线保显示连续;首次 merge/flush
                      用真实全局数无条件替换)
    """
    changed = False
    local = task.get("local_count")
    if not isinstance(local, int) or local < 0:
        task["local_count"] = _count_with_fallback(task)
        changed = True
    synced = task.get("synced_count")
    if not isinstance(synced, int) or synced < 0:
        task["synced_count"] = task["local_count"]
        changed = True
    backend = task.get("backend_count")
    if not isinstance(backend, int) or backend < 0:
        task["backend_count"] = task["local_count"]
        changed = True
    return changed


def _recount(task: dict) -> int:
    """显示值 = backend_count + (local_count - synced_count),写回 completed_count。

    防御:synced 不超 local(异常水位按已同步处理,宁可少显示不可多显示)。
    """
    local = int(task.get("local_count", 0) or 0)
    synced = min(int(task.get("synced_count", 0) or 0), local)
    task["completed_count"] = int(task.get("backend_count", 0) or 0) + (local - synced)
    return task["completed_count"]