#!/usr/bin/env python3
"""Download a pinned release. Never execute downloaded code or follow redirects."""
import argparse
import ctypes
import hashlib
import json
import os
from pathlib import Path
import shutil
import stat
import sys
import tempfile
from urllib.parse import urlsplit, urlunsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, build_opener

# Updated explicitly by scripts/build_contract_release.py after review.
RELEASE_SHA256 = '263f560ca56a512d029a0cd83b3323a774b11b35c72a593eed994bc2b3c35f2e'
FILLER_SHA256 = 'faa7344781d69f0d174e2cc0ada92a201802b82f156b8f536a1a0a85ff8eddf3'
FILLER_SIZE = 58081
MAX_JSON = 512 * 1024
MAX_FILE = 8 * 1024 * 1024


class NoRedirects(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise ValueError('Редирект запрещён: другой адрес не запрашивался. Проверьте точный адрес страницы.')


# Ignore environment proxies; HTTPS certificate validation remains enabled.
OPENER = build_opener(ProxyHandler({}), NoRedirects())


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError('Повторяющийся ключ JSON: ' + key)
        result[key] = value
    return result


def parse_json(body):
    return json.loads(body, object_pairs_hook=unique_object,
                      parse_constant=lambda value: (_ for _ in ()).throw(ValueError('Недопустимое число: ' + value)))


def resource_base(page_url, allow_local_preview=False):
    if any(ord(char) < 33 or char == '\\' for char in page_url):
        raise ValueError('В адресе есть пробел, управляющий символ или обратная косая черта.')
    page = urlsplit(page_url)
    if page.username or page.password:
        raise ValueError('Логин и пароль в URL запрещены.')
    official = page.scheme == 'https' and page.netloc == 'pachca.com'
    local = (allow_local_preview and page.scheme == 'http' and
             page.hostname in ('127.0.0.1', '::1') and page.port is not None)
    if not (official or local):
        raise ValueError('Для сайта нужен https://pachca.com. HTTP разрешён только с --allow-local-preview для буквального loopback-адреса с портом.')
    path = page.path.removesuffix('/index.html').rstrip('/')
    resource_paths = {'/ai_legal': '/ai_legal', '/en/ai_legal': '/en/ai_legal',
                      '/contracts': '/ai_legal', '/en/contracts': '/en/ai_legal'}
    if path not in resource_paths:
        raise ValueError('Допустимы только страницы /ai_legal и /en/ai_legal и их прежние адреса /contracts и /en/contracts.')
    return urlunsplit((page.scheme, page.netloc, resource_paths[path] + '/', '', ''))


def fetch(url, limit):
    with OPENER.open(url, timeout=30) as response:
        if response.status != 200 or response.geturl() != url:
            raise ValueError('Неожиданный HTTP-ответ или адрес ресурса.')
        length = response.headers.get('Content-Length')
        if length is not None and (not length.isdecimal() or int(length) > limit):
            raise ValueError('Недопустимый Content-Length или превышение размера ресурса.')
        body = response.read(limit + 1)
        if len(body) > limit:
            raise ValueError('Превышено ограничение размера ресурса.')
        return body


def checked(body, digest, name):
    if hashlib.sha256(body).hexdigest() != digest:
        raise ValueError('Контрольная сумма отличается от закреплённого выпуска: ' + name)
    return body


def bootstrap_bytes():
    fd = os.open(__file__, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0))
    with os.fdopen(fd, 'rb') as handle:
        if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
            raise ValueError('Загрузчик должен быть обычным проверенным файлом.')
        body = handle.read(MAX_JSON + 1)
    if len(body) > MAX_JSON:
        raise ValueError('Превышен размер загрузчика.')
    return body


def collect(base_url, template):
    release_body = checked(fetch(base_url + 'agent/release.json', MAX_JSON), RELEASE_SHA256, 'release.json')
    release = parse_json(release_body)
    instruction = next(row for row in release['files'] if row['path'] == 'agent/README.md')
    instruction_body = fetch(base_url + 'AGENTS.md', instruction['size'])
    if len(instruction_body) != instruction['size']:
        raise ValueError('Размер инструкции отличается от выпуска: AGENTS.md')
    checked(instruction_body, instruction['sha256'], 'AGENTS.md')
    bootstrap = bootstrap_bytes()
    rows = release['files'] + [
        dict(path='agent/release.json', sha256=RELEASE_SHA256, size=len(release_body), template=None),
        dict(path='agent/scripts/fill_contract.py', sha256=FILLER_SHA256, size=FILLER_SIZE, template=None),
        dict(path='agent/scripts/download.py', sha256=hashlib.sha256(bootstrap).hexdigest(), size=len(bootstrap), template=None),
    ]
    rows = sorted(rows, key=lambda row: row['path'])
    expected = dict(format=1, version=release['version'], entrypoint='../AGENTS.md',
                    templates=release['templates'], files=rows)
    manifest = parse_json(fetch(base_url + 'agent/manifest.json', MAX_JSON))
    if json.dumps(manifest, sort_keys=True) != json.dumps(expected, sort_keys=True):
        raise ValueError('Манифест не соответствует точному списку файлов доверенного выпуска.')
    downloads = []
    for row in rows:
        if row['template'] not in (None, template):
            continue
        # Paths, sizes and hashes come from the pinned release, not the manifest.
        relative = row['path'].removeprefix('agent/')
        if (not row['path'].startswith('agent/') or '\\' in relative or
                any(piece in ('', '.', '..') for piece in relative.split('/')) or
                type(row['size']) is not int or not 0 <= row['size'] <= MAX_FILE):
            raise ValueError('Недопустимый путь или размер в доверенном выпуске.')
        body = fetch(base_url + row['path'], row['size'])
        if len(body) != row['size']:
            raise ValueError('Размер файла отличается от выпуска: ' + row['path'])
        checked(body, row['sha256'], row['path'])
        downloads.append((relative, body))
    return downloads, release['version']


