#!/usr/bin/env python3
#
# cook-full - autonomously build all of SliTaz (~6k packages).
#
# M1: order engine. Reads the wok, builds the dependency graph
# (arch-correct: receipts are *sourced* with ARCH so conditional
# BUILD_DEPENDS like lz4's are honoured), and computes the build order:
#   1. curated bootstrap (configs/cookorder) for the toolchain/base salvo
#   2. iterative dependency resolution for the rest (ready = all
#      BUILD_DEPENDS + WANTED already ordered), in waves
#   3. anything left when a wave makes no progress = dependency loop.
#
# M2: build driver. Walks the order calling `cook <pkg>`, tracks state
# (sqlite if python3 has it, else a TSV fallback), resumes, and
# cascade-skips a package whose in-wok prereqs didn't build. The stop
# policy (critical list + impact + failure streaks) is M3; learning is M4.
# Source doctor: tarballs are magic-checked, stub/dead sources are seeded
# from mirror.slitaz.org/sources and re-cooked, and a background thread
# prefetches upcoming tarballs while the current package compiles.
#
# --dry-run / --status / --simulate cook NOTHING (agent-testable). Only a
# plain run (or --resume) actually calls cook -- pankso runs that.
#
# Copyright (C) SliTaz GNU/Linux - GNU GPL v3

import argparse
import fnmatch
import html
import json
import os
import re
import subprocess
import sys
import tempfile
import time

try:
    import sqlite3
    HAVE_SQLITE = True
except ImportError:
    HAVE_SQLITE = False

# Shell scanner: source each receipt (wok dir names on stdin) in a subshell
# with ARCH exported, emit one TSV row per package. $(echo $VAR) collapses
# newlines/tabs/runs of spaces in the multi-line dep vars to single spaces.
SCAN = r'''
# mirror variables receipts expand in WGET_URL ($GNU_MIRROR, $SF_MIRROR...);
# sourced FIRST: cook.conf also sets ARCH/WOK and must not override the args
. /etc/slitaz/cook.conf 2>/dev/null
ARCH="$1"; WOK="$2"; export ARCH
while read d; do
	r="$WOK/$d/receipt"
	[ -s "$r" ] || continue
	(
		PACKAGE=; HOST_ARCH=; WANTED=; BUILD_DEPENDS=; DEPENDS=
		TARBALL=; WGET_URL=
		. "$r" 2>/dev/null
		printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
			"${PACKAGE:-$d}" "$d" "$(echo $HOST_ARCH)" \
			"$(echo $WANTED)" "$(echo $BUILD_DEPENDS)" "$(echo $DEPENDS)" \
			"$(echo $TARBALL)" "$(echo $WGET_URL)"
	) </dev/null
done
'''

# bump when SCAN's output or the cache row format changes
SCAN_CACHE_V = '#cook-full-scan-v2'


def conf(name, default=None):
    """Read a variable from /etc/slitaz/cook.conf (best effort)."""
    path = '/etc/slitaz/cook.conf'
    if not os.path.exists(path):
        return default
    try:
        out = subprocess.run(
            ['sh', '-c', '. "$1" 2>/dev/null; eval "echo \\$$2"',
             '_', path, name],
            capture_output=True, text=True, timeout=10).stdout.strip()
        return out or default
    except Exception:
        return default


def host_arch():
    m = os.uname().machine
    if m == 'x86_64':
        return 'x86_64'
    return 'i486'  # i?86 and friends


class Pkg:
    __slots__ = ('name', 'wokdir', 'host_arch', 'wanted', 'bdeps', 'deps',
                 'tarball', 'wget')

    def __init__(self, name, wokdir, host_arch, wanted, bdeps, deps,
                 tarball='', wget=''):
        self.name = name
        self.wokdir = wokdir
        self.host_arch = host_arch
        self.wanted = wanted        # list (usually 0 or 1)
        self.bdeps = bdeps          # list
        self.deps = deps            # list (runtime; for info, not ordering)
        self.tarball = tarball      # source tarball file name ('' if none)
        self.wget = wget            # upstream WGET_URL ('' if none)


def scan_wok(wok, arch, cachefile=None):
    """Return {name: Pkg} by sourcing receipts with ARCH set.

    With cachefile, rows are cached keyed on (arch, receipt mtime): only
    receipts that changed since the last scan are re-sourced, so the
    typical fix-and-`--continue` cycle restarts in ~1s instead of
    re-forking ~6k subshells."""
    # wokdir -> [mtime, name, ha, wanted, bdeps, deps, tarball, wget]
    entries = {}
    if cachefile:
        try:
            with open(cachefile) as fh:
                # versioned header: a SCAN/format change must drop old rows
                # even though the receipts' mtimes did not move
                if fh.readline().rstrip('\n') == SCAN_CACHE_V:
                    for line in fh:
                        f = line.rstrip('\n').split('\t')
                        if len(f) == 10 and f[0] == arch:
                            entries[f[1]] = f[2:]
        except OSError:
            pass

    seen = set()
    todo = []           # (wokdir, mtime) to (re-)source
    for d in sorted(os.listdir(wok)):
        try:
            mt = '%d' % os.stat(os.path.join(wok, d, 'receipt')).st_mtime
        except OSError:
            continue
        seen.add(d)
        e = entries.get(d)
        if not e or e[0] != mt:
            todo.append((d, mt))
    pruned = set(entries) - seen        # receipt deleted from the wok
    for d in pruned:
        del entries[d]

    if todo:
        mtimes = dict(todo)
        res = subprocess.run(['sh', '-c', SCAN, 'cook-full-scan', arch, wok],
                             input='\n'.join(d for d, _ in todo) + '\n',
                             capture_output=True, text=True)
        for line in res.stdout.splitlines():
            f = line.split('\t')
            if len(f) != 8 or not f[0] or f[1] not in mtimes:
                continue
            entries[f[1]] = [mtimes[f[1]], f[0], f[2], f[3], f[4], f[5],
                             f[6], f[7]]

    if cachefile and (todo or pruned):
        tmp = cachefile + '.tmp'
        try:
            with open(tmp, 'w') as fh:
                fh.write(SCAN_CACHE_V + '\n')
                for d in sorted(entries):
                    fh.write('%s\t%s\t%s\n' % (arch, d, '\t'.join(entries[d])))
            os.replace(tmp, cachefile)
        except OSError:
            pass

    pkgs = {}
    for wokdir, e in entries.items():
        _, name, ha, wanted, bdeps, deps, tarball, wget = e
        pkgs[name] = Pkg(name, wokdir, ha.split(),
                         wanted.split(), bdeps.split(), deps.split(),
                         tarball, wget)
    return pkgs


def arch_ok(pkg, arch):
    """arch_db rule: unset HOST_ARCH = current arch; else must list arch|any."""
    if not pkg.host_arch:
        return True
    return arch in pkg.host_arch or 'any' in pkg.host_arch


def read_cookorder(path):
    order = []
    try:
        with open(path) as fh:
            for line in fh:
                line = line.strip()
                if line and not line.startswith('#'):
                    order.append(line)
    except OSError:
        pass
    return order


def compute_order(pkgs, arch, cookorder):
    """Bootstrap (cookorder) then iterative wave resolution.

    Returns (order, blocked) where blocked maps pkg -> unmet prereqs.
    A prereq (BUILD_DEPENDS or WANTED) only blocks if it is itself an
    arch-included wok package: deps outside this universe (external /
    other-arch / already provided by the toolchain) are treated as
    satisfied so they don't create false loops.
    """
    universe = {n for n, p in pkgs.items() if arch_ok(p, arch)}
    done = set()
    order = []

    def add(name):
        if name in universe and name not in done:
            done.add(name)
            order.append(name)

    # only prereqs that live in our universe gate the ordering
    def prereqs(p):
        return [d for d in (p.bdeps + p.wanted) if d in universe]

    # 1. Bootstrap salvo, in the curated order — but hoist each pkg's
    #    in-cookorder prereqs ahead of it, so a cookorder entry never
    #    precedes its own BUILD_DEPENDS/WANTED that are themselves
    #    bootstrapped (recurring "build dependency not found" gaps:
    #    dietlibc/busybox, libcroco/librsvg, libwebkit/midori). This
    #    reorders ONLY within the curated set (cookset) — it never pulls a
    #    wave-only package into the bootstrap. Cycle-guarded; the toolchain
    #    salvo is already deps-first so hoisting is a no-op there.
    cookset = set(cookorder) & universe
    def add_boot(name, stack):
        if name not in universe or name in done or name in stack:
            return
        stack.add(name)
        for d in prereqs(pkgs[name]):
            if d in cookset:
                add_boot(d, stack)
        stack.discard(name)
        add(name)
    for name in cookorder:
        add_boot(name, set())

    # 2. Iterative waves over the rest.

    # reverse out-degree: how many in-universe packages directly need this
    # one. Building high-degree libs first makes a big failure surface early
    # (M3 goal) and tie-breaks deterministically by name.
    rdeg = {n: 0 for n in universe}
    for n in universe:
        for d in prereqs(pkgs[n]):
            rdeg[d] += 1

    remaining = [n for n in universe if n not in done]
    while remaining:
        ready = [n for n in remaining
                 if all(d in done for d in prereqs(pkgs[n]))]
        if not ready:
            break  # no progress -> loop / unsatisfiable
        ready.sort(key=lambda n: (-rdeg[n], n))
        for name in ready:
            add(name)
        remaining = [n for n in remaining if n not in done]

    blocked = {}
    for name in remaining:
        unmet = [d for d in prereqs(pkgs[name]) if d not in done]
        blocked[name] = unmet
    return order, blocked


# ---------------------------------------------------------------------------
# M2: state store (sqlite if available, TSV fallback) + build driver
# ---------------------------------------------------------------------------

