#!/usr/bin/env node
/**
 * 将项目原型页发布到 S3（阿里云 OSS）。
 * 流程：Make Admin export-html 构建静态包 → 按页面独立前缀上传到 OSS。
 *
 * 用法：
 *   node scripts/publish-all-to-s3.mjs
 *   node scripts/publish-all-to-s3.mjs --dry-run
 *   node scripts/publish-all-to-s3.mjs --group prototypes
 */
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import extract from 'extract-zip';
import { readServerInfo } from './utils/serverInfo.mjs';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const CONFIG_PATH = path.join(projectRoot, '.axhub/make/axhub.config.json');

function parseArgs(argv) {
  const args = {
    projectId: 'oneos1.2',
    adminOrigin: '',
    dryRun: false,
    group: 'all',
    includeSource: true,
  };
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    if (arg === '--project-id' && argv[i + 1]) args.projectId = argv[++i].trim();
    else if (arg === '--admin-origin' && argv[i + 1]) args.adminOrigin = argv[++i].trim();
    else if (arg === '--group' && argv[i + 1]) args.group = argv[++i].trim();
    else if (arg === '--dry-run') args.dryRun = true;
    else if (arg === '--no-source') args.includeSource = false;
  }
  return args;
}

function resolveAdminOrigin(explicit) {
  if (explicit) return explicit.replace(/\/+$/u, '');
  const info = readServerInfo(projectRoot, 'admin');
  if (info?.origin) return info.origin.replace(/\/+$/u, '');
  return 'http://localhost:53817';
}

function readS3Config() {
  const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
  const s3 = raw?.cloudPublishing?.s3;
  if (!s3?.accessKeyId || !s3?.secretAccessKey || !s3?.bucket) {
    throw new Error('请在 .axhub/make/axhub.config.json 中配置 cloudPublishing.s3');
  }
  return {
    ...s3,
    pathAliases: s3.pathAliases && typeof s3.pathAliases === 'object' ? s3.pathAliases : {},
    includeSource: raw?.cloudPublishing?.publishSettings?.includeSource !== false,
  };
}

function toPublishPath(resource) {
  const raw = resource.filePath || resource.sourcePath || '';
  return String(raw).replace(/\\/gu, '/').replace(/\/index\.tsx?$/iu, '');
}

function resolveObjectPrefix(entry, pathAliases = {}) {
  const alias = pathAliases[entry.id];
  if (alias) return String(alias).replace(/^\/+|\/+$/gu, '');

  const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
  const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u);
  if (prototypeMatch?.[1]) return `prototypes/${prototypeMatch[1]}`;
  const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u);
  if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
  return entry.id;
}

function resolvePublicUrl(baseUrl, prefix) {
  const base = String(baseUrl || '').replace(/\/?$/u, '/');
  const key = `${prefix.replace(/^\/+|\/+$/gu, '')}/index.html`;
  return `${base}${key.split('/').map(encodeURIComponent).join('/')}`;
}

function guessContentType(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  const map = {
    '.html': 'text/html; charset=utf-8',
    '.js': 'application/javascript; charset=utf-8',
    '.css': 'text/css; charset=utf-8',
    '.json': 'application/json; charset=utf-8',
    '.webp': 'image/webp',
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.svg': 'image/svg+xml',
    '.woff2': 'font/woff2',
    '.woff': 'font/woff',
    '.txt': 'text/plain; charset=utf-8',
  };
  return map[ext] || 'application/octet-stream';
}

async function collectPublishEntries(adminOrigin, projectId, groupFilter, pathAliases = {}) {
  const res = await fetch(`${adminOrigin}/api/projects/${encodeURIComponent(projectId)}/resources`);
  if (!res.ok) {
    throw new Error(`GET /api/projects/${projectId}/resources failed: ${res.status}`);
  }
  const data = await res.json();
  const groups = groupFilter === 'all' ? ['prototypes', 'themes'] : [groupFilter];
  const entries = [];
  for (const group of groups) {
    for (const item of data.resources?.[group] || []) {
      const publishPath = toPublishPath(item);
      if (!publishPath) continue;
      entries.push({
        group,
        id: item.id,
        title: item.title || item.id,
        path: publishPath,
        prefix: resolveObjectPrefix({ path: publishPath, id: item.id }, pathAliases),
      });
    }
  }
  return entries;
}

async function exportHtmlZip(adminOrigin, projectId, entry, includeSource) {
  const params = new URLSearchParams({
    path: entry.path,
    projectId,
  });
  if (includeSource) params.set('includeSource', 'true');
  const res = await fetch(`${adminOrigin}/api/export-html?${params.toString()}`);
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    let message = text;
    try {
      message = JSON.parse(text).error || text;
    } catch {
      // keep raw text
    }
    throw new Error(message || `export-html failed (${res.status})`);
  }
  const buffer = Buffer.from(await res.arrayBuffer());
  if (buffer.length < 4 || buffer[0] !== 0x50 || buffer[1] !== 0x4b) {
    throw new Error('export-html 未返回有效 ZIP');
  }
  return buffer;
}

