#!/usr/bin/env node

import { createHmac, createHash } from 'node:crypto'
import { createReadStream, createWriteStream, statSync } from 'node:fs'
import { request } from 'node:https'
import { pipeline } from 'node:stream/promises'

function fail(message) {
  console.error(`oss_object=FAIL ${message}`)
  process.exit(1)
}

function requiredEnv(name, pattern) {
  const value = process.env[name] || ''
  if (!value || (pattern && !pattern.test(value))) fail(`invalid or missing ${name}`)
  return value
}

function encodeObjectKey(value) {
  if (!value || value.startsWith('/') || value.includes('..')) fail('invalid object key')
  return value.split('/').map(encodeURIComponent).join('/')
}

const [operation, objectKeyValue, filePath] = process.argv.slice(2)
if (!['put', 'get', 'head', 'delete'].includes(operation || '')) {
  fail('usage: oss-object.mjs {put|get|head|delete} <object-key> [file]')
}
if ((operation === 'put' || operation === 'get') && !filePath) fail(`${operation} requires a file`)

const accessKey = requiredEnv('ALI_KEY')
const secretKey = requiredEnv('ALI_SECRET')
const region = requiredEnv('ALI_REGION', /^[a-z0-9-]+$/)
const bucket = requiredEnv('ALI_BUCKET', /^[A-Za-z0-9.-]+$/)
const objectKey = encodeObjectKey(objectKeyValue)
const hostname = `${bucket}.${region}.aliyuncs.com`
const canonicalResource = `/${bucket}/${objectKey}`
const date = new Date().toUTCString()
const method = operation.toUpperCase()
const contentType = operation === 'put' ? 'application/octet-stream' : ''
const stringToSign = `${method}\n\n${contentType}\n${date}\n${canonicalResource}`
const signature = createHmac('sha1', secretKey).update(stringToSign).digest('base64')
const headers = {
  Authorization: `OSS ${accessKey}:${signature}`,
  Date: date,
}

let expectedDigest = ''
if (operation === 'put') {
  let details
  try {
    details = statSync(filePath)
  } catch (error) {
    fail(`cannot stat upload file: ${error.message}`)
  }
  if (!details.isFile()) fail('upload source must be a regular file')
  headers['Content-Type'] = contentType
  headers['Content-Length'] = String(details.size)
  expectedDigest = createHash('sha256')
}

const response = await new Promise((resolve, reject) => {
  const outgoing = request({
    method,
    hostname,
    path: `/${objectKey}`,
    headers,
    timeout: 120_000,
  }, resolve)
  outgoing.once('timeout', () => outgoing.destroy(new Error('request timed out')))
  outgoing.once('error', reject)
  if (operation === 'put') {
    const source = createReadStream(filePath)
    source.on('data', (chunk) => expectedDigest.update(chunk))
    source.once('error', reject)
    source.pipe(outgoing)
  } else {
    outgoing.end()
  }
}).catch((error) => fail(error.message))

const successStatus = operation === 'get' || operation === 'head' ? 200 : [200, 204].includes(response.statusCode)
if (!successStatus) {
  let responseBody = ''
  for await (const chunk of response) responseBody += chunk
  fail(`HTTP ${response.statusCode} ${responseBody.slice(0, 240).replaceAll(/\s+/g, ' ')}`)
}

if (operation === 'get') {
  const destination = createWriteStream(filePath, { flags: 'wx', mode: 0o600 })
  await pipeline(response, destination).catch((error) => fail(`download failed: ${error.message}`))
}

if (operation === 'put') {
  for await (const _chunk of response) {
    // Drain the response so the request completes before reporting success.
  }
  console.log(`oss_object=PASS operation=put sha256=${expectedDigest.digest('hex')}`)
} else {
  for await (const _chunk of response) {
    // GET is consumed by pipeline; other successful response bodies are empty.
  }
  console.log(`oss_object=PASS operation=${operation}`)
}