STATUSES = ('todo', 'building', 'done', 'failed', 'skipped', 'blocked')


def now():
    return time.strftime('%Y-%m-%d %H:%M:%S')


def broken_set(cache):
    try:
        with open(os.path.join(cache, 'broken')) as fh:
            return set(l.strip() for l in fh if l.strip())
    except OSError:
        return set()


def repo_built_names(pkgsdir, known=None):
    """Set of package names that have a built .tazpkg in the local repo
    ($PKGS). This -- not a leftover wok taz/ dir -- is the real proof a package
    is built: cook resolves build deps from $PKGS, and a partial/failed build
    leaves a taz/ dir with no package. Trusting the taz/ dir used to mark a
    half-built prereq 'done' and then HALT its dependents (gettext halting on
    an unbuilt coreutils).

    `known` is the set of real package names (the build universe). We resolve
    each `<name>-<version>.tazpkg` to the LONGEST known name that prefixes it,
    because the version-guessing regex below mis-splits any package whose own
    name ends in `-<digits>`: `docbook-xml-412-4.1.2.tazpkg` matched at the
    first `-4` and credited `docbook-xml`, so `docbook-xml-412` was never seen
    as built and got demoted to todo and re-cooked on every resume."""
    names = set()
    try:
        entries = os.listdir(pkgsdir)
    except OSError:
        return names
    if known:
        for f in entries:
            if not f.endswith('.tazpkg'):
                continue
            best = None
            for n in known:
                # Longest known name that prefixes "<name>-..." wins. We do NOT
                # require the version to start with a digit: snapshot versions
                # like cvs_20101110 / git_* / svn_* begin with a letter, so the
                # old digit check never credited tidy-cvs_20101110.tazpkg and
                # cook-full re-cooked tidy on every --retry-broken. Longest-match
                # already disambiguates coreutils vs coreutils-doc/-dev and
                # tidy vs tidy-dev/tidy-html5.
                if f.startswith(n + '-') \
                        and (best is None or len(n) > len(best)):
                    best = n
            if best:
                names.add(best)
        return names
    # Fallback when the universe is unknown: the version token starts with a
    # digit, so `coreutils` never picks up `coreutils-doc-*` / `coreutils-dev-*`
    # -- but a name ending in `-<digits>` is mis-split (see above).
    for f in entries:
        m = re.match(r'^(.+?)-[0-9].*\.tazpkg$', f)
        if m:
            names.add(m.group(1))
    return names


def run_cook(name, logpath):
    """Call `cook <pkg>`, appending its output to logpath. Returns exit code.
    Only ever called on a real run (never by the agent / --simulate)."""
    with open(logpath, 'a') as fh:
        fh.write('\n===== cook %s  (%s) =====\n' % (name, now()))
        fh.flush()
        try:
            return subprocess.call(['cook', name], stdout=fh,
                                   stderr=subprocess.STDOUT)
        except FileNotFoundError:
            fh.write('cook not found in PATH\n')
            return 127


def toolchain_canary():
    """Sanity-check the build environment BEFORE cooking anything: the C
    compiler must produce a runnable executable and `ar` must be GNU ar.
    Seen in production (2026-06-11): a busybox rebuild swept the binutils
    /usr/bin symlinks (as, ar, nm, ...) -- every subsequent package failed
    with 'C compiler cannot create executables' / 'could not determine ar
    interface' until someone noticed. Cheap to catch up front.
    Returns a list of problem strings (empty = healthy)."""
    probs = []
    with tempfile.TemporaryDirectory() as td:
        cfile = os.path.join(td, 'canary.c')
        exe = os.path.join(td, 'canary')
        with open(cfile, 'w') as fh:
            # <linux/limits.h> on purpose: a removed kernel build-dep can
            # sweep /usr/include/linux/* that linux-api-headers still owns
            # (tazpkg remove doesn't ref-count shared files) -> cpp sanity
            # fails for every subsequent ./configure. A bare `int main` would
            # not notice; including a kernel header makes the canary trip.
            fh.write('#include <stdio.h>\n'
                     '#include <linux/limits.h>\n'
                     'int main(void){return PATH_MAX > 0 ? 0 : 1;}\n')
        r = None
        for cc in ('cc', 'gcc'):
            try:
                r = subprocess.run([cc, cfile, '-o', exe],
                                   capture_output=True, text=True, timeout=60)
                break
            except (FileNotFoundError, subprocess.TimeoutExpired):
                continue
        if r is None:
            probs.append('no cc/gcc in PATH')
        elif r.returncode != 0:
            err = r.stderr.strip().splitlines()
            probs.append('cc cannot build an executable (%s)'
                         % (err[-1] if err else 'no diagnostic'))
        else:
            try:
                if subprocess.run([exe], timeout=10).returncode != 0:
                    probs.append('canary executable does not run')
            except (OSError, subprocess.TimeoutExpired):
                probs.append('canary executable does not run')
    try:
        out = subprocess.run(['ar', '--version'], capture_output=True,
                             text=True, timeout=10).stdout
        first = out.splitlines()[0] if out else ''
        if 'GNU ar' not in first:
            probs.append('ar is not GNU ar (binutils symlink swept?)')
    except (FileNotFoundError, subprocess.TimeoutExpired):
        probs.append('no ar in PATH')
    # python3 must be runnable: SliTaz has two python3 stacks (python3 vs the
    # legacy py3k) fighting over /usr/bin/python3; a py3k build-dep removal
    # sweeps the symlink (tazpkg remove doesn't restore the modified file),
    # then any python3-shebang build tool (gtkdoc-rebase, ...) dies with a
    # misleading '/bin/sh: ...: not found' pointing at the script not python3.
    try:
        if subprocess.run(['python3', '--version'], capture_output=True,
                          timeout=10).returncode != 0:
            probs.append('python3 missing or broken')
    except (FileNotFoundError, subprocess.TimeoutExpired):
        probs.append('python3 missing or broken')
    return probs


# System headers/files that a package reinstall can restore, keyed by a
# substring that shows up in the canary's compiler diagnostic. Seen 2026-06-13:
# a kernel build-dep removal (broadcom-wl / libdrm-mach64 / linux-cloop pull
# linux-module-headers) swept /usr/include/linux/* still owned by
# linux-api-headers -> `cpp` sanity check fails for every ./configure, and the
# next high-impact package (upower) takes the blame and halts the run.
HEALABLE = (
    ('linux/',  'linux-api-headers'),   # missing kernel uapi headers
    ('glibc headers', 'linux-api-headers'),
    ('python3 missing', 'python3'),     # /usr/bin/python3 swept by py3k remove
    # binutils swept by the tazpkg-remove "modified packages" bug (seen
    # 2026-06-16: a build-dep cleanup deleted /usr/bin/as|ld instead of
    # restoring them, so `as` vanished and `ar` fell back to busybox -> every
    # ./configure dies with "C compiler cannot create executables"). Both the
    # canary's "not GNU ar" string and the cc diagnostic ("exec 'as'") map here.
    ('not gnu ar', 'binutils'),
    ("exec 'as'", 'binutils'),
)


def heal_environment(msg):
    """A package just failed: check whether the build ENVIRONMENT broke under
    it (not the package's fault), repair known self-healable breakage with a
    forced tazpkg reinstall, and return True so the caller re-cooks once.
    Runs in the SliTaz chroot context cook-full executes in. Returns False if
    the env is healthy or the breakage isn't one we know how to fix."""
    probs = toolchain_canary()
    if not probs:
        return False
    blob = ' '.join(probs).lower()
    healed = []
    for needle, pkg in HEALABLE:
        if needle in blob and pkg not in healed:
            try:
                r = subprocess.run(['tazpkg', 'get-install', pkg, '--forced'],
                                   capture_output=True, text=True, timeout=300)
                if r.returncode == 0:
                    healed.append(pkg)
            except (FileNotFoundError, subprocess.TimeoutExpired):
                pass
    if healed and not toolchain_canary():
        msg('heal   build env was broken (%s) -> reinstalled %s; re-cooking\n'
            % (probs[0], ', '.join(healed)))
        return True
    return False


# Built-in critical set: a failure here halts the whole build (the toolchain
# / base nothing else can be built without). Overridable via data/critical.
DEFAULT_CRITICAL = set('''
glibc glibc-base glibc-dev gcc gcc-lib-base binutils busybox
zlib zlib-dev ncurses ncurses-dev ncursesw ncursesw-dev readline readline-dev
bash coreutils util-linux make m4 bison flex gettext perl python python-dev
pkg-config libtool automake autoconf slitaz-toolchain linux-api-headers
'''.split())


def load_critical(path):
    if path and os.path.exists(path):
        with open(path) as fh:
            return set(l.strip() for l in fh
                       if l.strip() and not l.startswith('#'))
    return set(DEFAULT_CRITICAL)


def load_skip(path, extra=''):
    """Glob patterns of packages to intentionally skip (data/skip + --skip)."""
    pats = set(p for p in extra.split(',') if p)
    if path and os.path.exists(path):
        with open(path) as fh:
            pats.update(l.strip() for l in fh
                        if l.strip() and not l.startswith('#'))
    return pats


def load_triage(path):
    """Ordered [(glob, action, category, note)] from data/triage.tsv -- the
    curated human narrative (fix idea / why we drop) the db can't carry."""
    rows = []
    if path and os.path.exists(path):
        with open(path) as fh:
            for l in fh:
                if not l.strip() or l.lstrip().startswith('#'):
                    continue
                f = l.rstrip('\n').split('\t')
                if len(f) >= 4:
                    rows.append((f[0], f[1], f[2], f[3]))
    return rows


