"""Dockerfile generation, image building, container running, and testing"""

# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.

# %% auto #0
__all__ = ['fasthtml_app', 'mk_flags', 'Dockerfile', 'Cli', 'Docker', 'test', 'drun', 'containers', 'images', 'stop', 'logs',
           'rm', 'rmi', 'dict2str', 'service', 'Compose', 'python_app', 'fastapi_react', 'go_app', 'rust_app',
           'node_app', 'detect_app', 'env_set', 'env_get', 'secret_set', 'secret_get', 'secrets']

# %% ../nbs/00_core.ipynb #c7b52454175ab4d4
import re, json, os, yaml, keyring, time
from fastcore.all import listify, joins, is_listy, L, patch, concat, bind, Path, filter_values, run, merge, true

# %% ../nbs/00_core.ipynb #t1qbqvj053
def mk_flags(*a, short=True, sym='=', **kw):
    'Build CLI flag list: single-char kwargs → -k [v], multi-char → --key[=v]. short=False: all keys use --key[=v]'
    k2f = lambda k: f'--{k.rstrip("_").replace("_","-")}'
    flags, sc, uv, vt = list(a), lambda k: len(k) == 1, lambda v: v not in (True, False, None), lambda v: v is True
    if short:
        flags += concat([f'-{k}', str(v)] for k,v in kw.items() if sc(k) and uv(v))
        flags += [f'-{k}' for k,v in kw.items() if sc(k) and vt(v)]
        kw = {k: v for k,v in kw.items() if not sc(k)}
    flags += [f'{k2f(k)}{sym}{v}' for k,v in kw.items() if uv(v)]
    flags += [k2f(k) for k,v in kw.items() if vt(v)]
    return flags

# %% ../nbs/00_core.ipynb #112779e5025a332d
def _instr(kw, v): return '%s %s'%(kw,v)
def _run(cmd: list | str): return _instr('RUN', joins(' && ', listify(cmd)))
def _cmd(cmd: list | str): return _instr('CMD', json.dumps(cmd) if is_listy(cmd) else cmd)
def _add(src:str, dst:str): return _instr('ADD', f'%s %s'%(src,dst))
def _workdir(path): return _instr('WORKDIR', path)
def _env(k, v=None): return _instr('ENV','%s%s'%(k,f'={v}' if v else ''))
def _arg(nm, def_=None): return _instr('ARG','%s%s'%(nm,f'={def_}' if def_ else ''))
def _expose(port): return _instr('EXPOSE', str(port))
def _entrypoint(cmd): return _instr('ENTRYPOINT', json.dumps(cmd) if is_listy(cmd) else cmd)
def _label(**kw): return _instr('LABEL', joins(' ', (f'{k}="{v}"' for k, v in kw.items())))
def _user(u): return _instr('USER', u)
def _volume(path): return _instr('VOLUME', json.dumps(path) if is_listy(path) else path)
def _shell(cmd): return _instr('SHELL', json.dumps(cmd))
def _stop_sig_(sig): return _instr('STOPSIGNAL', sig)
def _on_build(ins): return _instr('ONBUILD', str(ins))

def _from(image:str, tag:str=None, as_:str=None) -> str:
	'From instruction -- base image with optional tag and alias'
	t,a = ':%s'%tag if tag else '', ' AS %s'%as_ if as_ else ''
	return _instr('FROM', '%s%s%s'%(image,t,a))

def _copy(src:str, dst:str, from_:str=None, link=False):
	'COPY instruction -- copy files into image'
	return _instr('COPY', ' '.join([*mk_flags(short=False, link=link, from_=from_), src, dst]))

def _apt_install(*pkgs, y=False, clean=True):
	'RUN apt-get update && apt-get install packages'
	f,ps = '-y ' if y else '', " ".join(pkgs)
	c = ' && rm -rf /var/lib/apt/lists/*' if clean else ''
	return _run(['apt-get update', f'apt-get install {f}{ps}{c}'])

