"""
测试 prompt 转火山「图片N/视频N/音频N」格式 — v0.19.1+

火山模型无法理解文件名/asset id，必须用「素材类型+序号」指代（官方文档 FAQ Q3）。
本测试文件覆盖：
  单元测试：纯函数 _format_prompt_for_ark
  集成测试：video_generate_view 端到端（含 counter 对齐关键回归）
"""
import os
import sys
import django
from unittest import mock

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
django.setup()

import unittest
from django.test import TestCase, override_settings
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from apps.accounts.models import Team
from apps.generation.models import QuotaConfig, AssetGroup, Asset, GenerationRecord
from apps.generation.views import _format_prompt_for_ark

User = get_user_model()


# ────────────────────────────────────────────────
# 单元测试：纯函数 _format_prompt_for_ark
# ────────────────────────────────────────────────

class TestFormatPromptForArk(unittest.TestCase):
    """覆盖各种 label 替换场景（字符串级别）。"""

    def test_basic_replacement(self):
        """@label 替换为 placeholder。"""
        out = _format_prompt_for_ark('@碧碧.jpg 是碧儿', [('碧碧.jpg', '图片1')])
        self.assertEqual(out, '图片1 是碧儿')

    def test_multi_type_independent_counters(self):
        """图片/视频/音频各自独立编号。"""
        pairs = [
            ('img1.jpg', '图片1'),
            ('video1.mp4', '视频1'),
            ('audio1.mp3', '音频1'),
        ]
        out = _format_prompt_for_ark('用 @img1.jpg @video1.mp4 和 @audio1.mp3', pairs)
        self.assertEqual(out, '用 图片1 视频1 和 音频1')

    def test_same_label_multiple_at_signs(self):
        """同一 label 在 prompt 里 @ 多次，全部替换成同一 placeholder（str.replace 全局）。"""
        out = _format_prompt_for_ark('@foo 然后 @foo 再 @foo', [('foo', '图片1')])
        self.assertEqual(out, '图片1 然后 图片1 再 图片1')

    def test_substring_conflict_long_first(self):
        """当存在子串关系（'碧' 是 '碧碧' 的子串），长 label 必须先替换。"""
        # 模拟调用方已经按长度降序传入
        pairs = [('碧碧', '图片2'), ('碧', '图片1')]
        out = _format_prompt_for_ark('@碧碧 和 @碧 是姐妹', pairs)
        self.assertEqual(out, '图片2 和 图片1 是姐妹')

    def test_label_with_regex_metachars(self):
        """label 含正则元字符（[ ] + . * ? 等），str.replace 不当正则处理。"""
        pairs = [
            ('[test].png', '图片1'),
            ('a+b.png', '图片2'),
            ('a.b*.png', '图片3'),
        ]
        prompt = '@[test].png 和 @a+b.png 还有 @a.b*.png'
        out = _format_prompt_for_ark(prompt, pairs)
        self.assertEqual(out, '图片1 和 图片2 还有 图片3')

    def test_empty_mapping(self):
        """无 @ 素材的 prompt 原样返回。"""
        out = _format_prompt_for_ark('今天天气真好', [])
        self.assertEqual(out, '今天天气真好')

    def test_label_in_mapping_not_in_prompt(self):
        """mapping 里有 label 但 prompt 里没 @ → 不动。"""
        out = _format_prompt_for_ark('一段普通文字', [('foo.jpg', '图片1')])
        self.assertEqual(out, '一段普通文字')

    def test_chinese_punctuation_around_label(self):
        """中文标点不影响替换。"""
        out = _format_prompt_for_ark('@碧碧.jpg，说："你好。"', [('碧碧.jpg', '图片1')])
        self.assertEqual(out, '图片1，说："你好。"')

    def test_empty_label_skipped(self):
        """label 为空字符串时跳过，不崩溃。"""
        out = _format_prompt_for_ark('@real.jpg 内容', [('', '图片0'), ('real.jpg', '图片1')])
        self.assertEqual(out, '图片1 内容')