def triage_for(name, triage):
    """First matching triage row (glob, fnmatch) -> (action, cat, note)."""
    for glob, action, cat, note in triage:
        if fnmatch.fnmatch(name, glob):
            return action, cat, note
    return None


class SqliteStore:
    def __init__(self, path):
        self.db = sqlite3.connect(path)
        self.db.execute(
            'CREATE TABLE IF NOT EXISTS pkg('
            'name TEXT PRIMARY KEY, pos INTEGER DEFAULT -1, '
            'status TEXT DEFAULT "todo", seconds REAL DEFAULT 0, '
            'attempts INTEGER DEFAULT 0, culprit TEXT DEFAULT "", '
            'updated TEXT DEFAULT "")')
        self.db.commit()

    backend = 'sqlite'

    def sync_order(self, order):
        rows = [(name, i) for i, name in enumerate(order)]
        self.db.executemany('INSERT OR IGNORE INTO pkg(name) VALUES(?)',
                            [(n,) for n, _ in rows])
        self.db.executemany('UPDATE pkg SET pos=? WHERE name=?',
                            [(i, n) for n, i in rows])
        self.db.commit()

    def get(self, name):
        r = self.db.execute(
            'SELECT status,seconds,attempts,culprit,pos FROM pkg WHERE name=?',
            (name,)).fetchone()
        if not r:
            return {'status': 'unknown', 'seconds': 0, 'attempts': 0,
                    'culprit': '', 'pos': -1}
        return {'status': r[0], 'seconds': r[1], 'attempts': r[2],
                'culprit': r[3], 'pos': r[4]}

    def set(self, name, status, seconds=None, attempts=None, culprit=None):
        self.db.execute('INSERT OR IGNORE INTO pkg(name) VALUES(?)', (name,))
        sets = ['status=?', 'updated=?']
        vals = [status, now()]
        if seconds is not None:
            sets.append('seconds=?'); vals.append(seconds)
        if attempts is not None:
            sets.append('attempts=?'); vals.append(attempts)
        if culprit is not None:
            sets.append('culprit=?'); vals.append(culprit)
        vals.append(name)
        self.db.execute('UPDATE pkg SET %s WHERE name=?' % ','.join(sets), vals)
        self.db.commit()

    def counts(self):
        c = {s: 0 for s in STATUSES}
        for status, n in self.db.execute(
                'SELECT status,COUNT(*) FROM pkg GROUP BY status'):
            c[status] = n
        return c

    def by_status(self, status, limit=None):
        q = 'SELECT name FROM pkg WHERE status=? ORDER BY pos'
        if limit:
            q += ' LIMIT %d' % limit
        return [r[0] for r in self.db.execute(q, (status,))]

    def statuses(self):
        """One-query {name: status} snapshot (drive keeps it as a mirror)."""
        return dict(self.db.execute('SELECT name,status FROM pkg'))

    def flush(self):
        pass

    def close(self):
        self.db.close()


class TsvStore:
    """Flat-file fallback when python3 has no sqlite3 (e.g. x86_64 port).
    Writes are deferred (dirty flag): the full file is only rewritten by
    flush() -- the driver flushes with each progress snapshot and on exit,
    so a crash loses at most ~2s of state ('building' re-cooks on resume)."""
    backend = 'tsv'

    def __init__(self, path):
        self.path = path
        self.d = {}
        self.dirty = False
        try:
            with open(path) as fh:
                for line in fh:
                    f = line.rstrip('\n').split('\t')
                    if len(f) == 6:
                        self.d[f[0]] = {'pos': int(f[1]), 'status': f[2],
                                        'seconds': float(f[3]),
                                        'attempts': int(f[4]), 'culprit': f[5]}
        except OSError:
            pass

    def flush(self):
        if not self.dirty:
            return
        tmp = self.path + '.tmp'
        with open(tmp, 'w') as fh:
            for name in sorted(self.d, key=lambda n: self.d[n]['pos']):
                r = self.d[name]
                fh.write('%s\t%d\t%s\t%g\t%d\t%s\n' % (
                    name, r['pos'], r['status'], r['seconds'],
                    r['attempts'], r['culprit']))
        os.replace(tmp, self.path)
        self.dirty = False

    def sync_order(self, order):
        for i, name in enumerate(order):
            r = self.d.setdefault(name, {'pos': i, 'status': 'todo',
                                         'seconds': 0, 'attempts': 0,
                                         'culprit': ''})
            r['pos'] = i
        self.dirty = True
        self.flush()

    def get(self, name):
        return dict(self.d.get(name, {'status': 'unknown', 'seconds': 0,
                                      'attempts': 0, 'culprit': '', 'pos': -1}))

    def set(self, name, status, seconds=None, attempts=None, culprit=None):
        r = self.d.setdefault(name, {'pos': -1, 'status': 'todo', 'seconds': 0,
                                     'attempts': 0, 'culprit': ''})
        r['status'] = status
        if seconds is not None:
            r['seconds'] = seconds
        if attempts is not None:
            r['attempts'] = attempts
        if culprit is not None:
            r['culprit'] = culprit
        self.dirty = True

    def counts(self):
        c = {s: 0 for s in STATUSES}
        for r in self.d.values():
            c[r['status']] = c.get(r['status'], 0) + 1
        return c

    def by_status(self, status, limit=None):
        names = sorted((n for n, r in self.d.items() if r['status'] == status),
                       key=lambda n: self.d[n]['pos'])
        return names[:limit] if limit else names

    def statuses(self):
        return {n: r['status'] for n, r in self.d.items()}

    def close(self):
        self.flush()


def open_store(sdir, backend):
    use_sqlite = HAVE_SQLITE if backend == 'auto' else (backend == 'sqlite')
    if use_sqlite:
        return SqliteStore(os.path.join(sdir, 'state.db'))
    return TsvStore(os.path.join(sdir, 'state.tsv'))


# Failure log patterns that mean "try again" rather than "broken" -- network
# / fetch hiccups, not a real build error.
TRANSIENT = ('could not resolve', 'unable to resolve', 'temporary failure',
             'connection timed out', 'connection refused', 'network is '
             'unreachable', 'failed to connect', 'timed out', 'download '
             'failed', 'cannot download', 'name or service not known',
             'wget: error', 'tls handshake')


def tail_text(path, lines=80, maxbytes=65536):
    """Last `lines` lines of a (possibly huge) file, read from the end only:
    run.log grows to hundreds of MB over a full build, so never slurp it."""
    try:
        with open(path, 'rb') as fh:
            fh.seek(0, 2)
            size = fh.tell()
            fh.seek(max(0, size - maxbytes))
            data = fh.read()
    except OSError:
        return ''
    return '\n'.join(data.decode('utf-8', 'replace').splitlines()[-lines:])


def is_transient(logpath, lines=80):
    blob = tail_text(logpath, lines).lower()
    return any(p in blob for p in TRANSIENT)


# Source-download failure: cook tried WGET_URL *and* its $MIRROR_URL fallback
# (and wget retried internally) and still couldn't fetch the tarball. This is a
# dead/unreachable source URL, not a build bug and not worth re-cooking -- we
# record it as its own category ('src-download') instead of a generic failure.
# NB: no DNS patterns here -- those are TRANSIENT (whole network blinking, not
# a dead URL) and the driver checks transient first / retries before this.
DL_FAIL = ('error: wget ', "can't connect to remote host",
           '404 not found', '403 forbidden')


def is_dl_fail(logpath, lines=80):
    blob = tail_text(logpath, lines).lower()
    return any(p in blob for p in DL_FAIL)


# ---------------------------------------------------------------------------
# Source doctor: verify tarballs, rescue from the SliTaz sources mirror,
# prefetch upcoming ones in the background.
#
# The single biggest halt cause in the i486 full run was the "stub source":
# a dead upstream URL serving an HTML error page that wget happily saves as
# the tarball -- extraction then fails (dconf, audit, the 13-member
# uclibc-cross-compiler family...). Every fix was the same manual dance:
# delete the stub, fetch the real tarball from
# mirror.slitaz.org/sources/packages/<initial>/, re-cook. Automate exactly
# that dance.
# ---------------------------------------------------------------------------

SOURCES_MIRROR = 'http://mirror.slitaz.org/sources/packages'

SRC_MAGIC = (
    (b'\x1f\x8b', ('.tar.gz', '.tgz', '.gz')),
    (b'BZh', ('.tar.bz2', '.tbz', '.tbz2', '.bz2')),
    (b'\xfd7zXZ\x00', ('.tar.xz', '.txz', '.xz')),
    (b']\x00\x00', ('.tar.lzma', '.lzma')),
    (b'PK\x03\x04', ('.zip',)),
    (b"7z\xbc\xaf", ('.7z',)),
)


def tarball_ok(path):
    """Is the saved tarball plausibly real? False = stub/corrupt (a dead
    URL serving an error page that wget 'saved' with exit code 0)."""
    try:
        with open(path, 'rb') as fh:
            head = fh.read(512)
    except OSError:
        return False
    if not head:
        return False
    low = path.lower()
    for magic, exts in SRC_MAGIC:
        if low.endswith(exts):
            return head.startswith(magic)
    if low.endswith('.tar'):
        try:
            with open(path, 'rb') as fh:
                fh.seek(257)
                return fh.read(5) == b'ustar'
        except OSError:
            return False
    # unknown extension: at least call out the obvious HTML error page
    sniff = head[:256].lstrip().lower()
    return not (sniff.startswith(b'<!doctype') or sniff.startswith(b'<html'))


def fetch_url(url, dest, timeout=600):
    """wget url -> dest, atomically and verified: True only if the result
    looks like a real tarball (magic bytes), never leaves a partial file."""
    tmp = dest + '.part'
    try:
        rc = subprocess.call(['wget', '-q', '-T', '60', '-O', tmp, url],
                             stdout=subprocess.DEVNULL,
                             stderr=subprocess.DEVNULL, timeout=timeout)
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        rc = 1
    if rc == 0 and tarball_ok(tmp):
        os.replace(tmp, dest)
        return True
    try:
        os.remove(tmp)
    except OSError:
        pass
    return False