def _healthcheck(cmd, i=None, t=None, r=None, sp=None):
	'HEALTHCHECK instruction -- container health check'
	opts = joins(' ', mk_flags(short=False, interval=i, timeout=t, retries=r, start_period=sp))
	c = json.dumps(cmd) if is_listy(cmd) else cmd
	return _instr('HEALTHCHECK', f'{opts} CMD {c}'.strip())

# %% ../nbs/00_core.ipynb #15ja7m700to
def _parse(path_or_str: Path | str):
    'Parse Dockerfile text into list of instruction strings'
    t = Path(path_or_str).read_text() if isinstance(path_or_str, Path) else path_or_str
    return L.splitlines(re.sub(r'\\\n\s*', '', t)).filter(lambda l: l.strip() and not l.strip().startswith('#'))

# %% ../nbs/00_core.ipynb #77afa11b44a44293
class Dockerfile(L):
    'Fluent builder for Dockerfiles'
    @classmethod
    def load(cls, path:Path=Path('Dockerfile')): return cls(_parse(Path(path)))
    def from_(self, base, tag=None, as_=None): return self._add(_from(base, tag, as_))
    def _add(self, i): return self._new(self.items + [i])
    def run(self, cmd): return self._add(_run(cmd))
    def cmd(self, cmd): return self._add(_cmd(cmd))
    def copy(self, src, dst, from_=None, link=False): return self._add(_copy(src, dst, from_, link))
    def add(self, src, dst): return self._add(_add(src, dst))
    def workdir(self, path='/app'): return self._add(_workdir(path))
    def env(self, key, value=None): return self._add(_env(key, value))
    def expose(self, port): return self._add(_expose(port))
    def entrypoint(self, cmd): return self._add(_entrypoint(cmd))
    def arg(self, name, default=None): return self._add(_arg(name, default))
    def label(self, **kwargs): return self._add(_label(**kwargs))
    def user(self, user): return self._add(_user(user))
    def volume(self, path): return self._add(_volume(path))
    def shell(self, cmd): return self._add(_shell(cmd))
    def healthcheck(self, cmd, **kw): return self._add(_healthcheck(cmd, **kw))
    def stopsignal(self, signal): return self._add(_stop_sig_(signal))
    def onbuild(self, instruction): return self._add(_on_build(instruction))
    def apt_install(self, *pkgs, y=True, clean=True):
	    return self._add(_apt_install(*pkgs, y=y, clean=clean))

    def run_mount(self, cmd, type='cache', target=None, **kw):
        'RUN --mount=... for build cache mounts (uv, pip, apt) and secrets'
        opts = f'type={type}'
        if target: opts += f',target={target}'
        for k, v in kw.items(): opts += f',{k.replace("_", "-")}={v}'
        return self._add(f'RUN --mount={opts} {cmd}')

    def __call__(self, inst, *args, **kw):
        'Build a generic Dockerfile instruction: kw ARG1 ARG2 --flag=val --bool-flag'
        rest = joins(' ', [*mk_flags(short=False, **kw), *map(str, args)])
        return self._add(f'%s %s'%(inst.upper(), rest))

    def __getattr__(self, nm):
        'Dispatch unknown instruction names: df.some_instr(arg) → SOME-INSTR arg'
        if nm.startswith('_'): raise AttributeError(nm)
        return bind(self, nm.upper().rstrip('_'))

    def __str__(self): return joins(chr(10),self)
    def __repr__(self): return str(self)

    def save(self, path:Path=Path('Dockerfile')):
        Path(path).mk_write(str(self))
        return self

# %% ../nbs/00_core.ipynb #whnvww0xy2b
class Cli:
    'Base: __call__ builds flags → _run(), __getattr__ dispatches subcommands'
    def __call__(self, cmd, *a, **kw): return self._run(cmd, *mk_flags(*a, **kw))
    def _run(self, cmd, *a): raise NotImplementedError
    def __getattr__(self, nm):
        if nm.startswith('_'): raise AttributeError(nm)
        return bind(self, nm.replace('_', '-'))

# %% ../nbs/00_core.ipynb #4abe618740a1c539
def _docker_cmd(*args, cli='docker'): return run(cli or 'docker', *args)