def rename_new(source, destination):
    if os.name == 'nt':
        # On Windows os.rename never replaces an existing destination.
        os.rename(source, destination)
        return
    libc = ctypes.CDLL(None, use_errno=True)
    if sys.platform == 'darwin':
        rename = libc.renamex_np
        rename.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
        result = rename(os.fsencode(source), os.fsencode(destination), 0x00000004)  # RENAME_EXCL
    elif sys.platform.startswith('linux') and hasattr(libc, 'renameat2'):
        rename = libc.renameat2
        rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
        result = rename(-100, os.fsencode(source), -100, os.fsencode(destination), 1)  # AT_FDCWD, RENAME_NOREPLACE
    else:
        raise ValueError('На этой платформе нет поддерживаемого атомарного сохранения новой папки; файлы не опубликованы.')
    if result != 0:
        error = ctypes.get_errno()
        raise OSError(error, os.strerror(error), str(destination))


def publish(destination, downloads):
    destination = Path(os.path.abspath(destination.expanduser()))
    parent = destination.parent.resolve(strict=True)
    if not parent.is_dir():
        raise ValueError('Родительская папка ресурсов должна существовать.')
    destination = parent / destination.name
    # The complete verified bundle becomes visible in one no-replace rename.
    # There is no exists→write gap and no merging into a replaced directory.
    stage = Path(tempfile.mkdtemp(prefix='.contract-resources-', dir=parent))
    initial = stage.stat(follow_symlinks=False)
    try:
        for relative, body in downloads:
            path = stage / relative
            path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
            fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0), 0o600)
            with os.fdopen(fd, 'wb') as handle:
                handle.write(body)
                handle.flush()
                os.fsync(handle.fileno())
        current = stage.stat(follow_symlinks=False)
        if (initial.st_dev, initial.st_ino) != (current.st_dev, current.st_ino):
            raise ValueError('Временная папка ресурсов была заменена.')
        rename_new(stage, destination)
        current = destination.stat(follow_symlinks=False)
        if (initial.st_dev, initial.st_ino) != (current.st_dev, current.st_ino):
            raise ValueError('Папка ресурсов была заменена во время сохранения.')
        return destination
    except FileExistsError:
        raise ValueError('Папка, файл или ссылка уже существует; перезапись запрещена.')
    finally:
        # Never delete a path that was swapped to customer content.
        if os.path.lexists(stage):
            current = stage.stat(follow_symlinks=False)
            if (initial.st_dev, initial.st_ino) == (current.st_dev, current.st_ino):
                shutil.rmtree(stage)


def main():
    ap = argparse.ArgumentParser(description='Скачать закреплённый выпуск ресурсов для договора')
    ap.add_argument('page_url', help='Точный URL страницы, полученный от пользователя')
    ap.add_argument('destination', type=Path, help='Новая отдельная папка, согласованная с пользователем')
    ap.add_argument('--template', choices=['company', 'corporation'], required=True)
    ap.add_argument('--allow-local-preview', action='store_true', help='только локальный просмотр на буквальном loopback-адресе; не применять к публичному сайту')
    args = ap.parse_args()
    base_url = resource_base(args.page_url, args.allow_local_preview)
    if os.path.lexists(args.destination.expanduser()):
        raise ValueError('Папка или ссылка уже существует; перезапись запрещена.')
    downloads, version = collect(base_url, args.template)
    destination = publish(args.destination, downloads)
    print(json.dumps(dict(ok=True, version=version, release_sha256=RELEASE_SHA256,
                          files=[str(destination / relative) for relative, _ in downloads],
                          note='Файлы сверены с закреплённым выпуском. Код не исполнялся. Начальная доверенная версия загрузчика и локальная среда остаются корнем доверия.'), ensure_ascii=False, indent=2))


if __name__ == '__main__':
    try:
        main()
    except Exception as exc:
        print(json.dumps(dict(ok=False, code='DOWNLOAD_FAILED', message=str(exc),
                              next_step='Остановитесь. Сохраните ошибку и точный адрес; проверьте доверенную версию загрузчика. При частичной записи не используйте эту папку, выберите новую.'), ensure_ascii=False, indent=2))
        sys.exit(1)