def mirror_url(tarball):
    return '%s/%s/%s' % (SOURCES_MIRROR, tarball[0].lower(), tarball)


def is_vcs_url(url):
    return url.split('|')[0] in ('git', 'hg', 'svn', 'bzr', 'cvs')


def rescue_source(pkg, srcdir, msg):
    """After a failed cook: if the source tarball is missing or a stub,
    seed the real one from the SliTaz sources mirror. Returns True when a
    fresh tarball was put in place (caller re-cooks)."""
    if not pkg.tarball:
        return False
    path = os.path.join(srcdir, pkg.tarball)
    if os.path.exists(path):
        if tarball_ok(path):
            return False        # source looks sane: a real build failure
        msg('rescue %s: %s is a stub/corrupt file, trying the mirror\n'
            % (pkg.name, pkg.tarball))
        try:
            os.remove(path)
        except OSError:
            return False
    if fetch_url(mirror_url(pkg.tarball), path):
        msg('rescue %s: %s seeded from the sources mirror\n'
            % (pkg.name, pkg.tarball))
        return True
    return False


class Prefetcher:
    """Background tarball fetcher: downloads the sources of upcoming
    packages while the current one compiles. Dead/stub sources surface
    (and get seeded from the mirror) BEFORE their build starts instead of
    halting the run hours later. Touches only $SRC -- never the store
    (sqlite is not shared across threads); reports dead sources to a file
    the human can fix while the build goes on."""

    def __init__(self, srcdir, msg, report):
        import glob
        import queue
        import threading
        self.srcdir = srcdir
        self.msg = msg
        self.report = report
        self.queued = set()
        self.q = queue.Queue()
        self.dead = 0
        for part in glob.glob(os.path.join(srcdir, '*.part')):
            try:                # leftover of a download cut by a past exit
                os.remove(part)
            except OSError:
                pass
        threading.Thread(target=self._run, daemon=True).start()

    def push(self, pkg):
        if not pkg.tarball or pkg.name in self.queued:
            return
        self.queued.add(pkg.name)
        self.q.put(pkg)

    def _run(self):
        while True:
            pkg = self.q.get()
            try:
                self._fetch(pkg)
            except Exception:
                pass            # a prefetch problem must never kill the run

    def _fetch(self, pkg):
        path = os.path.join(self.srcdir, pkg.tarball)
        if os.path.exists(path):
            if tarball_ok(path):
                return
            try:                # stub on disk: replace it
                os.remove(path)
            except OSError:
                return
        # upstream first (canonical, keeps mirror load low), mirror second;
        # vcs pseudo-URLs (git|...) can only come from the mirror tarball
        if pkg.wget and not is_vcs_url(pkg.wget) \
                and fetch_url(pkg.wget, path):
            return
        if fetch_url(mirror_url(pkg.tarball), path):
            self.msg('prefetch %s: upstream dead, %s seeded from mirror\n'
                     % (pkg.name, pkg.tarball))
            return
        self.dead += 1
        self.msg('prefetch %s: SOURCE UNREACHABLE (%s)\n'
                 % (pkg.name, pkg.tarball))
        try:
            with open(self.report, 'a') as fh:
                fh.write('%s\t%s\t%s\t%s\n'
                         % (now(), pkg.name, pkg.tarball, pkg.wget))
        except OSError:
            pass


def fmt_dur(secs):
    secs = int(secs)
    h, rem = divmod(secs, 3600)
    m, s = divmod(rem, 60)
    if h:
        return '%dh%02dm' % (h, m)
    if m:
        return '%dm%02ds' % (m, s)
    return '%ds' % s


def ts_epoch(ts):
    """'YYYY-mm-dd HH:MM:SS' -> epoch seconds (0 if empty/unparseable)."""
    try:
        return time.mktime(time.strptime(ts, '%Y-%m-%d %H:%M:%S'))
    except (ValueError, OverflowError):
        return 0