def _compose_cmd():
	'Return compose invocation style: ("docker", "compose") or ("docker-compose",)'
	try: return ('docker-compose',) if run('docker-compose', 'version') else ('docker', 'compose')
	except Exception: return 'docker', 'compose'

def _clean_cfg():
	'Create a docker config dir with credential helpers stripped, symlinking contexts and cli-plugins'
	src = Path(os.environ.get('DOCKER_CONFIG', Path.home()/'.docker'))
	dst = Path.home()/'.fastops'/'config'
	cfgf = dst/'config.json'
	if not cfgf.exists():
		dst.mkdir(parents=True, exist_ok=True)
		cfg = src.joinpath('config.json').read_json() if (src/'config.json').exists() else {}
		cfg.pop('credsStore', None); cfg.pop('credHelpers', None)
		cfgf.write_json(cfg)
	for name in ('contexts', 'cli-plugins'):
		link, target = dst/name, src/name
		if target.exists() and not link.exists(): link.symlink_to(target)
	return str(dst)

class Docker(Cli):
	'Unified docker CLI wrapper. no_creds strips credential helpers from config.'
	def __init__(self, no_creds=False): self.no_creds = no_creds
	def _run(self, cmd, *a): return _docker_cmd(*self._pre(), cmd, *a)

	def _pre(self):
		chk = os.environ.get('DOCKR_RUNTIME', 'docker') == 'docker' and self.no_creds
		return ('--config', _clean_cfg()) if chk else ()

	def compose(self, *a, f='docker-compose.yml'):
		'Run docker compose subcommand — auto-detects docker-compose vs docker compose'
		base, *sub = _compose_cmd()
		pre = self._pre() if sub else ()  # --config is a docker CLI flag; docker-compose doesn't support it
		return _docker_cmd(*pre, *sub, '-f', f, *a, cli=base)


# %% ../nbs/00_core.ipynb #ae10280a7aae03af
@patch
def build(df:Dockerfile, tag:str=None, path:str='.', no_creds=False, fn='Dockerfile'):
    'Build image from Dockerfile via docker compose build (uses daemon BuildKit, no buildx required).'
    import subprocess, tempfile
    df.save(Path(path) / fn)
    svc = {'build': {'context': str(Path(path).resolve())}}
    if tag: svc['image'] = tag
    with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f:
        f.write(yaml.dump({'services': {'img': svc}})); tmp = f.name
    try:
        pre = ['--config', _clean_cfg()] if no_creds else []
        res = subprocess.run(['docker'] + pre + ['compose', '-f', tmp, 'build'], capture_output=True)
        if res.returncode: raise IOError((res.stdout + b' ;; ' + res.stderr).decode().strip())
    finally:
        Path(tmp).unlink(missing_ok=True)
    return tag

def test(img_or_tag:str, cmd):
	'Run cmd in image, return True if exit code 0'
	try: Docker().run('--rm', str(img_or_tag), *listify(cmd)); return True
	except Exception: return False

def _container_running(name:str, timeout:float=2.0, poll:float=0.1) -> bool:
	'Poll until container is running or has exited. Returns True if running.'
	deadline = time.monotonic() + timeout
	while time.monotonic() < deadline:
		try: status = Docker()('inspect', '--format={{.State.Status}}', name).strip()
		except IOError: return False  # already removed (--rm) or never created
		if status == 'running': return True
		if status in ('exited', 'dead', 'removing'): return False
		time.sleep(poll)
	return True  # timed out without definitive state — optimistic

def drun(img:str, detach=False, ports=None, name=None, remove=False, command=None, check=True):
	'Run a container, return container ID (detached) or output. check=True raises on startup failure.'
	p_flags = concat(['-p', f'{hp}:{cp.split("/")[0]}'] for cp, hp in (ports or {}).items())
	result = Docker()('run', *p_flags, *mk_flags(d=detach, rm=remove, name=name), str(img), *listify(command or []))
	if detach and check:
		nm = name or result.strip()
		if not _container_running(nm):
			try: log = Docker()('logs', nm)
			except Exception: log = '(no logs available)'
			raise RuntimeError(f"Container {nm!r} failed to start.\n{log}")
	return result

