/**
 * Douyin publish — 8-phase pipeline for scheduling video posts.
 *
 * Phases:
 *   1. upload auth v5 credentials
 *   2. Apply TOS upload URL
 *   3. TOS multipart upload
 *   4. Cover upload (optional, via ImageX)
 *   5. Enable video
 *   6. Poll transcode
 *   7. Content safety check
 *   8. create_v2 publish
 */
import * as fs from 'node:fs';
import * as path from 'node:path';
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
import { getUploadAuthV5Credentials, applyVideoUploadInner, commitVideoUploadInner } from './_shared/vod-upload.js';
import { tosUpload } from './_shared/tos-upload.js';
import { imagexUpload } from './_shared/imagex-upload.js';
import { browserFetch } from './_shared/browser-fetch.js';
import { requireObjectEvaluateResult } from './_shared/evaluate-result.js';
import { generateCreationId } from './_shared/creation-id.js';
import { validateTiming, toUnixSeconds } from './_shared/timing.js';
import { parseTextExtra, extractHashtagNames } from './_shared/text-extra.js';
const VISIBILITY_MAP = {
    public: 0,
    friends: 1,
    private: 2,
};
const IMAGEX_BASE = 'https://imagex.bytedanceapi.com';
const IMAGEX_SERVICE_ID = '1147';
const DEVICE_PARAMS = 'aid=1128&cookie_enabled=true&screen_width=1512&screen_height=982&browser_language=zh-CN&browser_platform=MacIntel&browser_name=Mozilla&browser_online=true&timezone_name=Asia%2FTokyo&support_h265=1';
const DEFAULT_COVER_TOOLS_INFO = JSON.stringify({
    video_cover_source: 2,
    cover_timestamp: 0,
    recommend_timestamp: 0,
    is_cover_edit: 0,
    is_cover_template: 0,
    cover_template_id: '',
    is_text_template: 0,
    text_template_id: '',
    text_template_content: '',
    is_text: 0,
    text_num: 0,
    text_content: '',
    is_use_sticker: 0,
    sticker_id: '',
    is_use_filter: 0,
    filter_id: '',
    is_cover_modify: 0,
    to_status: 0,
    cover_type: 0,
    initial_cover_uri: '',
    cut_coordinate: '',
});
function isFastDetectRetryable(error) {
    const message = error instanceof Error ? error.message : String(error);
    return message.includes('post_assistant/fast_detect') && (message.includes('Empty response') || message.includes('404') || message.includes('Not Found') || message.includes('Timeout') || message.includes('timed out') || message.includes('Failed to fetch'));
}
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
function throwIfImagexError(action, payload) {
    const error = payload?.ResponseMetadata?.Error ?? payload?.Error;
    if (error) {
        throw new CommandExecutionError(`${action}失败: ${JSON.stringify(error)}`);
    }
}
async function tryFastDetectFetch(page, method, url, options) {
    let lastError;
    for (let attempt = 1; attempt <= 3; attempt += 1) {
        try {
            return { ok: true, value: await browserFetch(page, method, url, options) };
        } catch (error) {
            if (!isFastDetectRetryable(error)) {
                throw error;
            }
            lastError = error;
            if (attempt < 3) {
                await sleep(500 * attempt);
            }
        }
    }
    return { ok: false, error: lastError };
}
cli({
    site: 'douyin',
    name: 'publish',
    access: 'write',
    description: '定时发布视频到抖音（必须设置 2h ~ 14天后的发布时间）',
    domain: 'creator.douyin.com',
    strategy: Strategy.COOKIE,
    args: [
        { name: 'video', required: true, positional: true, help: '视频文件路径' },
        { name: 'title', required: true, help: '视频标题（≤30字）' },
        { name: 'schedule', required: true, help: '定时发布时间（ISO8601 或 Unix 秒，2h ~ 14天后）' },
        { name: 'caption', default: '', help: '正文内容（≤1000字，支持 #话题）' },
        { name: 'cover', default: '', help: '封面图片路径（不提供时使用视频截帧）' },
        { name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] },
        { name: 'allow_download', type: 'bool', default: false, help: '允许下载' },
        { name: 'collection', default: '', help: '合集 ID' },
        { name: 'activity', default: '', help: '活动 ID' },
        { name: 'poi_id', default: '', help: '地理位置 ID' },
        { name: 'poi_name', default: '', help: '地理位置名称' },
        { name: 'hotspot', default: '', help: '关联热点词' },
        { name: 'no_safety_check', type: 'bool', default: false, help: '跳过内容安全检测' },
        { name: 'sync_toutiao', type: 'bool', default: false, help: '同步发布到头条' },
    ],
    columns: ['status', 'aweme_id', 'url', 'publish_time'],
    func: async (page, kwargs) => {
        // ── Fail-fast validation ────────────────────────────────────────────
        const videoPath = path.resolve(kwargs.video);
        if (!fs.existsSync(videoPath)) {
            throw new ArgumentError(`视频文件不存在: ${videoPath}`);
        }
        const ext = path.extname(videoPath).toLowerCase();
        if (!['.mp4', '.mov', '.avi', '.webm'].includes(ext)) {
            throw new ArgumentError(`不支持的视频格式: ${ext}（支持 mp4/mov/avi/webm）`);
        }
        const fileSize = fs.statSync(videoPath).size;
        const title = kwargs.title;
        if (title.length > 30) {
            throw new ArgumentError('标题不能超过 30 字');
        }
        const caption = kwargs.caption || '';
        if (caption.length > 1000) {
            throw new ArgumentError('正文不能超过 1000 字');
        }
        const timingTs = toUnixSeconds(kwargs.schedule);
        validateTiming(timingTs);
        const visibilityType = VISIBILITY_MAP[kwargs.visibility] ?? 0;
        const coverPath = kwargs.cover;
        if (coverPath) {
            if (!fs.existsSync(path.resolve(coverPath))) {
                throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`);
            }
        }
        // ── Phase 1: upload credentials ────────────────────────────────────
        const credentials = await getUploadAuthV5Credentials(page);
        // ── Phase 2: Apply TOS upload URL ───────────────────────────────────
        const tosUploadInfo = await applyVideoUploadInner(fileSize, credentials);
        let coverUri = '';
        let coverWidth = 720;
        let coverHeight = 1280;
        // ── Phase 3: TOS upload ─────────────────────────────────────────────
        await tosUpload({
            filePath: videoPath,
            uploadInfo: tosUploadInfo,
            credentials,
            onProgress: (uploaded, total) => {
                const pct = Math.round((uploaded / total) * 100);
                process.stderr.write(`\r  上传进度: ${pct}%`);
            },
        });
        process.stderr.write('\n');
        process.stderr.write('  提交上传...\n');
        const committedVideo = await commitVideoUploadInner(tosUploadInfo, credentials);
        const videoId = committedVideo.video_id;
        process.stderr.write(`  上传已提交: ${videoId}\n`);
        coverWidth = committedVideo.width || coverWidth;
        coverHeight = committedVideo.height || coverHeight;
        if (!coverUri && committedVideo.poster_uri) {
            coverUri = committedVideo.poster_uri;
        }
        // ── Phase 4: Cover upload (optional) ────────────────────────────────
        if (kwargs.cover) {
            const resolvedCoverPath = path.resolve(kwargs.cover);
            // 4A: Apply ImageX upload
            const applyUrl = `${IMAGEX_BASE}/?Action=ApplyImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01&UploadNum=1`;
            const applyJs = `fetch(${JSON.stringify(applyUrl)}, { credentials: 'include' }).then(r => r.json())`;
            const applyRes = requireObjectEvaluateResult(await page.evaluate(applyJs), '抖音封面申请上传地址响应异常');
            throwIfImagexError('抖音封面申请上传地址', applyRes);
            const imgStoreInfo = applyRes.Result?.UploadAddress?.StoreInfos?.[0];
            if (!imgStoreInfo?.UploadHost || !imgStoreInfo?.StoreUri) {
                throw new CommandExecutionError(`抖音封面申请上传地址响应缺少 UploadHost/StoreUri: ${JSON.stringify(applyRes).slice(0, 500)}`);
            }
            const imgUploadUrl = `https://${imgStoreInfo.UploadHost}/${imgStoreInfo.StoreUri}`;
            // 4B: Upload image
            const coverStoreUri = await imagexUpload(resolvedCoverPath, {
                upload_url: imgUploadUrl,
                store_uri: imgStoreInfo.StoreUri,
            });
            // 4C: Commit ImageX upload
            const commitUrl = `${IMAGEX_BASE}/?Action=CommitImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01`;
            const commitBody = JSON.stringify({ SuccessObjKeys: [coverStoreUri] });
            const commitJs = `
        fetch(${JSON.stringify(commitUrl)}, {
          method: 'POST',
          credentials: 'include',
          headers: { 'Content-Type': 'application/json' },
          body: ${JSON.stringify(commitBody)}
        }).then(r => r.json())
      `;
            const commitRes = requireObjectEvaluateResult(await page.evaluate(commitJs), '抖音封面提交上传响应异常');
            throwIfImagexError('抖音封面提交上传', commitRes);
            coverUri = coverStoreUri;
        }
        // The gateway upload flow returns a committed VOD upload result; the legacy
        // enable/transend endpoints can hang for that flow, so create_v2 consumes
        // the committed video_id and poster metadata directly.
        // ── Phase 7: Content safety check ───────────────────────────────────
        if (!kwargs.no_safety_check) {
            const safetyUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/pre_check';
            const safetyBody = {
                video_id: videoId,
                title,
                desc: caption,
            };
            const preCheck = await tryFastDetectFetch(page, 'POST', safetyUrl, { body: safetyBody });
            if (!preCheck.ok) {
                process.stderr.write('  内容安全预检接口无响应，继续轮询检测结果。\n');
            }
            const pollUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/poll';
            const deadline = Date.now() + 30_000;
            let safetyPassed = false;
            let pollUnavailableCount = 0;
            while (Date.now() < deadline) {
                const poll = await tryFastDetectFetch(page, 'POST', pollUrl, { body: safetyBody });
                if (!poll.ok) {
                    pollUnavailableCount += 1;
                    if (!preCheck.ok && pollUnavailableCount >= 3) {
                        break;
                    }
                    await sleep(2000);
                    continue;
                }
                pollUnavailableCount = 0;
                const pollRes = poll.value;
                if (pollRes.status === 0 || (pollRes.has_done === true && pollRes.detect_result?.reason_code === 0 && (pollRes.detect_list?.length ?? 0) === 0)) {
                    safetyPassed = true;
                    break;
                }
                if (pollRes.status === 1) {
                    throw new CommandExecutionError('内容安全检测不通过，请修改后重试', '使用 --no_safety_check 跳过');
                }
                await sleep(2000);
            }
            if (!safetyPassed) {
                if (!preCheck.ok && pollUnavailableCount >= 3) {
                    process.stderr.write('  内容安全预检持续无响应，跳过本地预检，交由 create_v2 后的平台审核。\n');
                }
                else {
                    throw new CommandExecutionError('内容安全检测超时（30s），请稍后重试', '如确认要跳过本地预检，可使用 --no_safety_check；提交后仍会走抖音平台审核');
                }
            }
        }
        // ── Phase 8: create_v2 publish ──────────────────────────────────────
        const hashtagNames = extractHashtagNames(caption);
        const hashtags = [];
        let searchFrom = 0;
        for (const name of hashtagNames) {
            const idx = caption.indexOf(`#${name}`, searchFrom);
            if (idx === -1)
                continue;
            hashtags.push({ name, id: 0, start: idx, end: idx + name.length + 1 });
            searchFrom = idx + name.length + 1;
        }
        const publishText = caption ? `${title} ${caption}` : title;
        const captionOffset = caption ? title.length + 1 : 0;
        const textExtraArr = parseTextExtra(publishText, hashtags.map((hashtag) => ({
            ...hashtag,
            start: hashtag.start + captionOffset,
            end: hashtag.end + captionOffset,
        })));
        const publishBody = {
            item: {
                common: {
                    text: publishText,
                    caption: caption,
                    item_title: title,
                    activity: JSON.stringify(kwargs.activity ? [kwargs.activity] : []),
                    text_extra: JSON.stringify(textExtraArr),
                    challenges: '[]',
                    mentions: '[]',
                    hashtag_source: '',
                    hot_sentence: kwargs.hotspot || '',
                    interaction_stickers: '[]',
                    visibility_type: visibilityType,
                    download: kwargs.allow_download ? 1 : 0,
                    timing: timingTs,
                    creation_id: generateCreationId(),
                    media_type: 4,
                    video_id: videoId,
                    music_source: 0,
                    music_id: null,
                    ...(kwargs.poi_id
                        ? { poi_id: kwargs.poi_id, poi_name: kwargs.poi_name }
                        : {}),
                },
                cover: {
                    poster: coverUri,
                    custom_cover_image_height: coverHeight,
                    custom_cover_image_width: coverWidth,
                    poster_delay: 0,
                    cover_tools_info: DEFAULT_COVER_TOOLS_INFO,
                    cover_tools_extend_info: '{}',
                },
                mix: kwargs.collection
                    ? { mix_id: kwargs.collection, mix_order: 0 }
                    : {},
                chapter: {
                    chapter: JSON.stringify({
                        chapter_abstract: '',
                        chapter_details: [],
                        chapter_type: 0,
                    }),
                },
                anchor: {},
                sync: {
                    should_sync: false,
                    sync_to_toutiao: kwargs.sync_toutiao ? 1 : 0,
                },
                open_platform: {},
                assistant: { is_preview: 0, is_post_assistant: 1 },
                declare: { user_declare_info: '{}' },
            },
        };
        const publishUrl = `https://creator.douyin.com/web/api/media/aweme/create_v2/?read_aid=2906&${DEVICE_PARAMS}`;
        process.stderr.write('  创建定时发布...\n');
        const publishRes = (await browserFetch(page, 'POST', publishUrl, {
            body: publishBody,
        }));
        const awemeId = publishRes.aweme_id ?? publishRes.item_id;
        if (!awemeId) {
            throw new CommandExecutionError(`发布成功但未返回 aweme_id/item_id: ${JSON.stringify(publishRes)}`);
        }
        const url = `https://www.douyin.com/video/${awemeId}`;
        const publishTimeStr = new Date(timingTs * 1000).toLocaleString('zh-CN', {
            timeZone: 'Asia/Tokyo',
        });
        return [
            {
                status: '✅ 定时发布成功！',
                aweme_id: awemeId,
                url,
                publish_time: publishTimeStr,
            },
        ];
    },
});