function resolveS3Endpoint(s3Config) {
  const bucket = String(s3Config.bucket || '').trim();
  const endpoint = String(s3Config.endpoint || '').trim().replace(/\/+$/u, '');
  const region = String(s3Config.region || 'cn-hangzhou').trim();

  if (endpoint) {
    // 配置里可能是 bucket 级域名（model-lnoneos.oss-...），AWS SDK 只需区域 endpoint。
    const bucketHostPrefix = bucket ? `${bucket}.` : '';
    if (bucketHostPrefix && endpoint.includes(bucketHostPrefix)) {
      return endpoint.replace(bucketHostPrefix, '');
    }
    return endpoint;
  }

  return `https://oss-${region}.aliyuncs.com`;
}

function createS3Client(s3Config) {
  return new S3Client({
    region: s3Config.region || 'cn-hangzhou',
    endpoint: resolveS3Endpoint(s3Config),
    forcePathStyle: false,
    credentials: {
      accessKeyId: s3Config.accessKeyId,
      secretAccessKey: s3Config.secretAccessKey,
    },
  });
}

async function uploadDirectory(client, bucket, prefix, dir) {
  const uploaded = [];
  const walk = async (currentDir, relative = '') => {
    for (const name of fs.readdirSync(currentDir)) {
      const abs = path.join(currentDir, name);
      const rel = relative ? `${relative}/${name}` : name;
      if (fs.statSync(abs).isDirectory()) {
        await walk(abs, rel);
        continue;
      }
      const key = [prefix.replace(/^\/+|\/+$/gu, ''), rel].filter(Boolean).join('/');
      await client.send(new PutObjectCommand({
        Bucket: bucket,
        Key: key,
        Body: fs.readFileSync(abs),
        ContentType: guessContentType(abs),
      }));
      uploaded.push(key);
    }
  };
  await walk(dir);
  return uploaded;
}

async function publishEntry(client, s3Config, adminOrigin, projectId, entry, includeSource) {
  const zipBuffer = await exportHtmlZip(adminOrigin, projectId, entry, includeSource);
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'axhub-publish-'));
  const zipPath = path.join(tempDir, 'bundle.zip');
  const extractDir = path.join(tempDir, 'site');
  fs.writeFileSync(zipPath, zipBuffer);
  fs.mkdirSync(extractDir, { recursive: true });
  await extract(zipPath, { dir: extractDir });
  const objectPrefix = s3Config.prefix
    ? `${String(s3Config.prefix).replace(/^\/+|\/+$/gu, '')}/${entry.prefix}`
    : entry.prefix;
  const keys = await uploadDirectory(client, s3Config.bucket, objectPrefix, extractDir);
  fs.rmSync(tempDir, { recursive: true, force: true });
  return {
    url: resolvePublicUrl(s3Config.baseUrl, objectPrefix),
    fileCount: keys.length,
    keys,
  };
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  const adminOrigin = resolveAdminOrigin(args.adminOrigin);
  const s3Config = readS3Config();
  const includeSource = args.includeSource && s3Config.includeSource;
  const entries = await collectPublishEntries(adminOrigin, args.projectId, args.group, s3Config.pathAliases);

  console.log(`Admin: ${adminOrigin}`);
  console.log(`Bucket: ${s3Config.bucket}`);
  console.log(`Base URL: ${s3Config.baseUrl}`);
  console.log(`Pages to publish: ${entries.length}`);

  if (args.dryRun) {
    for (const entry of entries) {
      console.log(`[dry-run] ${entry.group}/${entry.id} -> ${entry.prefix}/index.html`);
    }
    return;
  }

  const client = createS3Client(s3Config);
  const results = [];
  let failed = 0;

  for (let i = 0; i < entries.length; i += 1) {
    const entry = entries[i];
    const label = `[${i + 1}/${entries.length}] ${entry.group}/${entry.id}`;
    process.stdout.write(`${label} ... `);
    try {
      const result = await publishEntry(client, s3Config, adminOrigin, args.projectId, entry, includeSource);
      console.log(`OK ${result.url} (${result.fileCount} files)`);
      results.push({ ...entry, status: 'success', ...result });
    } catch (error) {
      failed += 1;
      const message = error instanceof Error ? error.message : String(error);
      console.log(`FAIL ${message}`);
      results.push({ ...entry, status: 'failed', error: message });
    }
  }

  const reportPath = path.join(
    projectRoot,
    '.axhub/make/exports',
    `cloud.publish.s3-all-${new Date().toISOString().replace(/[:.]/gu, '-')}.json`,
  );
  fs.mkdirSync(path.dirname(reportPath), { recursive: true });
  fs.writeFileSync(reportPath, `${JSON.stringify({
    schemaVersion: 2,
    projectId: args.projectId,
    target: 's3',
    method: 'export-html+s3-upload',
    total: entries.length,
    success: entries.length - failed,
    failed,
    completedAt: new Date().toISOString(),
    results: results.map(({ keys, ...rest }) => ({
      ...rest,
      fileCount: rest.fileCount ?? keys?.length ?? 0,
    })),
  }, null, 2)}\n`, 'utf8');

  console.log(`\nDone: ${entries.length - failed}/${entries.length} succeeded`);
  if (failed > 0) process.exitCode = 1;
  console.log(`Report: ${reportPath}`);
}

main().catch((error) => {
  console.error(error instanceof Error ? error.message : error);
  process.exit(1);
});
