#!/usr/bin/env python3
"""iSomor API 完整示例（Python 3.10+，仅标准库）。

凭证从 ISOMOR_API_BASE_URL / ISOMOR_API_KEY 读取，不写入状态文件。
首次调用：--file sample.pdf --target zh --output ./run-001 --submit
继续同一任务：--output ./run-001 --resume
默认最多使用 1 积分；扩大范围须明确传 --max-credits。无操作开关时不发请求。
"""

import argparse
import hashlib
import json
import math
import os
import re
import ssl
import sys
import tempfile
import time
import uuid
from http.client import HTTPException
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener

FORMATS = ("translated", "bilingual")
MAX_DOWNLOAD = 128 * 1024 * 1024
USER_AGENT = "iSomor-Python-Client/1.0"


class ClientError(Exception):
    """保存可向操作者展示的固定错误，不包含凭证、文件内容或签名 URL。"""


class NoRedirect(HTTPRedirectHandler):
    """拒绝重定向，避免 Bearer Key 或下载请求被转送到其他站点。"""

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        """无论原目标是否同源均停止，要求使用接入邮件的最终 HTTPS 地址。"""
        return None


def https_url(value, base=False):
    """校验 HTTPS 地址；API 基础地址不允许账号、查询串、片段或路径歧义。"""
    try:
        parts = urlsplit(value)
        if parts.scheme != "https" or not parts.hostname or parts.username or parts.password:
            raise ValueError()
        if parts.fragment or any(ord(char) <= 32 for char in value):
            raise ValueError()
        if base and (parts.query or parts.path != "/api/isomor/v1"):
            raise ValueError()
        parts.port
    except (ValueError, TypeError):
        raise ClientError(
            "Use a valid HTTPS URL; the API base must end in /api/isomor/v1."
        ) from None
    return value


def public_id(value, prefix):
    """只允许安全的不透明 ID 进入路径和终端，拒绝服务端异常响应中的任意文字。"""
    if not isinstance(value, str) or not re.fullmatch(prefix + r"_[A-Za-z0-9_-]{1,35}", value):
        raise ClientError("The service returned an invalid resource ID.")
    return value


def error_code(data):
    """从错误体提取受限标识；不显示 message、原始响应或敏感访问材料。"""
    error = data.get("error", {}) if isinstance(data, dict) else {}
    code = error.get("code", "request_failed") if isinstance(error, dict) else "request_failed"
    return (
        code if isinstance(code, str) and re.fullmatch(r"[a-z_]{1,64}", code) else "request_failed"
    )