# %% ../nbs/00_core.ipynb #c9fa9e9480de4c3f
def containers(all=False): return Docker().ps(format='{{.Names}}', a=all).splitlines()
def images(): return Docker().images(format='{{.Repository}}:{{.Tag}}').splitlines()
def stop(nm): Docker()('stop', nm)
def logs(nm, n=10): return Docker()('logs', f'--tail={n}', nm)
def rm(nm, force=False): Docker()('rm', *mk_flags(f=force), nm)
def rmi(nm, force=False): Docker()('rmi', *mk_flags(f=force), nm)

# %% ../nbs/00_core.ipynb #32772a234650ed9
def dict2str(d: dict, sep=':'): return [f'{k}{sep}{v}' for k, v in d.items()] if isinstance(d, dict) else d
def service(image=None, build=None, ports=None, env=None, volumes=None, depends_on=None, command=None, **kw):
	'Create a docker-compose service dict'
	if isinstance(build, Dockerfile): build = '.'
	return filter_values(dict(image=image, command=command, depends_on=depends_on,
	                          ports=dict2str(ports), build=build, environment=dict2str(env, '='),
	                          volumes=dict2str(volumes)), lambda v: v is not None) | kw

# %% ../nbs/00_core.ipynb #55ce7f669533f474
class Compose(L):
	'Fluent builder for docker-compose.yml files'
	def _add(self, item): return self._new(self.items + [item])
	def _collect(self, tag): return {n: c for t, n, c in self if t == tag}
	def save(self, path='docker-compose.yml'): Path(path).write_text(str(self)); return self
	def svc(self, name, **kw): return self._add(('services', name, service(**kw)))
	def network(self, name, **kw): return self._add(('networks', name, kw or None))
	def volume(self, name, **kw): return self._add(('volumes', name, kw or None))

	@classmethod
	def load(cls, path='docker-compose.yml'):
		'Load an existing docker-compose.yml'
		d = yaml.safe_load(Path(path).read_text())
		it = [('services', n, c) for n, c in (d.get('services') or {}).items()]
		it += [('networks', n, c) for n, c in (d.get('networks') or {}).items()]
		it += [('volumes', n, c) for n, c in (d.get('volumes') or {}).items()]
		return cls(it)

	def to_dict(self):
		'Render full compose config as dict'
		d = {'services': self._collect('services')}
		if nets := self._collect('networks'): d['networks'] = nets
		if vols := self._collect('volumes'):  d['volumes'] = vols
		return d

	def up(self, detach=True, path='docker-compose.yml', no_creds=False, **kw):
		'Run docker compose up'
		self.save(path)
		return Docker(no_creds).compose('up', *mk_flags(d=detach, **kw), f=path)

	def down(self, path='docker-compose.yml', no_creds=False, **kw):
		'Run docker compose down'
		return Docker(no_creds).compose('down', *mk_flags(**kw), f=path)

	def logs(self, path='docker-compose.yml', no_creds=False, **kw):
		'Run docker compose logs'
		return Docker(no_creds).compose('logs', *mk_flags(**kw), f=path)

	def __str__(self): return yaml.dump(self.to_dict(), default_flow_style=False, sort_keys=False)
	def __repr__(self): return str(self)

# %% ../nbs/00_core.ipynb #f56e4e9f534ac2ca
@patch
def inst_uv(self:Dockerfile, req=False, wd='/app'):
	'Single-stage uv install: copy uv binary and sync deps. req=True uses requirements.txt instead of pyproject.toml'
	self = self.copy('/uv', '/usr/local/bin/uv', from_='ghcr.io/astral-sh/uv:latest')
	if req: return self.copy('requirements.txt', '.').run('uv pip install --system -r requirements.txt')
	return self.copy('pyproject.toml', '.').run('uv sync --no-dev').env('PATH', f'{wd}/.venv/bin:$PATH')