class History:
    """Cross-run learning: per-package last build time, consecutive failure
    count and last status. Lives in its own file so --restart never wipes it."""

    def __init__(self, sdir, backend):
        self.use_sqlite = (HAVE_SQLITE if backend == 'auto'
                           else backend == 'sqlite')
        self.d = {}
        if self.use_sqlite:
            self.db = sqlite3.connect(os.path.join(sdir, 'history.db'))
            self.db.execute(
                'CREATE TABLE IF NOT EXISTS hist('
                'name TEXT PRIMARY KEY, last_seconds REAL DEFAULT 0, '
                'consec_fails INTEGER DEFAULT 0, last_status TEXT DEFAULT "", '
                'last_ts TEXT DEFAULT "")')
            self.db.commit()
        else:
            self.path = os.path.join(sdir, 'history.tsv')
            try:
                with open(self.path) as fh:
                    for line in fh:
                        f = line.rstrip('\n').split('\t')
                        if len(f) == 5:
                            self.d[f[0]] = {'last_seconds': float(f[1]),
                                            'consec_fails': int(f[2]),
                                            'last_status': f[3],
                                            'last_ts': f[4]}
            except OSError:
                pass

    def get(self, name):
        if self.use_sqlite:
            r = self.db.execute(
                'SELECT last_seconds,consec_fails,last_status,last_ts '
                'FROM hist WHERE name=?', (name,)).fetchone()
            if not r:
                return {'last_seconds': 0, 'consec_fails': 0,
                        'last_status': '', 'last_ts': ''}
            return {'last_seconds': r[0], 'consec_fails': r[1],
                    'last_status': r[2], 'last_ts': r[3]}
        return dict(self.d.get(name, {'last_seconds': 0, 'consec_fails': 0,
                                      'last_status': '', 'last_ts': ''}))

    def record(self, name, ok, seconds):
        h = self.get(name)
        secs = seconds if (ok and seconds > 0) else h['last_seconds']
        fails = 0 if ok else h['consec_fails'] + 1
        status = 'done' if ok else 'failed'
        if self.use_sqlite:
            self.db.execute(
                'INSERT OR REPLACE INTO hist'
                '(name,last_seconds,consec_fails,last_status,last_ts) '
                'VALUES(?,?,?,?,?)', (name, secs, fails, status, now()))
            self.db.commit()
        else:
            self.d[name] = {'last_seconds': secs, 'consec_fails': fails,
                            'last_status': status, 'last_ts': now()}
            tmp = self.path + '.tmp'
            with open(tmp, 'w') as fh:
                for n, r in sorted(self.d.items()):
                    fh.write('%s\t%g\t%d\t%s\t%s\n' % (
                        n, r['last_seconds'], r['consec_fails'],
                        r['last_status'], r.get('last_ts', '')))
            os.replace(tmp, self.path)

    def median_seconds(self, default=60.0):
        vals = []
        if self.use_sqlite:
            vals = [r[0] for r in self.db.execute(
                'SELECT last_seconds FROM hist WHERE last_seconds>0')]
        else:
            vals = [r['last_seconds'] for r in self.d.values()
                    if r['last_seconds'] > 0]
        if not vals:
            return default
        vals.sort()
        return vals[len(vals) // 2]

    def close(self):
        if self.use_sqlite:
            self.db.close()


def drive(store, order, blocked, pkgs, arch, wok, cache, sdir,
          simulate=False, fail=frozenset(), limit=0,
          critical=frozenset(), impact_max=20, no_stop=False, resume=True,
          history=None, retries=2, retry_broken=False, broken_after=3,
          transient=frozenset(), skip=(), dlfail=frozenset(), max_consec=5,
          srcdir='/home/slitaz/src', prefetch_n=8):
    """Walk the order, cook each ready package, cascade-skip on a failed
    prereq, and HALT (write a stop sentinel) when a failure is critical,
    its pending impact exceeds the threshold, or several unrelated packages
    fail in a row (broken environment, not broken packages)."""
    universe = set(order)
    broken = broken_set(cache)
    msg = sys.stderr.write

    # in-memory status mirror: drive is the single writer, and the hot paths
    # (cascade check, ETA, impact) would otherwise be thousands of store
    # round-trips per cooked package.
    status = store.statuses()

    # Packages already built before this run = their .tazpkg sits in $PKGS
    # (computed once; see repo_built_names). A package cooked during this run
    # is settled via setst() below, so the pre-run snapshot is exactly the
    # right "was it already built?" question for resume / cycle-credit.
    built_names = repo_built_names(conf('PKGS') or '/home/slitaz/packages',
                                   known=universe)

    def setst(name, st, **kw):
        status[name] = st
        store.set(name, st, **kw)

    # Reconcile stale 'done' entries against reality: a package the store calls
    # done but whose .tazpkg is gone from $PKGS isn't really built -- its
    # dependents' build deps would be unsatisfiable (gettext halting on an
    # "unbuilt" coreutils/automake whose taz/ dir lingered but package didn't).
    # Demote it so it gets re-cooked. A 'done' is skipped at the top of the
    # build loop, so without this the bad state would never be re-examined.
    # WANTED splits are repacked with their parent -> leave their state alone.
    for name in order:
        if status.get(name) == 'done' and not pkgs[name].wanted \
                and name not in built_names:
            setst(name, 'todo')

    # Symmetric credit: a package the store still calls 'failed' but whose
    # .tazpkg is now in $PKGS really built -- typically cooked BY HAND to
    # clear a halt. The build-loop resume-credit below only covers non-WANTED
    # packages, so a hand-built WANTED split (gtk+-dev, repacked with gtk+)
    # would stay 'failed'/durably-broken forever and --continue never
    # re-examines a non-'done' that is actually built. built_names (the
    # .tazpkg exists) + not-in-cook's-broken is the proof it built.
    for name in order:
        if status.get(name) == 'failed' \
                and name in built_names and name not in broken:
            setst(name, 'done')

    # surface cycles in the state -- but credit cycle members already built
    # (e.g. cooked by hand to break the loop): they are not in `order`, so
    # the resume-from-artifact check below never sees them.
    for name in blocked:
        if status.get(name) == 'done':
            continue
        if name in built_names and name not in broken:
            setst(name, 'done')
        else:
            setst(name, 'blocked')

    def inwok_prereqs(name):
        p = pkgs[name]
        return [d for d in (p.bdeps + p.wanted) if d in universe]

    # reverse graph: dep -> packages that directly need it (in universe)
    rdeps = {n: [] for n in universe}
    for n in universe:
        for d in inwok_prereqs(n):
            rdeps[d].append(n)

    def pending_impact(name):
        """How many genuinely pending (todo/building) packages transitively
        depend on name. Failed/skipped ones are already settled: counting
        them would make the halt ever more nervous as cascade-skips pile up
        over a long run."""
        seen = set()
        stack = list(rdeps.get(name, ()))
        while stack:
            x = stack.pop()
            if x in seen:
                continue
            seen.add(x)
            stack.extend(rdeps.get(x, ()))
        return sum(1 for x in seen if status.get(x) in ('todo', 'building'))

    def halt(reason):
        with open(os.path.join(sdir, 'stop'), 'w') as fh:
            fh.write('%s\t%s\n' % (now(), reason))
        msg('\n*** HALT: %s ***\n' % reason)

    logpath = os.path.join(sdir, 'run.log')
    med = history.median_seconds() if history else 60.0

    def expected(n):
        s = history.get(n)['last_seconds'] if history else 0
        return s if s > 0 else med

    def eta_remaining():
        return sum(expected(n) for n in order
                   if status.get(n) in ('todo', 'building'))

    if not simulate:
        pend = sum(1 for n in order if status.get(n) == 'todo')
        msg('  ETA (rough)  : %s for %d pending\n'
            % (fmt_dur(eta_remaining()), pend))

    recent = []
    last_snap = [0.0]

    def snap(current='', force=False):
        if not force and time.time() - last_snap[0] < 2:
            return
        last_snap[0] = time.time()
        store.flush()
        write_progress(sdir, store, arch, store.backend, current=current,
                       eta=int(eta_remaining()), recent=recent[-15:])

    def log_recent(name, st, secs=0):
        recent.append({'name': name, 'status': st, 'seconds': int(secs)})

    prefetcher = None
    if prefetch_n and not simulate:
        prefetcher = Prefetcher(srcdir, msg,
                                os.path.join(sdir, 'prefetch-dead.tsv'))

    def feed_prefetch(start):
        """Queue the tarballs of the next prefetch_n pending packages."""
        fed = 0
        for nx in order[start:]:
            if fed >= prefetch_n:
                break
            if status.get(nx) == 'todo' \
                    and not any(fnmatch.fnmatch(nx, p) for p in skip):
                prefetcher.push(pkgs[nx])
                fed += 1

    snap(force=True)
    built = 0
    consec = 0          # consecutive failures (any package)
    consec_dl = 0       # consecutive src-download failures (network down?)
    consec_fast = 0     # consecutive INSTANT failures (cook itself broken?)
    try:
        for pos, name in enumerate(order):
            st = status.get(name)
            if st == 'done':
                continue
            # intentional skip-list (globs, e.g. linux* on the default branch
            # while the kernel is reworked elsewhere): never build, never halt;
            # dependents cascade-skip naturally.
            if any(fnmatch.fnmatch(name, pat) for pat in skip):
                setst(name, 'skipped', culprit='skip-list')
                log_recent(name, 'skipped')
                msg('skip   %s (skip-list)\n' % name)
                snap()
                continue
            # orphan split: its WANTED parent is gone from the wok (or can't
            # build on this arch), so the child can never be packed -- the
            # receipt is dead wood to clean up, not a build to attempt
            orphan = [w for w in pkgs[name].wanted
                      if w not in universe and w not in blocked]
            if orphan:
                setst(name, 'skipped', culprit='orphan-wanted:%s' % orphan[0])
                log_recent(name, 'skipped')
                msg('skip   %s (WANTED %s not buildable here)\n'
                    % (name, orphan[0]))
                snap()
                continue
            # resume from real artifacts (e.g. cooked by hand): non-WANTED only.
            # Proof is the .tazpkg in $PKGS (what cook needs for the dependents'
            # build deps), NOT a leftover wok taz/ dir -- a stale taz/ used to
            # mark a half-built prereq done and halt its dependents.
            if resume and st != 'building' and not pkgs[name].wanted \
                    and name in built_names and name not in broken:
                setst(name, 'done')
                continue
            # cascade skip: a prereq that didn't build
            bad = [d for d in inwok_prereqs(name)
                   if status.get(d) in ('failed', 'skipped', 'blocked')]
            if bad:
                setst(name, 'skipped', culprit=bad[0])
                log_recent(name, 'skipped')
                msg('skip   %s (prereq %s)\n' % (name, bad[0]))
                snap()
                continue
            # learned auto-skip: durably broken across past runs -- unless the
            # receipt was edited after the last failure (= someone fixed it)
            if history and not retry_broken:
                h = history.get(name)
                if h['last_status'] == 'failed' \
                        and h['consec_fails'] >= broken_after:
                    try:
                        rmt = os.stat(os.path.join(
                            wok, pkgs[name].wokdir, 'receipt')).st_mtime
                    except OSError:
                        rmt = 0
                    if rmt <= ts_epoch(h['last_ts']):
                        setst(name, 'skipped',
                              culprit='durably-broken x%d' % h['consec_fails'])
                        log_recent(name, 'skipped')
                        msg('skip   %s (durably broken x%d)\n'
                            % (name, h['consec_fails']))
                        snap()
                        continue
                    msg('retry  %s (durably broken x%d, but receipt edited)\n'
                        % (name, h['consec_fails']))

            # build, retrying on transient (network/fetch) failures
            base_attempts = store.get(name)['attempts']
            setst(name, 'building')
            if prefetcher:
                feed_prefetch(pos + 1)
            snap(current=name, force=True)
            ok = False
            dl_fail = False
            rescued = False
            healed = False
            tries = 0
            t0 = time.time()
            while True:
                tries += 1
                if simulate:
                    ok = (name not in fail and name not in dlfail
                          and not (name in transient and tries == 1))
                else:
                    rc = run_cook(name, logpath)
                    if rc == 127:   # no cook in PATH: environment, not package
                        setst(name, 'failed', culprit='no-cook')
                        halt('cook not found in PATH')
                        return
                    broken = broken_set(cache)
                    ok = (rc == 0) and (name not in broken)
                if ok:
                    break
                # transient (network blink)? retry BEFORE classifying as a
                # dead source -- the patterns overlap (DNS down looks dead)
                tr = (name in transient) if simulate else is_transient(logpath)
                if tr and tries < retries:
                    msg('retry  %s (transient, %d/%d)\n'
                        % (name, tries, retries))
                    continue
                # missing/stub tarball? seed it from the sources mirror and
                # re-cook once instead of failing on a dead upstream URL
                if not simulate and not rescued \
                        and rescue_source(pkgs[name], srcdir, msg):
                    rescued = True
                    continue
                # build ENVIRONMENT silently broke under this package? (e.g. a
                # removed kernel build-dep swept /usr/include/linux that
                # linux-api-headers still owns -> cpp sanity fails). Self-heal
                # and re-cook once instead of mis-blaming the package + halting.
                if not simulate and not healed and heal_environment(msg):
                    healed = True
                    continue
                dl_fail = (name in dlfail) if simulate else is_dl_fail(logpath)
                break
            secs = 0.0 if simulate else time.time() - t0
            setst(name, 'done' if ok else 'failed', seconds=secs,
                  attempts=base_attempts + tries,
                  culprit='src-download' if dl_fail else '')
            if history:
                history.record(name, ok, secs)
            log_recent(name, 'dlfail' if dl_fail
                       else ('done' if ok else 'failed'), secs)
            tag = 'ok' if ok else ('DLFAIL' if dl_fail else 'FAIL')
            msg('%-6s %s%s\n' % (tag, name,
                                 '' if simulate else ' (%s)' % fmt_dur(secs)))
            snap()
            built += 1
            consec = 0 if ok else consec + 1
            consec_dl = (consec_dl + 1 if dl_fail else 0) if not ok else 0
            # a real build failure takes time; dying instantly, many times
            # in a row, means cook/the environment is broken in a way the
            # toolchain canary can't see (seen: a cook deployed with its
            # @@PREFIX@@ placeholders unsubstituted -- 100+ 0s failures
            # while the canary kept saying the toolchain was healthy)
            fast = (not ok) and not simulate and secs < 5
            consec_fast = consec_fast + 1 if fast else 0

            # stop policy: critical list, impact threshold, failure streaks
            if not ok and not no_stop:
                if name in critical:
                    halt('critical package "%s" failed' % name)
                    return
                imp = pending_impact(name)
                if imp > impact_max:
                    halt('"%s" failed and blocks %d pending packages (> %d)'
                         % (name, imp, impact_max))
                    return
                if max_consec and consec_dl >= max_consec:
                    halt('%d source-download failures in a row ("%s" last)'
                         ' -- network down?' % (consec_dl, name))
                    return
                # 3x the generic threshold: small leaf packages can fail
                # fast for real, individual reasons (a 2s gcc error is
                # legitimate) -- systemic cook breakage hits dozens in a row
                if max_consec and consec_fast >= 3 * max_consec:
                    halt('%d instant (<5s) failures in a row ("%s" last) -- '
                         'cook itself broken? check %s'
                         % (consec_fast, name,
                            os.path.join(conf('LOGS') or '/home/slitaz/log',
                                         name + '.log')))
                    return
                if max_consec and consec >= max_consec:
                    # a failure streak is only an environment problem if the
                    # toolchain really is broken -- 5 siblings of one family
                    # dying of the same receipt bug is just an unlucky
                    # cluster (seen: uclibc-cross-compiler-arm*)
                    probs = [] if simulate else toolchain_canary()
                    if not probs and not simulate:
                        msg('note   %d failures in a row but the toolchain is '
                            'healthy: unlucky cluster, going on\n' % consec)
                        consec = 0
                    elif not simulate and heal_environment(msg):
                        # env was broken but SELF-HEALABLE (python3 swept by a
                        # py3k bdep removal; kernel uapi headers; binutils): a
                        # whole CLUSTER of env-breaking packages (the ~30 py3k
                        # consumers) would otherwise halt the run every 5. Repair
                        # and carry on -- the genuinely broken packages still
                        # fail+cascade-skip individually, but the run advances.
                        msg('note   %d failures in a row, env repaired '
                            '(%s) -- going on\n' % (consec, probs[0]))
                        consec = 0
                    else:
                        halt('%d failures in a row ("%s" last)%s'
                             % (consec, name,
                                ': ' + '; '.join(probs) if probs else
                                ' -- broken environment? (toolchain/network)'))
                        return

            if limit and built >= limit:
                msg('reached --limit %d\n' % limit)
                break
    finally:
        # always leave a fresh snapshot + flushed store, even on ^C/halt
        snap(force=True)


def write_progress(sdir, store, arch, backend, current='', eta=-1,
                   recent=None):
    """Snapshot the run state to progress.json for the web dashboard.

    A bare call (no current/eta/recent, e.g. from --status) must not blank the
    live panel: reuse the previous snapshot's current/eta/recent in that case."""
    c = store.counts()
    halt = None
    try:
        with open(os.path.join(sdir, 'stop')) as fh:
            halt = fh.read().strip()
    except OSError:
        pass
    if recent is None:
        try:
            with open(os.path.join(sdir, 'progress.json')) as fh:
                prev = json.load(fh)
            recent = prev.get('recent', [])
            if not current:
                current = prev.get('current', '')
            if eta < 0:
                eta = prev.get('eta_seconds', -1)
        except (OSError, ValueError):
            recent = []
    data = {'arch': arch, 'backend': backend, 'updated': now(),
            'total': sum(c.values()), 'counts': c, 'current': current,
            'eta_seconds': eta, 'halt': halt, 'recent': recent or []}
    tmp = os.path.join(sdir, 'progress.json.tmp')
    try:
        with open(tmp, 'w') as fh:
            json.dump(data, fh)
        os.replace(tmp, os.path.join(sdir, 'progress.json'))
    except OSError:
        pass


def cmd_status(store, sdir=None):
    c = store.counts()
    total = sum(c.values())
    out = sys.stdout.write
    out('cook-full state (%s)\n' % store.backend)
    if sdir:
        try:
            with open(os.path.join(sdir, 'stop')) as fh:
                out('  HALTED   : %s\n' % fh.read().strip())
        except OSError:
            pass
    out('  total    : %d\n' % total)
    for s in STATUSES:
        if c[s]:
            out('  %-8s : %d\n' % (s, c[s]))
    # split failures into dead-source (src-download) vs real build failures
    failed = store.by_status('failed')
    dl = [n for n in failed if store.get(n)['culprit'] == 'src-download']
    build = [n for n in failed if n not in dl]
    if dl:
        out('  src-download (dead URL): %s%s\n'
            % (' '.join(dl[:20]), ' ...' if len(dl) > 20 else ''))
    if build:
        out('  failed: %s%s\n'
            % (' '.join(build[:20]), ' ...' if len(build) > 20 else ''))
    for s in ('skipped', 'blocked'):
        names = store.by_status(s, 20)
        if names:
            out('  %s: %s%s\n' % (s, ' '.join(names),
                                  ' ...' if c[s] > 20 else ''))
    if sdir:
        try:
            with open(os.path.join(sdir, 'prefetch-dead.tsv')) as fh:
                dead = [l.split('\t')[1] for l in fh if '\t' in l]
            if dead:
                out('  prefetch found unreachable sources: %s\n'
                    % ' '.join(sorted(set(dead))))
        except OSError:
            pass


# Self-contained dashboard palette, shared look with web/cook-full.cgi (kept
# duplicated: the report ships as one standalone .html, no external assets).
REPORT_CSS = '''
:root{--bg:#1c1c1c;--fg:#dcdcdc;--dim:#8a8a8a;--accent:#ff8700;--ok:#5fd75f;
--err:#ff5f5f;--warn:#ffaf00;--card:#262626;--bar:#333}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.5 "DejaVu Sans Mono",monospace}
header{background:#111;padding:14px 20px;border-bottom:2px solid var(--accent)}
header h1{margin:0;font-size:20px;color:var(--accent)}
header span{color:var(--dim);font-size:13px}
main{padding:20px;max-width:980px;margin:0 auto}
h2{font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:1px;
margin:26px 0 8px;border-bottom:1px solid #333;padding-bottom:4px}
.barwrap{background:var(--bar);border-radius:6px;height:26px;overflow:hidden;
margin:6px 0 4px}
.bar{height:100%;background:linear-gradient(90deg,#4a7,#5fd75f);text-align:right;
color:#06250a;font-weight:bold;padding-right:8px;line-height:26px;white-space:nowrap}
.chips{display:flex;flex-wrap:wrap;gap:10px;margin:14px 0}
.chip{background:var(--card);border-radius:6px;padding:8px 14px;min-width:92px}
.chip b{display:block;font-size:20px}
.chip.done b{color:var(--ok)}.chip.failed b{color:var(--err)}
.chip.skipped b{color:var(--warn)}.chip.blocked b{color:#d75fff}
.chip.todo b{color:var(--dim)}.chip.building b{color:var(--accent)}
table{border-collapse:collapse;width:100%;margin:4px 0}
td,th{text-align:left;padding:4px 10px;border-bottom:1px solid #2c2c2c;
vertical-align:top}
th{color:var(--dim);font-weight:normal;font-size:12px}
td.pkg{color:var(--fg);white-space:nowrap}
td.note{color:#bdbdbd}
.cat{display:flex;flex-wrap:wrap;gap:8px}
.cat span{background:var(--card);border-radius:5px;padding:4px 10px;font-size:13px}
.cat b{color:var(--ok)}
.badge{display:inline-block;border-radius:4px;padding:1px 8px;font-size:12px;
font-weight:bold;color:#111;white-space:nowrap}
.a-fixed{background:var(--ok)}.a-fix{background:var(--warn)}
.a-wait{background:#5fafff}.a-drop{background:var(--err)}
.a-keep-blocked{background:#d75fff}.a-auto{background:#555;color:#ddd}
footer{color:var(--dim);padding:18px 20px;text-align:center;font-size:12px}
'''


def _auto_cat(name, logdir):
    """Best-effort failure category from the package's cook log tail."""
    lp = os.path.join(logdir, name + '.log')
    if not os.path.exists(lp):
        return ''
    if is_dl_fail(lp):
        return 'source download (dead URL)'
    if is_transient(lp):
        return 'transient / network'
    return 'build error'


def _cat_index(pkgsinfo):
    """{pkgname: CATEGORY} from packages.info (col 3); {} if absent."""
    idx = {}
    if pkgsinfo and os.path.exists(pkgsinfo):
        with open(pkgsinfo) as fh:
            for l in fh:
                f = l.rstrip('\n').split('\t')
                if len(f) >= 3:
                    idx[f[0]] = f[2]
    return idx


def build_report(store, triage, arch, pkgsinfo, logdir):
    """Render the standalone shareable HTML status report (string)."""
    esc = html.escape
    c = store.counts()
    total = sum(c.values()) or 1
    done = c.get('done', 0)
    pct = 100.0 * done / total
    b = []

    b.append('<div class="barwrap"><div class="bar" style="width:%.1f%%">%.1f%%'
             '</div></div>' % (max(pct, 6), pct))
    order = ('done', 'failed', 'skipped', 'blocked', 'building', 'todo')
    b.append('<div class="chips">%s</div>' % ''.join(
        '<div class="chip %s"><b>%d</b>%s</div>' % (k, c.get(k, 0), k)
        for k in order))

    # Scan skipped culprits once -- powers both the dev to-do (cascade leverage)
    # and the Skipped detail further down. culprit encodes WHY a pkg was skipped.
    skipped = store.by_status('skipped')
    buckets = {'skip': 0, 'broken': 0, 'orphan': 0, 'cascade': 0}
    roots = {}
    for n in skipped:
        cul = store.get(n)['culprit'] or ''
        if cul == 'skip-list':
            buckets['skip'] += 1
        elif cul.startswith('durably-broken'):
            buckets['broken'] += 1
        elif cul.startswith('orphan-wanted'):
            buckets['orphan'] += 1
        else:
            buckets['cascade'] += 1
            if cul:
                roots[cul] = roots.get(cul, 0) + 1

    def root_reason(root):
        r = store.get(root)
        st, cul = r['status'], r['culprit'] or ''
        if st == 'failed':
            return 'build failure'
        if cul.startswith('durably-broken'):
            return 'fails repeatedly'
        if cul == 'skip-list':
            return 'intentionally excluded'
        if cul.startswith('orphan-wanted'):
            return 'orphan split'
        if st == 'blocked':
            return 'blocked'
        return st or '?'

    # --- For developers: a concrete action plan, ranked by impact ---
    fixes = [(g, note) for g, a, _c, note in triage if a == 'fix']
    waits = [(g, note) for g, a, _c, note in triage if a == 'wait']
    levers = [(r, n, root_reason(r))
              for r, n in sorted(roots.items(), key=lambda kv: -kv[1])]
    levers = [x for x in levers
              if x[2] in ('build failure', 'fails repeatedly')][:12]
    b.append('<h2>For developers &mdash; what to do</h2>')
    todo = []
    if fixes:
        todo.append('<p class="note"><b>1. Known fixes to apply</b> '
                    '(the what-to-do is known):</p><table><tr><th>package</th>'
                    '<th>action</th></tr>%s</table>' % ''.join(
            '<tr><td class="pkg">%s</td><td class="note">%s</td></tr>'
            % (esc(g), esc(note)) for g, note in fixes))
    if levers:
        todo.append('<p class="note"><b>2. Fix first</b> (high leverage: '
                    'fixing the root unblocks its whole cascade):</p>'
                    '<table><tr><th>root package</th><th>unblocks</th>'
                    '<th>its state</th></tr>%s</table>' % ''.join(
            '<tr><td class="pkg">%s</td><td><b>%d</b> packages</td>'
            '<td class="note">%s</td></tr>' % (esc(r), n, esc(reason))
            for r, n, reason in levers))
    if waits:
        todo.append('<p class="note"><b>3. Decisions to make</b> (fix or drop '
                    '&mdash; not a code bug):</p><table><tr>'
                    '<th>package</th><th>context</th></tr>%s</table>' % ''.join(
            '<tr><td class="pkg">%s</td><td class="note">%s</td></tr>'
            % (esc(g), esc(note)) for g, note in waits))
    if buckets['broken']:
        todo.append('<p class="note">+ <b>%d packages failing repeatedly</b> to '
                    'investigate one by one (log: '
                    '<code>%s/&lt;package&gt;.log</code>).</p>'
                    % (buckets['broken'], esc(logdir)))
    b.append(''.join(todo) or '<p style="color:var(--dim)">&mdash;</p>')

    # Done -- summary by receipt CATEGORY (from packages.info), no name dump
    cats = {}
    catidx = _cat_index(pkgsinfo)
    for n in store.by_status('done'):
        cats[catidx.get(n, '(other)')] = cats.get(catidx.get(n, '(other)'), 0) + 1
    b.append('<h2>Done &mdash; %d packages</h2>' % done)
    if cats:
        b.append('<div class="cat">%s</div>' % ''.join(
            '<span>%s <b>%d</b></span>' % (esc(k), v)
            for k, v in sorted(cats.items(), key=lambda kv: -kv[1])))

    def rows(names, fallback_logcat=False):
        r = []
        for n in names:
            t = triage_for(n, triage)
            if t:
                action, cat, note = t
            elif fallback_logcat:
                action, cat, note = 'auto', _auto_cat(n, logdir), ''
            else:
                action, cat, note = 'auto', '', ''
            r.append('<tr><td class="pkg">%s</td><td><span class="badge a-%s">'
                     '%s</span></td><td>%s</td><td class="note">%s</td></tr>'
                     % (esc(n), esc(action), esc(action), esc(cat), esc(note)))
        return ('<table><tr><th>package</th><th>action</th><th>category</th>'
                '<th>note</th></tr>%s</table>' % ''.join(r)) if r else \
               '<p style="color:var(--dim)">&mdash;</p>'

    # Failures -- with fix idea / wait reason (triage), log-category fallback
    failed = store.by_status('failed')
    b.append('<h2>Failures &amp; possible fixes &mdash; %d</h2>' % len(failed))
    b.append(rows(failed, fallback_logcat=True))

    # Blocked -- intentional / dependency policy
    blocked = store.by_status('blocked')
    b.append('<h2>Blocked &mdash; %d</h2>' % len(blocked))
    b.append(rows(blocked))

    # Drops -- curated only (re-homed pkgs are no longer in the db)
    drops = [(g, cat, note) for g, a, cat, note in triage if a == 'drop']
    b.append('<h2>Dropped &amp; reasons &mdash; %d</h2>' % len(drops))
    if drops:
        b.append('<table><tr><th>package</th><th>category</th><th>reason</th>'
                 '</tr>%s</table>' % ''.join(
            '<tr><td class="pkg">%s</td><td>%s</td><td class="note">%s</td></tr>'
            % (esc(g), esc(cat), esc(note)) for g, cat, note in drops))
    else:
        b.append('<p style="color:var(--dim)">&mdash;</p>')

    # Skipped detail -- buckets/roots already computed up top (dev to-do reuses
    # them). Here we just explain the count, family by family.
    LEGEND = [
        ('skip', 'intentionally excluded',
         "data/skip: kernel, .ko modules, arch-specific packages"),
        ('broken', 'given up (fails repeatedly)',
         "fail on every attempt -- candidates to fix or drop"),
        ('cascade', 'cascade',
         "a prerequisite (build-dep / wanted) could not be built"),
        ('orphan', 'orphan splits',
         "sub-package whose parent no longer exists in the wok"),
    ]
    b.append('<h2>Skipped &mdash; %d</h2>' % len(skipped))
    b.append('<table><tr><th>reason</th><th>n</th><th>explanation</th></tr>%s'
             '</table>' % ''.join(
        '<tr><td class="pkg">%s</td><td>%d</td><td class="note">%s</td></tr>'
        % (esc(label), buckets[key], esc(expl))
        for key, label, expl in LEGEND if buckets[key]))
    if roots:
        top = sorted(roots.items(), key=lambda kv: -kv[1])[:15]
        b.append('<p class="note" style="margin:8px 0 2px">Cascade roots '
                 '(fixing one unblocks N packages):</p>')
        b.append('<div class="cat">%s</div>' % ''.join(
            '<span>%s <b>%d</b></span>' % (esc(k), v) for k, v in top))

    b.append('<footer>arch %s &middot; %d packages &middot; generated %s '
             '&middot; SliTaz cook-full</footer>'
             % (esc(arch), total, esc(now())))

    return ('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
            '<meta name="viewport" content="width=device-width,initial-scale=1">'
            '<title>Cook-Full — report</title><style>%s</style></head><body>'
            '<header><h1>Cook-Full — report</h1>'
            '<span>full SliTaz build status (%s)</span></header>'
            '<main>%s</main></body></html>'
            % (REPORT_CSS, esc(arch), ''.join(b)))


def cmd_report(store, out_path, triage, arch, pkgsinfo, logdir):
    page = build_report(store, triage, arch, pkgsinfo, logdir)
    with open(out_path, 'w') as fh:
        fh.write(page)
    sys.stderr.write('cook-full: report written to %s\n' % out_path)


def main():
    ap = argparse.ArgumentParser(
        prog='cook-full',
        description='Build all of SliTaz (M1 order engine + M2 driver).')
    ap.add_argument('--arch', help='target arch (default: cook.conf/host)')
    ap.add_argument('--wok', help='wok path (default: cook.conf $WOK)')
    ap.add_argument('--cookorder',
                    help='bootstrap order file (default: configs/cookorder)')
    ap.add_argument('--dry-run', action='store_true',
                    help='print the computed build order, cook nothing')
    ap.add_argument('--blocked', action='store_true',
                    help='with --dry-run: list blocked/cyclic packages')
    ap.add_argument('--check', action='store_true',
                    help='with --dry-run: verify the order invariant')
    ap.add_argument('--status', action='store_true',
                    help='print the saved build state and exit')
    ap.add_argument('--report', nargs='?', const='cook-full-report.html',
                    default=None, metavar='PATH',
                    help='write a standalone shareable HTML status report to '
                         'PATH (default cook-full-report.html) and exit; reads '
                         'the live db, no run needed')
    ap.add_argument('--restart', action='store_true',
                    help='wipe saved state before running')
    ap.add_argument('--backend', choices=('auto', 'sqlite', 'tsv'),
                    default='auto', help='state backend (default: auto)')
    ap.add_argument('--simulate', action='store_true',
                    help='drive the loop without cooking (test state machine); '
                         'uses its own state dir, never touches a real run')
    ap.add_argument('--fail', default='',
                    help='with --simulate: comma list of pkgs to mark failed')
    ap.add_argument('--limit', type=int, default=0,
                    help='stop after cooking N packages (testing)')
    ap.add_argument('--impact', type=int, default=20,
                    help='halt if a failure blocks > N pending pkgs (def 20)')
    ap.add_argument('--skip', default='',
                    help='comma list of globs to skip, e.g. "linux*" '
                         '(merged with data/skip); skipped pkgs never halt')
    ap.add_argument('--no-stop', action='store_true',
                    help='never halt: cook everything, just record failures')
    ap.add_argument('--consec', type=int, default=5,
                    help='halt after N consecutive failures, broken-environment'
                         ' guard (def 5, 0=off)')
    ap.add_argument('--no-canary', action='store_true',
                    help='skip the pre-run toolchain sanity check')
    ap.add_argument('--prefetch', type=int, default=8,
                    help='download the next N source tarballs in the '
                         'background while building (def 8, 0=off)')
    ap.add_argument('--continue', dest='cont', action='store_true',
                    help='clear a previous stop sentinel and resume')
    ap.add_argument('--critical',
                    help='critical-package list file (default: data/critical)')
    ap.add_argument('--no-resume', action='store_true',
                    help='ignore existing taz/ on disk (force full rebuild)')
    ap.add_argument('--retries', type=int, default=2,
                    help='max attempts on transient (network) failures (def 2)')
    ap.add_argument('--retry-broken', action='store_true',
                    help='also retry packages marked durably broken')
    ap.add_argument('--transient', default='',
                    help='with --simulate: pkgs that fail once then succeed')
    ap.add_argument('--dlfail', default='',
                    help='with --simulate: pkgs that fail as source-download')
    args = ap.parse_args()

    arch = args.arch or conf('ARCH') or host_arch()
    wok = args.wok or conf('WOK') or '/home/slitaz/wok'
    cache = conf('CACHE') or '/home/slitaz/cache'
    # --simulate gets its own state dir: it marks packages done/failed, so
    # sharing the real run's state.db would corrupt a build in progress.
    sdir = os.path.join(cache, 'cook-full-sim' if args.simulate
                        else 'cook-full')
    # realpath so a /usr/bin/cook-full symlink still finds ../configs/cookorder
    here = os.path.dirname(os.path.realpath(__file__))
    cookorder_path = args.cookorder
    if not cookorder_path:
        for cand in ('/etc/slitaz/cookorder',
                     os.path.join(here, '..', 'configs', 'cookorder')):
            if os.path.exists(cand):
                cookorder_path = cand
                break

    if args.status:
        os.makedirs(sdir, exist_ok=True)
        store = open_store(sdir, args.backend)
        cmd_status(store, sdir)
        write_progress(sdir, store, arch, store.backend)
        store.close()
        return

    if args.report is not None:
        os.makedirs(sdir, exist_ok=True)
        store = open_store(sdir, args.backend)
        triage = load_triage(os.path.join(here, 'data', 'triage.tsv'))
        pkgsinfo = os.path.join(conf('PKGS') or '/home/slitaz/packages',
                                'packages.info')
        logdir = conf('LOGS') or '/home/slitaz/log'
        cmd_report(store, args.report, triage, arch, pkgsinfo, logdir)
        store.close()
        return

    if not os.path.isdir(wok):
        sys.exit('cook-full: wok not found: %s' % wok)

    # The startup work (sourcing ~6000 receipts to build the dep graph, then
    # ordering) takes a while with no output otherwise -- announce each phase.
    # The mtime-keyed scan cache makes the usual fix-and-continue restart ~1s.
    msg = sys.stderr.write
    t0 = time.time()
    cachefile = None
    try:
        os.makedirs(sdir, exist_ok=True)
        cachefile = os.path.join(sdir, 'scan-cache.tsv')
    except OSError:
        pass        # e.g. --dry-run outside the chroot: scan without cache
    msg('cook-full: scanning wok %s (arch=%s)...\n' % (wok, arch))
    pkgs = scan_wok(wok, arch, cachefile)
    msg('  %d receipts scanned in %s\n' % (len(pkgs), fmt_dur(time.time() - t0)))

    t1 = time.time()
    msg('  computing build order (bootstrap + dependency waves)...\n')
    cookorder = read_cookorder(cookorder_path) if cookorder_path else []
    order, blocked = compute_order(pkgs, arch, cookorder)
    msg('  ordered %d, blocked %d in %s\n'
        % (len(order), len(blocked), fmt_dur(time.time() - t1)))

    included = sum(1 for p in pkgs.values() if arch_ok(p, arch))
    boot = [n for n in cookorder if n in pkgs and arch_ok(pkgs[n], arch)]
    wanted = sum(1 for n in order if pkgs[n].wanted)

    if args.dry_run:
        for name in order:
            print(name)
        msg('\n')
        msg('cook-full --dry-run  arch=%s  wok=%s\n' % (arch, wok))
        msg('  receipts scanned : %d\n' % len(pkgs))
        msg('  arch-included    : %d\n' % included)
        msg('  bootstrap (order): %d (from %s)\n'
            % (len(boot), cookorder_path or 'none'))
        msg('  ordered total    : %d  (incl. %d WANTED sub-packages)\n'
            % (len(order), wanted))
        msg('  blocked / cyclic : %d\n' % len(blocked))
        if args.check:
            pos = {name: i for i, name in enumerate(order)}
            universe = set(pos)
            boot_set = set(boot)
            iter_bad = 0
            boot_late = []   # (bootstrap pkg, its in-wok bdep ordered AFTER it)
            for name in order:
                for d in (pkgs[name].bdeps + pkgs[name].wanted):
                    if d in universe and pos[d] > pos[name]:
                        if name in boot_set:
                            boot_late.append((name, d))
                        else:
                            iter_bad += 1
                            msg('  ITER VIOLATION: %s before %s\n' % (name, d))
            msg('  iterative check  : %s (%d violations)\n'
                % ('OK' if iter_bad == 0 else 'FAILED', iter_bad))
            # These are IN-WOK build deps of a bootstrap (cookorder) package
            # ordered AFTER it: cookorder is taken verbatim and doesn't hoist
            # them, so cook falls back to the (maybe stale) mirror copy at build
            # time. Fix by adding the bdep to configs/cookorder before its user
            # (e.g. dietlibc before busybox). Critical-package ones matter most.
            msg('  bootstrap bdeps ordered late: %d (in-wok, not in cookorder)\n'
                % len(boot_late))
            crit = load_critical(None)
            hot = [(n, d) for n, d in boot_late if n in crit]
            if hot:
                msg('    affecting CRITICAL bootstrap pkgs:\n')
                for n, d in sorted(hot):
                    msg('      %s <- %s  (cook %s before %s)\n' % (n, d, d, n))
            if boot_late and args.blocked:
                msg('    all (bootstrap <- late in-wok bdep):\n')
                for n, d in sorted(boot_late):
                    msg('      %s <- %s\n' % (n, d))
            elif boot_late:
                msg('    (re-run with --blocked to list them all)\n')
        if blocked and args.blocked:
            msg('\n  blocked packages (unmet in-wok prereqs):\n')
            for name in sorted(blocked):
                msg('    %s <- %s\n' % (name, ' '.join(blocked[name]) or '?'))
        elif blocked:
            msg('  (re-run with --blocked to list them)\n')
        return

    # Real run (or --simulate). The agent only runs --simulate/--dry-run;
    # a plain run calls cook and is launched by pankso.
    os.makedirs(sdir, exist_ok=True)

    # one cook-full per state dir: a second concurrent run (forgotten tmux,
    # double --continue) would silently corrupt the state
    lockfile = os.path.join(sdir, 'pid')
    if os.path.exists(lockfile):
        try:
            with open(lockfile) as fh:
                pid = int(fh.read().strip())
            os.kill(pid, 0)
            sys.exit('cook-full: already running (pid %d, %s)'
                     % (pid, lockfile))
        except (ValueError, OSError):
            pass        # stale lock: no such process
    with open(lockfile, 'w') as fh:
        fh.write('%d\n' % os.getpid())

    stopfile = os.path.join(sdir, 'stop')
    if args.restart:
        for f in ('state.db', 'state.tsv', 'stop', 'progress.json'):
            try:
                os.remove(os.path.join(sdir, f))
            except OSError:
                pass
    if os.path.exists(stopfile):
        if args.cont:
            os.remove(stopfile)
        else:
            with open(stopfile) as fh:
                reason = fh.read().strip()
            sys.exit('cook-full: halted last run -> %s\n'
                     '  address it, then re-run with --continue (or '
                     '--restart)' % reason)

    crit_path = args.critical
    if not crit_path:
        cand = os.path.join(here, 'data', 'critical')
        if os.path.exists(cand):
            crit_path = cand
    critical = load_critical(crit_path)
    skip_path = os.path.join(here, 'data', 'skip')
    skip = load_skip(skip_path if os.path.exists(skip_path) else None, args.skip)

    # pre-flight canary: refuse to start cooking with a broken toolchain
    # (every package would fail one by one until a halt threshold trips)
    if not args.simulate and not args.no_canary:
        probs = toolchain_canary()
        if probs:
            sys.exit('cook-full: build environment is broken, not starting:\n'
                     + ''.join('  - %s\n' % p for p in probs)
                     + '  fix it (or --no-canary to override)')

    store = open_store(sdir, args.backend)
    store.sync_order(order)
    history = History(sdir, args.backend)
    fail = set(x for x in args.fail.split(',') if x)
    transient = set(x for x in args.transient.split(',') if x)
    dlfail = set(x for x in args.dlfail.split(',') if x)
    msg('cook-full %s  arch=%s  backend=%s  order=%d  blocked=%d\n'
        % ('SIMULATE' if args.simulate else 'run', arch, store.backend,
           len(order), len(blocked)))
    msg('  stop policy: %s, impact>%d, consec>=%s  | critical pkgs: %d  | '
        'retries: %d\n'
        % ('OFF (--no-stop)' if args.no_stop else 'ON', args.impact,
           args.consec or 'off', len(critical), args.retries))
    if skip:
        msg('  skip-list   : %s\n' % ' '.join(sorted(skip)))
    msg('  bootstrap   : %d pkgs from %s\n'
        % (len(boot), cookorder_path or 'NONE'))
    if not boot:
        msg('  WARNING: no cookorder bootstrap -> no toolchain-first salvo, '
            'expect many cyclic blocks. Check --cookorder.\n')
    try:
        drive(store, order, blocked, pkgs, arch, wok, cache, sdir,
              simulate=args.simulate, fail=fail, limit=args.limit,
              critical=critical, impact_max=args.impact, no_stop=args.no_stop,
              resume=not args.no_resume, history=history, retries=args.retries,
              retry_broken=args.retry_broken, transient=transient, skip=skip,
              dlfail=dlfail, max_consec=args.consec,
              srcdir=conf('SRC') or '/home/slitaz/src',
              prefetch_n=args.prefetch)
        msg('\n')
        cmd_status(store, sdir)
    finally:
        history.close()
        store.close()
        try:
            os.remove(lockfile)
        except OSError:
            pass


if __name__ == '__main__':
    main()