# ────────────────────────────────────────────────
# 集成测试：video_generate_view
# ────────────────────────────────────────────────

@override_settings(SEEDANCE_ENABLED=True, ARK_API_KEY='fake-test-key')
class TestVideoGenerateArkPrompt(TestCase):
    """经 POST /api/v1/video/generate 验证 prompt 转换 + DB 原文保留 + counter 对齐。"""

    def setUp(self):
        QuotaConfig.objects.get_or_create(pk=1)
        self.team = Team.objects.create(
            name='test-ark-prompt',
            is_active=True,
            monthly_spending_limit=10000,
            markup_percentage=0,
            balance=10000,
            frozen_amount=0,
        )
        self.user = User.objects.create_user(
            username='ark_prompt_user',
            email='arkprompt@example.com',
            password='testpass123',
            team=self.team,
            spending_limit=-1,
            daily_generation_limit=-1,
            monthly_generation_limit=-1,
        )
        self.client = APIClient()
        self.client.force_authenticate(user=self.user)

        # 建两个 local asset 方便多场景复用
        self.group_a = AssetGroup.objects.create(
            team=self.team, remote_group_id='group-fake-a', name='角色A',
        )
        self.asset_bibi = Asset.objects.create(
            group=self.group_a, remote_asset_id='asset-fake-bibi', name='碧碧.jpg',
            url='https://fake/bibi.jpg', asset_type='Image', status='active',
        )
        self.asset_bubu = Asset.objects.create(
            group=self.group_a, remote_asset_id='asset-fake-bubu', name='布布.jpg',
            url='https://fake/bubu.jpg', asset_type='Image', status='active',
        )

    def _post_generate(self, prompt, references):
        return self.client.post('/api/v1/video/generate', {
            'prompt': prompt,
            'mode': 'universal',
            'model': 'seedance_2.0',
            'aspect_ratio': '9:16',
            'duration': 5,
            'resolution': '720p',
            'references': references,
        }, format='json')

    @mock.patch('apps.generation.tasks.poll_video_task')
    @mock.patch('apps.generation.views.create_task')
    def test_view_converts_prompt_for_local_assets(self, mock_create_task, mock_poll):
        """prompt 里两个 @local 素材 → 发给火山的 prompt 变成「图片1/图片2」。"""
        mock_create_task.return_value = {'id': 'ark-mock-1'}
        prompt = '@碧碧.jpg 是碧儿，@布布.jpg 是步若'
        resp = self._post_generate(prompt, [
            {'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': '碧碧.jpg'},
            {'url': f'asset://local-{self.asset_bubu.id}', 'type': 'image', 'label': '布布.jpg'},
        ])
        self.assertEqual(resp.status_code, 202, resp.content)
        self.assertTrue(mock_create_task.called, 'create_task must be called')
        sent_prompt = mock_create_task.call_args.kwargs['prompt']
        self.assertEqual(sent_prompt, '图片1 是碧儿，图片2 是步若')

    @mock.patch('apps.generation.tasks.poll_video_task')
    @mock.patch('apps.generation.views.create_task')
    def test_view_db_prompt_unchanged_for_reedit(self, mock_create_task, mock_poll):
        """DB.prompt 必须保留用户原文（含 @xxx.jpg），reEdit 才能重建带缩略图的标签。"""
        mock_create_task.return_value = {'id': 'ark-mock-2'}
        prompt = '@碧碧.jpg 走过来'
        resp = self._post_generate(prompt, [
            {'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': '碧碧.jpg'},
        ])
        self.assertEqual(resp.status_code, 202, resp.content)
        rec = GenerationRecord.objects.filter(user=self.user).order_by('-id').first()
        self.assertIsNotNone(rec)
        self.assertEqual(rec.prompt, prompt)  # 原文，不含 '图片1'
        self.assertIn('@碧碧.jpg', rec.prompt)
        self.assertNotIn('图片1', rec.prompt)

    @mock.patch('apps.generation.tasks.poll_video_task')
    @mock.patch('apps.generation.views.create_task')
    def test_legacy_group_url_skips_replacement(self, mock_create_task, mock_poll):
        """asset://group-{id} 老路径：counter 递增但不登记 label，WARNING 日志，@组名 原样留在 prompt。"""
        mock_create_task.return_value = {'id': 'ark-mock-3'}
        prompt = '@角色A 做动作'
        with self.assertLogs('apps.generation.views', level='WARNING') as cm:
            resp = self._post_generate(prompt, [
                {'url': f'asset://group-{self.group_a.id}', 'type': 'image', 'label': '角色A'},
            ])
        self.assertEqual(resp.status_code, 202, resp.content)
        sent_prompt = mock_create_task.call_args.kwargs['prompt']
        self.assertEqual(sent_prompt, prompt)  # 未替换
        # 验证 WARNING log
        self.assertTrue(any('legacy asset://group-' in line for line in cm.output),
                        f'expected legacy warning, got: {cm.output}')

    @mock.patch('apps.generation.tasks.poll_video_task')
    @mock.patch('apps.generation.views.create_task')
    def test_counter_alignment_with_mixed_local_and_group(self, mock_create_task, mock_poll):
        """关键回归：group 展开 2 张图后，紧跟的 local asset 的 label 必须映射到「图片3」不是「图片1」。"""
        mock_create_task.return_value = {'id': 'ark-mock-4'}
        prompt = '@foo 是主角'
        resp = self._post_generate(prompt, [
            {'url': f'asset://group-{self.group_a.id}', 'type': 'image', 'label': '角色A'},  # 展开 2 张图
            {'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': 'foo'},
        ])
        self.assertEqual(resp.status_code, 202, resp.content)
        sent_prompt = mock_create_task.call_args.kwargs['prompt']
        # foo 对应第 3 张 image（group 两张在前）
        self.assertEqual(sent_prompt, '图片3 是主角')
        # content_items 验证长度
        sent_content_items = mock_create_task.call_args.kwargs['content_items']
        image_items = [it for it in sent_content_items if it['type'] == 'image_url']
        self.assertEqual(len(image_items), 3)

    @mock.patch('apps.generation.tasks.poll_video_task')
    @mock.patch('apps.generation.views.create_task')
    def test_counter_alignment_mixed_types(self, mock_create_task, mock_poll):
        """图片/音频独立计数 — 图片序号不因音频夹在中间而跳变。"""
        mock_create_task.return_value = {'id': 'ark-mock-5'}
        # 新建一个 audio asset
        asset_audio = Asset.objects.create(
            group=self.group_a, remote_asset_id='asset-fake-audio', name='speech.mp3',
            url='https://fake/speech.mp3', asset_type='Audio', status='active',
        )
        prompt = '@碧碧.jpg 说 @speech.mp3 的话，@布布.jpg 听'
        resp = self._post_generate(prompt, [
            {'url': f'asset://local-{self.asset_bibi.id}', 'type': 'image', 'label': '碧碧.jpg'},
            {'url': f'asset://local-{asset_audio.id}', 'type': 'audio', 'label': 'speech.mp3'},
            {'url': f'asset://local-{self.asset_bubu.id}', 'type': 'image', 'label': '布布.jpg'},
        ])
        self.assertEqual(resp.status_code, 202, resp.content)
        sent_prompt = mock_create_task.call_args.kwargs['prompt']
        # 碧碧=图片1, speech=音频1, 布布=图片2（图片/音频独立计数）
        self.assertEqual(sent_prompt, '图片1 说 音频1 的话，图片2 听')


if __name__ == '__main__':
    unittest.main(verbosity=2)