@patch
def with_uv(self:Dockerfile, uv_image, image, workdir):
	'Multistage uv builder: appends uv builder stage then runtime base onto df'
	return (self.from_(uv_image, as_='builder').workdir(workdir)
			.env('UV_COMPILE_BYTECODE', '1').env('UV_LINK_MODE', 'copy')
			.copy('pyproject.toml', '.').copy('uv.lock', '.')
			.run_mount('uv sync --frozen --no-dev --no-install-project', target='/root/.cache/uv')
			.copy('.', '.').run_mount('uv sync --frozen --no-dev', target='/root/.cache/uv')
			.from_(image).workdir(workdir))

@patch
def packages(self:Dockerfile, *pkgs):
	'Install system packages with apt-get. pkgs can be a list or space-separated string.'
	return self.apt_install(*pkgs) if pkgs else self

# %% ../nbs/00_core.ipynb #e17b41b26d006536
def python_app(port=8000, cmd=None, im='python:3.13-slim', wd='/app', pkgs=None,
    vols=None, multistage=True, uv_image='ghcr.io/astral-sh/uv:python3.13-bookworm', healthcheck=None, req=False):
    'Python app Dockerfile. multistage=True (default): uv-builder → slim. False: single-stage with uv binary copy. req=True: use requirements.txt instead of pyproject.toml (forces multistage=False).'
    if multistage:
        df = Dockerfile().with_uv(uv_image, im, wd).copy(wd, wd, from_='builder').env('PATH', f'{wd}/.venv/bin:$PATH').packages(*listify(pkgs))
    else: df = Dockerfile().from_(im).workdir(wd).packages(*listify(pkgs)).inst_uv(req=req, wd=wd).copy('.', '.')
    if vols: df = df.run('mkdir -p ' + ' '.join(listify(vols)))
    if healthcheck: df = df.healthcheck(f'curl -f http://localhost:{port}{healthcheck}', i='30s', t='5s', r='3')
    _cmd = cmd or ['python', 'main.py']
    return df.expose(port).cmd(_cmd if isinstance(_cmd, list) else _cmd.split())

def fastapi_react(port=8000, node_version='20', frontend_dir='frontend', build_dir='dist',
    image='python:3.13-slim', pkgs=None, healthcheck='/health', multistage=False,
    uv_image='ghcr.io/astral-sh/uv:python3.13-bookworm'):
    'Two-stage (default) or three-stage (multistage=True) Dockerfile: Node.js frontend + Python/FastAPI backend'
    df = (Dockerfile().from_(f'node:{node_version}-slim', as_='frontend')
          .workdir('/build').copy(f'{frontend_dir}/package*.json', '.')
          .run('npm ci').copy(frontend_dir, '.').run('npm run build'))
    if multistage: df = df.with_uv(uv_image, image, '/app').copy('/app', '/app', from_='builder').env('PATH', '/app/.venv/bin:$PATH').packages(*listify(pkgs))
    else: df = df.from_(image).workdir('/app').packages(*listify(pkgs)).inst_uv(wd='/app').copy('.', '.')
    df = df.copy(f'/build/{build_dir}', '/app/static', from_='frontend')
    if healthcheck: df = df.healthcheck(f'curl -f http://localhost:{port}{healthcheck}', i='30s', t='5s', r='3')
    return df.expose(port).cmd(['uvicorn', 'main:app', '--host', '0.0.0.0', f'--port={port}'])

def go_app(port=8080, go_version='1.22', binary='app', runtime='gcr.io/distroless/static', cmd=None, cgo=False):
    'Two-stage Go Dockerfile: go compiler + go mod cache → distroless runtime'
    df = (Dockerfile().from_(f'golang:{go_version}-alpine', as_='builder')
          .workdir('/src').copy('go.mod', '.').copy('go.sum', '.')
          .run_mount('go mod download', target='/go/pkg/mod')
          .copy('.', '.').env('CGO_ENABLED', '0' if not cgo else '1')
          .run(f'go build -ldflags="-s -w" -o /{binary} .').from_(runtime)
          .copy(f'/{binary}', f'/{binary}', from_='builder').expose(port))
    return df.cmd(cmd or [f'/{binary}'])