class HttpClient:
    """注入 HTTP 传输、时钟与等待函数，固定 API 凭证目的地并限制重试和响应大小。"""

    def __init__(self, base, key, opener, timeout=600, clock=time.monotonic, sleep=time.sleep):
        """保存服务端配置；总等待时间由 timeout 限定，下载请求永不携带 API Key。"""
        self.base, self.key, self.opener = https_url(base, base=True), key, opener
        if not key or any(ord(char) <= 32 or ord(char) >= 127 for char in key):
            raise ClientError("Set ISOMOR_API_KEY in the server environment.")
        self.clock, self.sleep = clock, sleep
        self.deadline = clock() + timeout

    def wait(self, headers=None, fallback=5):
        """遵循 Retry-After，最短五秒；超出本次截止时间就停止，不提前重试。"""
        value = (headers or {}).get("Retry-After", str(fallback))
        delay = max(5, int(value)) if str(value).isdigit() else max(5, fallback)
        if self.clock() + delay >= self.deadline:
            raise ClientError("Time limit reached. Resume with the same output directory.")
        self.sleep(delay)

    def _open(self, request):
        remaining = self.deadline - self.clock()
        if remaining <= 0:
            raise ClientError("Time limit reached. Resume with the same output directory.")
        return self.opener.open(request, timeout=min(30, remaining))

    def _json_once(self, request):
        try:
            response = self._open(request)
        except HTTPError as error:
            response = error
        with response:
            raw = response.read(1024 * 1024 + 1)
            try:
                data = json.loads(raw) if len(raw) <= 1024 * 1024 else None
            except (ValueError, UnicodeError):
                data = None
            return response.status, response.headers, data

    def api(self, method, path, data=None, content_type="application/json", key=None, retry=True):
        """只访问固定 API 前缀；创建重试沿用 key，上传默认由调用方禁止自动重试。"""
        if not re.fullmatch(
            r"/(capabilities|quota|files|translations(?:/job_[A-Za-z0-9_-]+(?:/results)?)?)", path
        ):
            raise ClientError("Invalid API path.")
        headers = {
            "Authorization": "Bearer " + self.key,
            "Accept": "application/json",
            "User-Agent": USER_AGENT,
        }
        if data is not None:
            headers["Content-Type"] = content_type
        if key:
            headers["Idempotency-Key"] = key
        request = Request(self.base + path, data=data, headers=headers, method=method)
        return self._attempts(request, retry)

    def _attempts(self, request, retry):
        for attempt in range(4 if retry else 1):
            try:
                status, headers, data = self._json_once(request)
            except (URLError, TimeoutError, OSError, HTTPException):
                status, headers, data = 503, {}, None
            if 200 <= status < 300:
                if not isinstance(data, dict):
                    raise ClientError("Invalid JSON response. Resume the same operation.")
                return data, headers
            code = error_code(data)
            transient = status >= 500 or (
                status == 429 and code in ("rate_limited", "concurrency_limit")
            )
            if not retry or not transient or attempt == 3:
                raise ClientError(f"HTTP {status}: {code}. Keep the output directory for recovery.")
            self.wait(headers, 5 * 2**attempt)
        raise ClientError("Retry limit reached.")

    def download(self, url, destination):
        """下载到新建私有文件；不转发认证头、不跟随跳转、不覆盖已有 PDF。"""
        url = https_url(url)
        try:
            response = self._open(
                Request(url, headers={"Accept": "application/pdf", "User-Agent": USER_AGENT})
            )
        except HTTPError as error:
            status = error.code
            error.close()
            if status in (401, 403):
                return False
            raise ClientError(f"Download HTTP {status}; retrieve results again.") from None
        with response:
            self._save_pdf(response, destination)
        return True

    def _save_pdf(self, response, destination):
        # x 模式防止覆盖，失败时仅清理由本函数创建的部分文件。
        file = os.fdopen(os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb")
        try:
            with file:
                first = response.read(1024)
                if b"%PDF-" not in first:
                    raise ClientError("The download is not a PDF.")
                file.write(first)
                total = len(first)
                while chunk := response.read(65536):
                    total += len(chunk)
                    if total > MAX_DOWNLOAD:
                        raise ClientError("The download exceeds the example's 128 MiB limit.")
                    file.write(chunk)
                expected = response.headers.get("Content-Length")
                if expected is not None and (not expected.isdigit() or total != int(expected)):
                    raise ClientError("Incomplete PDF download. Resume the same job.")
        except BaseException:
            destination.unlink()
            raise


def save_state(directory, state):
    """原子保存恢复信息，权限 0600；不保存凭证、源文档内容或签名下载链接。"""
    fd, name = tempfile.mkstemp(prefix=".state-", dir=directory)
    try:
        with os.fdopen(fd, "w") as file:
            json.dump(state, file)
            file.flush()
            os.fsync(file.fileno())
        os.replace(name, directory / "state.json")
    finally:
        if os.path.exists(name):
            os.unlink(name)


def selected_count(pages, count):
    """计算去重的选页数量，校验一开始的闭区间且不允许越界。"""
    if pages is None:
        return count
    if len(pages) > 256 or not re.fullmatch(r"\d+(-\d+)?(,\d+(-\d+)?)*", pages):
        raise ClientError("Invalid page selection.")
    selected = set()
    for part in pages.split(","):
        values = [int(value) for value in part.split("-")]
        start, end = values[0], values[-1]
        if not 1 <= start <= end <= count:
            raise ClientError("Page selection exceeds the PDF.")
        selected.update(range(start, end + 1))
    return len(selected)


def upload_body(content):
    """构建单文件 multipart；固定文件名避免路径和真实文件名进入上传元数据。"""
    boundary = "isomor-" + uuid.uuid4().hex
    prefix = (
        f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
        'filename="document.pdf"\r\nContent-Type: application/pdf\r\n\r\n'
    ).encode()
    return (
        prefix + content + f"\r\n--{boundary}--\r\n".encode(),
        "multipart/form-data; boundary=" + boundary,
    )


def prepare(client, args, directory):
    """校验文件和服务限制，上传一次后检查明确的积分上限；创建前持久化原请求。"""
    capabilities, _ = client.api("GET", "/capabilities")
    quota, _ = client.api("GET", "/quota")
    maximum = min(int(capabilities["limits"]["max_file_bytes"]), 50 * 1024 * 1024)
    with Path(args.file).open("rb") as file:
        content = file.read(maximum + 1)
    if len(content) > maximum or b"%PDF-" not in content[:1024]:
        raise ClientError("Provide a PDF within the service size limit.")
    if quota["remaining"] <= 0:
        raise ClientError("No API allowance available. Request more by email.")
    directory.mkdir(mode=0o700, parents=False, exist_ok=False)
    body, media = upload_body(content)
    uploaded, _ = client.api("POST", "/files", body, media, retry=False)
    amount = selected_count(args.pages, uploaded["page_count"]) * capabilities["credits_per_page"]
    if amount > args.max_credits or amount > quota["remaining"]:
        raise ClientError("Credit limit exceeded. File uploaded; no translation submitted.")
    payload = dict(
        file_id=public_id(uploaded["id"], "file"),
        target_language=args.target,
        source_language="auto",
        output_formats=list(FORMATS),
    )
    if args.pages is not None:
        payload["pages"] = args.pages
    state = dict(
        version=1,
        base_url=client.base,
        request=payload,
        idempotency_key=uuid.uuid4().hex,
        first_attempt_at=time.time(),
        max_credits=args.max_credits,
        estimated_credits=amount,
        source_sha256=hashlib.sha256(content).hexdigest(),
    )
    save_state(directory, state)
    return state


def restore(client, directory):
    """恢复同一次请求并拒绝换基础地址；不读取或存储 API Key。"""
    path = directory / "state.json"
    if path.stat().st_size > 16384:
        raise ClientError("Invalid recovery state.")
    state = json.loads(path.read_text())
    if state.get("version") != 1 or state.get("base_url") != client.base:
        raise ClientError("Recovery requires the same API base URL and project credential.")
    if not isinstance(state.get("request"), dict) or not re.fullmatch(
        r"[a-f0-9]{32}", state.get("idempotency_key", "")
    ):
        raise ClientError("Invalid recovery state. Do not create another job blindly.")
    return state


def ensure_job(client, directory, state):
    """已知任务只查询；结果未知时仅在保守的 23 小时窗口内重放原始请求。"""
    if state.get("job_id"):
        return public_id(state["job_id"], "job")
    age = time.time() - state["first_attempt_at"]
    if not math.isfinite(age) or not 0 <= age < 23 * 3600:
        raise ClientError("Creation outcome unknown and retry window unsafe. Contact support.")
    data, _ = client.api(
        "POST", "/translations", json.dumps(state["request"]).encode(), key=state["idempotency_key"]
    )
    if data.get("file_id") != state["request"]["file_id"]:
        raise ClientError("Accepted file ID differs from the saved request. Contact support.")
    state["job_id"] = public_id(data["id"], "job")
    save_state(directory, state)
    return state["job_id"]


def wait_for_job(client, job_id):
    """有界轮询稳定任务 ID，失败立即停止，不自动新建或消耗另一份额度。"""
    while True:
        data, headers = client.api("GET", "/translations/" + job_id)
        if data.get("id") != job_id:
            raise ClientError("Response job ID differs from the saved job. Contact support.")
        if data["status"] == "succeeded":
            return
        if data["status"] == "failed":
            raise ClientError("Translation failed. Contact support with the saved job ID.")
        if data["status"] not in ("queued", "running"):
            raise ClientError("Unknown job status; retain the saved job ID.")
        client.wait(headers)


def file_digest(path):
    """有界读取文件计算摘要，用于恢复时确认已保存产物未被替换。"""
    digest = hashlib.sha256()
    with path.open("rb") as file:
        while chunk := file.read(65536):
            digest.update(chunk)
    return digest.hexdigest()


def download_results(client, job_id, directory, state):
    """取回两种产物；过期链接只重新获取一次，不重建翻译任务。"""
    for fmt in FORMATS:
        destination = directory / (fmt + ".pdf")
        if destination.exists():
            saved = state.get("downloads", {}).get(fmt)
            if saved and file_digest(destination) == saved:
                continue
            raise ClientError("Output already exists. Keep it safe; do not overwrite results.")
        for attempt in range(2):
            data, _ = client.api("GET", "/translations/" + job_id + "/results")
            if data.get("job_id") != job_id:
                raise ClientError("Result job ID does not match the requested job.")
            item = next((item for item in data["files"] if item["format"] == fmt), None)
            if item is None:
                raise ClientError("A requested PDF output is missing.")
            if client.download(item["url"], destination):
                state.setdefault("downloads", {})[fmt] = file_digest(destination)
                save_state(directory, state)
                break
            if attempt:
                raise ClientError("Download authorization expired. Resume without creating a job.")


def parser():
    """定义明确提交/恢复开关及默认一积分上限；无开关不发送请求。"""
    value = argparse.ArgumentParser(description=__doc__)
    mode = value.add_mutually_exclusive_group()
    mode.add_argument("--submit", action="store_true")
    mode.add_argument("--resume", action="store_true")
    value.add_argument("--file")
    value.add_argument("--target", choices=("zh", "en"), default="zh")
    value.add_argument("--pages", help="1-based selection, e.g. 1-3,5; omit for all pages")
    value.add_argument(
        "--output", required=True, help="New private directory; reuse only with --resume"
    )
    value.add_argument("--max-credits", type=int, default=1)
    value.add_argument(
        "--timeout", type=int, default=600, help="Total seconds for this run, 1–3600"
    )
    return value


def main(argv=None):
    """组合传输和工作流；只输出成功状态或脱敏错误，无隐式登录、付费或新建重试。"""
    args = parser().parse_args(argv)
    if not (args.submit or args.resume):
        print(
            "No requests sent. Use --submit to upload and translate, or --resume for the same job."
        )
        return 0
    try:
        if args.max_credits < 1 or not 1 <= args.timeout <= 3600 or (args.submit and not args.file):
            raise ClientError(
                "Set --file for submission, a positive credit cap and a 1–3600s timeout."
            )
        opener = build_opener(
            ProxyHandler({}), HTTPSHandler(context=ssl.create_default_context()), NoRedirect()
        )
        client = HttpClient(
            os.environ.get("ISOMOR_API_BASE_URL", ""),
            os.environ.get("ISOMOR_API_KEY", ""),
            opener,
            args.timeout,
        )
        directory = Path(args.output)
        state = restore(client, directory) if args.resume else prepare(client, args, directory)
        job_id = ensure_job(client, directory, state)
        print("Job accepted:", job_id)
        wait_for_job(client, job_id)
        download_results(client, job_id, directory, state)
        print("Saved translated.pdf and bilingual.pdf. Protect the output directory.")
        return 0
    except ClientError as error:
        print(str(error), file=sys.stderr)
    except (OSError, ValueError, KeyError, TypeError, HTTPException):
        print(
            "Local I/O or response error. Preserve the output directory; do not submit again blindly.",
            file=sys.stderr,
        )
    return 1


if __name__ == "__main__":
    raise SystemExit(main())