def rust_app(port=8080, rust_version='1', binary='app', runtime='gcr.io/distroless/static', features=None):
    'Two-stage Rust Dockerfile: cargo build --release → distroless runtime'
    build_cmd = 'cargo build --release' + (f' --features {features}' if features else '')
    df = (Dockerfile().from_(f'rust:{rust_version}-slim-bookworm', as_='builder').workdir('/src')
          .copy('.', '.').run_mount(build_cmd, target='/usr/local/cargo/registry').from_(runtime)
          .copy(f'/src/target/release/{binary}', f'/{binary}', from_='builder').expose(port))
    return df.cmd([f'/{binary}'])

def node_app(port=3000, node_version='20', cmd=None, build_cmd='npm run build', static=False):
    'Node.js Dockerfile. static=True does two-stage build → serve dist/; False runs node directly.'
    df = (Dockerfile().from_(f'node:{node_version}-slim', as_='builder' if static else None)
          .workdir('/app').copy('package*.json', '.').run('npm ci').copy('.', '.'))
    if static:
        return (df.run(build_cmd).from_(f'node:{node_version}-slim').workdir('/app')
                .run('npm install -g serve').copy('/app/dist', '.', from_='builder')
                .expose(port).cmd(cmd or ['serve', '-s', '.', '-l', str(port)]))
    return df.expose(port).cmd(cmd or ['node', 'index.js'])

fasthtml_app = bind(python_app, port=5001, cmd=['python', 'app.py'], multistage=False)

def detect_app(path='.', multistage=True, **kw):
    'A naive project type detector from path and return the appropriate Dockerfile. for **kw lookup other app builders'
    p = Path(path)
    has = lambda f: (p/f).exists()
    read = lambda f: (p/f).read_text().lower() if has(f) else ''
    pyp, req= has('pyproject.toml'), has('requirements.txt')
    has_py = pyp or req
    if has('Cargo.toml'): return rust_app(**kw)
    if has('go.mod'): return go_app(**kw)
    if has('package.json') and has_py: return fastapi_react(multistage=multistage, **kw)
    if has('package.json'): return node_app(**kw)
    if pyp and 'python-fasthtml' in read('pyproject.toml'): return fasthtml_app(multistage=multistage, **kw)
    if pyp: return python_app(multistage=multistage, **kw)
    if req: return python_app(multistage=False, req=True, **kw)
    raise ValueError(f'Cannot detect project type in {path!r}')

# %% ../nbs/00_core.ipynb #5f5a4229dae3d2c4
_FASTOPS_ENV = Path.home() / '.config' / 'fastops' / '.env'

def env_set(key, value, path=None):
    'Upsert key=value into fastops .env file and os.environ. Returns True if changed.'
    from dotenv import get_key, set_key
    p = Path(path or _FASTOPS_ENV)
    p.parent.mkdir(parents=True, exist_ok=True)
    if not p.exists(): p.touch(); os.chmod(p, 0o600)
    if get_key(str(p), key) != str(value): set_key(str(p), key, str(value))
    os.environ[key] = str(value)

def env_get(key, path=None, default=None):
    'Read key: os.environ first, then fastops .env file, then default.'
    val = os.environ.get(key)
    if val is not None: return val
    from dotenv import get_key
    return get_key(str(Path(path or _FASTOPS_ENV)), key) or default

# %% ../nbs/00_core.ipynb #f98d154fe212c161
def secret_set(key, value, service='fastops', save=True, path=None):
	'Store secret in OS keychain (if available) and os.environ. Silent fallback on headless/VPS.'
	try: keyring.set_password(service, key, value)
	except Exception: pass
	if save: env_set(key, value, path=path)
	return True

def secret_get(key, service='fastops', default=None, path=None):
	'Read secret: OS keychain → os.environ → default.'
	try: return keyring.get_password(service, key) or default
	except Exception: pass
	return env_get(key, path=path, default=default)

# %% ../nbs/00_core.ipynb #x6lph8464h
def secrets(*keys, service='fastops', path=None):
    'Read multiple secrets from keychain/env into a dict. Warns and skips any that are missing.'
    return filter_values(merge(*L(keys).map(lambda k: {k:secret_get(k, service=service, path=path)})),true)
