#!/usr/bin/env python3
import argparse
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath


EXPECTED_SELF = Path("/usr/local/sbin/nodedc-deploy")
DEPLOY_ROOT = Path("/volume1/docker/nodedc-deploy")
INBOX = DEPLOY_ROOT / "inbox"
APPLIED_DIR = DEPLOY_ROOT / "applied"
FAILED_DIR = DEPLOY_ROOT / "failed"
BACKUPS_DIR = DEPLOY_ROOT / "backups"
STATE_DIR = DEPLOY_ROOT / "state"
TMP_DIR = DEPLOY_ROOT / ".tmp"
STATE_FILE = STATE_DIR / "applied.jsonl"
FAILED_STATE_FILE = STATE_DIR / "failed.jsonl"
LOCK_DIR = STATE_DIR / "deploy.lock"
DOCKER = Path("/usr/local/bin/docker")

MAX_ARTIFACT_BYTES = 512 * 1024 * 1024
MAX_MEMBER_COUNT = 20000
MAX_PAYLOAD_BYTES = 1024 * 1024 * 1024
MAX_FILE_BYTES = 256 * 1024 * 1024

PATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
MANIFEST_KEYS = {"id", "component", "type"}

COMPONENTS = {
    "engine": {
        "payload_root": Path("/volume2/nodedc-demo"),
        "compose_root": Path("/volume2/nodedc-demo"),
        "services": ("nodedc-backend", "app"),
        "compose_no_deps": True,
        "publish_dist": True,
        "healthchecks": (
            "http://127.0.0.1:8080/",
            "http://127.0.0.1:3001/health",
        ),
    },
    "launcher": {
        "payload_root": Path("/volume1/docker/nodedc-platform/launcher/source"),
        "build_root": Path("/volume1/docker/nodedc-platform/launcher/source"),
        "build": ("build", "--no-cache", "-t", "nodedc/launcher:local", "."),
        "compose_root": Path("/volume1/docker/nodedc-platform/platform"),
        "compose_env_file": Path("/volume1/docker/nodedc-platform/platform/.env.synology"),
        "compose_files": (
            Path("/volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml"),
        ),
        "compose_no_deps": True,
        "services": ("launcher",),
        "healthchecks": (
            {
                "url": "http://127.0.0.1:18080/healthz",
                "headers": {"Host": "hub.nodedc.ru"},
            },
        ),
    },
    "platform": {
        "payload_root": Path("/volume1/docker/nodedc-platform"),
        "compose_root": Path("/volume1/docker/nodedc-platform/platform"),
        "compose_env_file": Path("/volume1/docker/nodedc-platform/platform/.env.synology"),
        "compose_files": (
            Path("/volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml"),
        ),
        "compose_no_deps": True,
        "services": ("notification-postgres", "notification-core", "ai-workspace-hub", "launcher", "reverse-proxy"),
        "healthchecks": (
            "http://127.0.0.1:5185/healthz",
            "http://127.0.0.1:18081/healthz",
            {
                "url": "http://127.0.0.1:18080/healthz",
                "headers": {"Host": "hub.nodedc.ru"},
            },
            {
                "url": "http://127.0.0.1:18080/",
                "headers": {"Host": "id.nodedc.ru"},
            },
        ),
    },
    "tasker": {
        "payload_root": Path("/volume1/docker/nodedc-platform/tasker"),
        "compose_root": Path("/volume1/docker/nodedc-platform/tasker/plane-app"),
        "compose_project": "nodedc-tasker",
        "compose_env_file": Path("/volume1/docker/nodedc-platform/tasker/plane-app/.env.synology"),
        "compose_files": (
            Path("/volume1/docker/nodedc-platform/tasker/plane-app/docker-compose.yaml"),
            Path("/volume1/docker/nodedc-platform/tasker/plane-app/docker-compose.synology.override.yml"),
        ),
        "services": ("api", "worker", "beat-worker", "web"),
        "healthchecks": (
            {
                "url": "http://127.0.0.1:18090/",
                "headers": {"Host": "ops.nodedc.ru"},
            },
        ),
    },
    "ops-agents": {
        "payload_root": Path("/volume1/docker/nodedc-platform/ops-agents"),
        "compose_root": Path("/volume1/docker/nodedc-platform/ops-agents"),
        "compose_env_file": Path("/volume1/docker/nodedc-platform/ops-agents/.env"),
        "compose_files": (
            Path("/volume1/docker/nodedc-platform/ops-agents/docker-compose.synology.yml"),
        ),
        "compose_build": True,
        "compose_no_deps": True,
        "services": ("agent-gateway",),
        "healthchecks": (
            "http://172.22.0.222:18190/readyz",
        ),
    },
    "bim-viewer": {
        "payload_root": Path("/volume1/docker/nodedc-platform/bim-viewer/source"),
        "build_root": Path("/volume1/docker/nodedc-platform/bim-viewer/source/converter"),
        "build": ("build", "--no-cache", "-t", "nodedc/bim-converter:local", "."),
        "compose_root": Path("/volume1/docker/nodedc-platform/bim-viewer/source"),
        "compose_env_file": Path("/volume1/docker/nodedc-platform/bim-viewer/source/.env"),
        "compose_files": (
            Path("/volume1/docker/nodedc-platform/bim-viewer/source/docker-compose.beam.yml"),
        ),
        "compose_no_deps": True,
        "services": ("ndc-beam-viewer", "nodedc-bim-converter"),
        "healthchecks": (
            "http://127.0.0.1:18100/api/auth/session",
        ),
    },
    "dc-cms": {
        "payload_root": Path("/volume1/docker/dc-cms/source"),
        "compose_root": Path("/volume1/docker/dc-cms/source/infra"),
        "compose_env_file": Path("/volume1/docker/dc-cms/source/infra/.env.synology"),
        "compose_files": (
            Path("/volume1/docker/dc-cms/source/infra/docker-compose.yml"),
        ),
        "bootstrap_root": True,
        "compose_build": True,
        "services": ("postgresql-authentik", "authentik-server", "authentik-worker", "authentik-bootstrap", "cms-app", "reverse-proxy"),
        "healthchecks": (
            {
                "url": "http://172.22.0.222:9918/auth/login?returnTo=%2F",
                "headers": {"Host": "cms.dcserve.ru"},
            },
            {
                "url": "http://172.22.0.222:9919/",
                "headers": {"Host": "auth.dcserve.ru"},
            },
        ),
    },
    "dc-cms-site-nodedc": {
        "payload_root": Path("/volume1/docker/dc-cms/sites/nodedc"),
        "compose_root": Path("/volume1/docker/dc-cms/source/infra"),
        "compose_env_file": Path("/volume1/docker/dc-cms/source/infra/.env.synology"),
        "compose_files": (
            Path("/volume1/docker/dc-cms/source/infra/docker-compose.yml"),
        ),
        "compose_no_deps": True,
        "services": ("cms-app", "reverse-proxy"),
        "healthchecks": (
            {
                "url": "http://172.22.0.222:9918/auth/login?returnTo=%2F",
                "headers": {"Host": "cms.dcserve.ru"},
            },
        ),
    },
}


class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


NO_REDIRECT_OPENER = urllib.request.build_opener(NoRedirectHandler)


class DeployError(Exception):
    pass


def die(message):
    raise DeployError(message)


def utc_now():
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def stamp():
    return datetime.now().strftime("%Y%m%d-%H%M%S")


def sha256_file(path):
    import hashlib

    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def safe_name(value):
    return re.sub(r"[^A-Za-z0-9._-]", "_", value)


def is_relative_to(child, parent):
    try:
        child.relative_to(parent)
        return True
    except ValueError:
        return False


def ensure_layout():
    for path in (INBOX, APPLIED_DIR, FAILED_DIR, BACKUPS_DIR, STATE_DIR, TMP_DIR):
        path.mkdir(parents=True, exist_ok=True)

    for path in (STATE_DIR, BACKUPS_DIR, TMP_DIR):
        os.chown(path, 0, 0)
        path.chmod(0o700)

    for path in (APPLIED_DIR, FAILED_DIR):
        os.chown(path, 0, 0)
        path.chmod(0o755)


def require_root():
    if os.geteuid() != 0:
        die("run with sudo")


def verify_file_mode(path, require_root_owner):
    try:
        st = path.lstat()
    except FileNotFoundError:
        die(f"missing path: {path}")

    if stat.S_ISLNK(st.st_mode):
        die(f"path must not be a symlink: {path}")

    if require_root_owner and (st.st_uid != 0 or st.st_gid != 0):
        die(f"path must be root:root: {path}")

    if st.st_mode & stat.S_IWGRP:
        die(f"path must not be group-writable: {path}")

    if st.st_mode & stat.S_IWOTH:
        die(f"path must not be world-writable: {path}")


def verify_install():
    invoked = Path(sys.argv[0]).absolute()
    if invoked != EXPECTED_SELF:
        die(f"runner must be invoked as {EXPECTED_SELF}, current: {invoked}")

    verify_file_mode(EXPECTED_SELF, require_root_owner=True)
    verify_file_mode(Path("/usr/local"), require_root_owner=True)
    verify_file_mode(Path("/usr/local/sbin"), require_root_owner=True)

    print(f"path={EXPECTED_SELF}")
    print(f"sha256={sha256_file(EXPECTED_SELF)}")
    print("verify-install-ok")


def validate_artifact_location(path):
    if path.suffix != ".tgz":
        die("artifact must have .tgz extension")

    try:
        st = path.lstat()
    except FileNotFoundError:
        die(f"artifact not found: {path}")

    if stat.S_ISLNK(st.st_mode):
        die(f"artifact must not be a symlink: {path}")

    if not stat.S_ISREG(st.st_mode):
        die(f"artifact must be a regular file: {path}")

    if st.st_size > MAX_ARTIFACT_BYTES:
        die(f"artifact too large: {st.st_size} bytes")

    inbox_real = INBOX.resolve()
    parent_real = path.parent.resolve()
    if parent_real != inbox_real:
        die(f"artifact must be inside {INBOX}")

    name = path.name
    if "/" in name or ".." in name or name.startswith("."):
        die(f"unsafe artifact filename: {name}")


def validate_posix_path(value):
    if not value:
        die("empty path")
    if "\\" in value:
        die(f"backslash is not allowed in path: {value}")
    pure = PurePosixPath(value)
    if pure.is_absolute():
        die(f"absolute path rejected: {value}")
    if any(part in ("", ".", "..") for part in pure.parts):
        die(f"path escape rejected: {value}")
    if any(ord(ch) < 32 for ch in value):
        die(f"control character rejected in path: {value!r}")
    return pure


def tar_name_to_path(work_dir, name):
    pure = validate_posix_path(name)
    target = work_dir.joinpath(*pure.parts)
    resolved = target.resolve(strict=False)
    if not is_relative_to(resolved, work_dir.resolve()):
        die(f"tar path escaped work dir: {name}")
    return target


def validate_tar_member(member):
    name = member.name
    validate_posix_path(name)

    if name not in ("manifest.env", "files.txt", "payload") and not name.startswith("payload/"):
        die(f"unexpected tar member: {name}")

    if not (member.isfile() or member.isdir()):
        die(f"unsupported tar member type: {name}")

    if member.mode & stat.S_ISUID or member.mode & stat.S_ISGID:
        die(f"setuid/setgid tar member rejected: {name}")

    if member.isfile() and member.size > MAX_FILE_BYTES:
        die(f"file too large in artifact: {name}")


def scan_tar(artifact):
    member_count = 0
    payload_bytes = 0
    names = set()

    with tarfile.open(artifact, "r:gz") as tar:
        for member in tar:
            member_count += 1
            if member_count > MAX_MEMBER_COUNT:
                die("too many files in artifact")

            validate_tar_member(member)
            if member.name in names:
                die(f"duplicate tar member rejected: {member.name}")
            names.add(member.name)

            if member.isfile() and member.name.startswith("payload/"):
                payload_bytes += member.size
                if payload_bytes > MAX_PAYLOAD_BYTES:
                    die("payload is too large")

    if "manifest.env" not in names:
        die("manifest.env missing")
    if "files.txt" not in names:
        die("files.txt missing")
    if not any(name == "payload" or name.startswith("payload/") for name in names):
        die("payload missing")


def safe_extract(artifact, work_dir):
    scan_tar(artifact)

    with tarfile.open(artifact, "r:gz") as tar:
        for member in tar:
            validate_tar_member(member)
            target = tar_name_to_path(work_dir, member.name)

            if member.isdir():
                target.mkdir(parents=True, exist_ok=True)
                target.chmod(0o755)
                continue

            if target.exists() and target.is_dir():
                die(f"file target already exists as directory: {member.name}")

            target.parent.mkdir(parents=True, exist_ok=True)
            source = tar.extractfile(member)
            if source is None:
                die(f"cannot read tar member: {member.name}")

            tmp = target.with_name(f"{target.name}.extracting")
            with source, tmp.open("wb") as out:
                shutil.copyfileobj(source, out, 1024 * 1024)
            tmp.chmod(0o644)
            os.replace(tmp, target)


def parse_manifest(path):
    data = {}
    with path.open("r", encoding="utf-8") as f:
        for lineno, line in enumerate(f, 1):
            line = line.rstrip("\n")
            if not line:
                continue
            if "=" not in line:
                die(f"manifest line {lineno} must be key=value")
            key, value = line.split("=", 1)
            if key not in MANIFEST_KEYS:
                die(f"unsupported manifest key: {key}")
            if key in data:
                die(f"duplicate manifest key: {key}")
            if any(ord(ch) < 32 for ch in value):
                die(f"control character in manifest value: {key}")
            data[key] = value

    missing = sorted(MANIFEST_KEYS - set(data))
    if missing:
        die(f"manifest keys missing: {', '.join(missing)}")

    if not PATCH_ID_RE.match(data["id"]):
        die("manifest id contains unsafe characters")

    if data["type"] != "app-overlay":
        die(f"unsupported artifact type: {data['type']}")

    if data["component"] not in COMPONENTS:
        die(f"unsupported component: {data['component']}")

    return data


def parse_files_list(path):
    entries = []
    seen = set()
    with path.open("r", encoding="utf-8") as f:
        for lineno, raw in enumerate(f, 1):
            rel = raw.rstrip("\n")
            if not rel:
                continue
            if rel.strip() != rel:
                die(f"files.txt line {lineno} has leading/trailing whitespace")
            validate_posix_path(rel)
            if rel in seen:
                die(f"duplicate files.txt entry: {rel}")
            seen.add(rel)
            entries.append(rel)

    if not entries:
        die("files.txt is empty")

    return entries


def denied_payload_path(component, rel):
    lower = rel.lower()
    parts = lower.split("/")
    base = parts[-1]

    if component == "engine" and rel == "docker-compose.yml":
        pass
    elif component == "platform" and rel in (
        "platform/docker-compose.platform-http.yml",
        "platform/notification-core/Dockerfile",
        "platform/ai-workspace-hub/Dockerfile",
        "platform/ai-workspace-assistant/Dockerfile",
    ):
        pass
    elif component == "bim-viewer" and rel in (
        "converter/Dockerfile",
    ):
        pass
    elif component == "dc-cms" and rel in (
        "Dockerfile",
        "infra/docker-compose.yml",
    ):
        pass
    elif base in (".env", "dockerfile", "docker-compose.yml", "compose.yml"):
        return "sensitive or infrastructure filename"

    if base.endswith(".env") or base.endswith((".pem", ".crt", ".key", ".p12", ".pfx")):
        return "secret-like file extension"

    if base.endswith((".sh", ".bash", ".zsh")):
        return "shell files are not allowed in app-overlay artifacts"

    if base.endswith(".zip"):
        return "zip backup files are not deployable artifacts"

    if any(part in (".git", "node_modules") for part in parts):
        return "repository/build dependency directory"

    if any(token in lower for token in ("secret", "token", "password")):
        return "secret-like path"

    runtime_prefixes = ()
    if component == "engine":
        runtime_prefixes = (
            "nodedc-source/server/data",
            "nodedc-source/server/storage",
            "nodedc-source/server/logs",
            "nodedc-source/public/storage",
            "nodedc-source/dist/storage",
        )
        if rel == "nodedc-source/server/.env":
            return "server env file"
    elif component == "launcher":
        runtime_prefixes = (
            "server/data",
            "server/storage",
            "server/logs",
            "public/storage",
            "dist/storage",
        )
        if rel == "server/.env":
            return "server env file"
    elif component == "platform":
        runtime_prefixes = (
            "backups",
            "launcher",
            "ops-agents",
            "tasker",
            "platform/.env.synology",
            "platform/authentik",
            "platform/docs",
            "authentik/data",
            "authentik/certs",
            "authentik/media",
            "authentik/postgresql",
        )
        if rel.startswith("platform/.env"):
            return "platform env file"
        if rel.startswith(("platform/Caddyfile.http.bak", "platform/docker-compose.platform-http.yml.bak")):
            return "platform backup file"
    elif component == "tasker":
        runtime_prefixes = (
            "plane-app/.local-web-root",
            "plane-app/archive",
            "plane-app/backup",
            "plane-app/plane.env",
            "plane-app/plane.env.example",
            "plane-app/plane.env.staging.example",
            "plane-app/plane.env.synology",
            "plane-app/plane.env.synology.base",
            "plane-src/.cache",
            "plane-src/.next",
            "plane-src/.pnpm-store",
            "plane-src/.react-router",
            "plane-src/.turbo",
            "plane-src/build",
            "plane-src/coverage",
            "plane-src/dist",
            "plane-src/playwright-report",
            "plane-src/test-results",
        )
        if rel.startswith(("plane-app/docker-compose.yaml.bak", "plane-app/docker-compose.synology.override.yml.bak")):
            return "tasker backup file"
    elif component == "ops-agents":
        runtime_prefixes = (
            "dist",
        )
        if rel.startswith(".env"):
            return "ops-agents env file"
    elif component == "bim-viewer":
        runtime_prefixes = (
            "dist",
            "frontend/dist",
            "server/data",
            "server/logs",
            "server/storage",
            "server/tmp",
        )
        if rel == ".env" or rel.startswith(".env."):
            if rel != ".env.synology.example":
                return "bim-viewer env file"
        if rel.startswith(("docker-compose.beam.yml.bak",)):
            return "bim-viewer backup file"
    elif component == "dc-cms":
        runtime_prefixes = (
            "admin/uploads",
            "infra/authentik/certs",
            "infra/authentik/data",
            "infra/authentik/media",
            "infra/authentik/postgresql",
            "server/data",
            "server/logs",
            "server/storage",
            "sites",
        )
        if rel == ".env" or rel.startswith(".env."):
            return "dc-cms root env file"
        if rel in ("infra/.env", "infra/.env.synology"):
            return "dc-cms env file"
        if rel.startswith("infra/.env.") and rel not in (
            "infra/.env.example",
            "infra/.env.synology.example",
        ):
            return "dc-cms env file"
        if rel.startswith(("Dockerfile.bak", "infra/docker-compose.yml.bak")):
            return "dc-cms backup file"
    elif component == "dc-cms-site-nodedc":
        runtime_prefixes = (
            "assets/.trash",
            "assets/uploads/.trash",
            "node_modules",
        )

    if any(rel == prefix or rel.startswith(prefix + "/") for prefix in runtime_prefixes):
        return "runtime data path"

    return None


def allowed_payload_path(component, rel):
    validate_posix_path(rel)
    reason = denied_payload_path(component, rel)
    if reason:
        die(f"path rejected: {rel}: {reason}")

    if component == "engine":
        if rel in ("nodedc-source/package.json", "nodedc-source/package-lock.json"):
            return True
        if rel == "docker-compose.yml":
            return True
        if rel.startswith((
            "nodedc-source/server/",
            "nodedc-source/src/",
            "nodedc-source/dist/",
            "nodedc-source/public/",
            "nodedc-source/workers/ai-workspace-bridge/",
        )):
            return True
        if rel == "nodedc-source/dist":
            return True

    if component == "launcher":
        if rel in (
            "index.html",
            "package.json",
            "package-lock.json",
            "tsconfig.json",
            "tsconfig.app.json",
            "tsconfig.node.json",
            "vite.config.ts",
        ):
            return True
        if rel.startswith(("server/", "src/", "public/", "scripts/", "dc-ui-guideline/")):
            return True

    if component == "platform":
        if rel in (
            "platform/Caddyfile.http",
            "platform/docker-compose.platform-http.yml",
        ):
            return True
        if rel == "platform/notification-core" or rel.startswith("platform/notification-core/"):
            return True
        if rel == "platform/ai-workspace-hub" or rel.startswith("platform/ai-workspace-hub/"):
            return True
        if rel == "platform/ai-workspace-assistant" or rel.startswith("platform/ai-workspace-assistant/"):
            return True
        if rel == "authentik/custom-templates" or rel.startswith("authentik/custom-templates/"):
            return True

    if component == "tasker":
        if rel in (
            "plane-app/docker-compose.yaml",
            "plane-app/docker-compose.synology.override.yml",
        ):
            return True
        if rel.startswith((
            "plane-src/apps/api/",
            "plane-src/apps/web/",
            "plane-src/packages/",
        )):
            return True

    if component == "ops-agents":
        if rel in (
            ".dockerignore",
            ".env.synology.example",
            ".gitignore",
            "Dockerfile",
            "README.md",
            "docker-compose.local.yml",
            "docker-compose.synology.yml",
            "package.json",
            "package-lock.json",
            "tsconfig.json",
        ):
            return True
        if rel.startswith((
            "docs/",
            "migrations/",
            "src/",
        )):
            return True

    if component == "bim-viewer":
        if rel in (
            "converter",
            "embeddedDemos",
            "frontend",
            "images",
            "locales",
            "processor",
            "report",
            "src",
        ):
            return True
        if rel in (
            ".env.synology.example",
            ".esdoc.json",
            ".gitattributes",
            ".gitignore",
            ".release-please-manifest.json",
            "CHANGELOG.md",
            "CODE_OF_CONDUCT.md",
            "LICENSE",
            "README.md",
            "SECURITY.md",
            "_config.yml",
            "changelog-template.hbs",
            "docker-compose.beam.yml",
            "embeddedViewer.html",
            "index.js",
            "package.json",
            "package-lock.json",
            "release-please-config.json",
            "rollup.config.js",
            "rollup.dev.config.js",
            "webComponentExample.html",
            "xeokit-bim-viewer.css",
        ):
            return True
        if rel.startswith((
            "converter/",
            "embeddedDemos/",
            "frontend/",
            "images/",
            "locales/",
            "processor/",
            "report/",
            "server/",
            "src/",
        )):
            return True

    if component == "dc-cms":
        if rel in (
            ".dockerignore",
            ".gitignore",
            "Dockerfile",
            "README.md",
            "package.json",
            "package-lock.json",
            "infra/.env.example",
            "infra/.env.synology.example",
            "infra/docker-compose.yml",
            "infra/authentik/bootstrap-cms.py",
        ):
            return True
        if rel.startswith((
            "admin/",
            "infra/authentik/custom-templates/",
            "infra/reverse-proxy/",
            "projects/",
            "server/",
        )):
            return True

    if component == "dc-cms-site-nodedc":
        if rel in (
            ".gitignore",
            "README.md",
            "asset-manifest.json",
            "index.html",
            "package.json",
            "package-lock.json",
            "robots.txt",
            "sitemap.xml",
        ):
            return True
        if rel.startswith((
            "assets/",
            "content/",
            "knowledge/",
            "templates/",
            "tools/",
        )):
            return True

    die(f"path allowlist rejected: {rel}")


def path_is_covered_by_files_list(rel, entries):
    return any(rel == entry or rel.startswith(entry.rstrip("/") + "/") for entry in entries)


def validate_payload_tree(component, payload_dir, entries):
    root = payload_dir.resolve()
    for path in payload_dir.rglob("*"):
        resolved = path.resolve(strict=False)
        if not is_relative_to(resolved, root):
            die(f"payload path escaped payload dir: {path}")

        rel = path.relative_to(payload_dir).as_posix()
        if path.is_symlink():
            die(f"payload symlink rejected: {rel}")
        if not path.is_file() and not path.is_dir():
            die(f"payload special file rejected: {rel}")

        if path.is_dir():
            continue

        allowed_payload_path(component, rel)
        if not path_is_covered_by_files_list(rel, entries):
            die(f"payload file is not covered by files.txt: {rel}")


def load_artifact(artifact, work_dir):
    safe_extract(artifact, work_dir)

    manifest_path = work_dir / "manifest.env"
    files_path = work_dir / "files.txt"
    payload_dir = work_dir / "payload"

    if not manifest_path.is_file():
        die("manifest.env missing after extract")
    if not files_path.is_file():
        die("files.txt missing after extract")
    if not payload_dir.is_dir():
        die("payload directory missing after extract")

    manifest = parse_manifest(manifest_path)
    entries = parse_files_list(files_path)

    for rel in entries:
        allowed_payload_path(manifest["component"], rel)
        if not (payload_dir / rel).exists():
            die(f"files.txt entry missing in payload: {rel}")

    validate_payload_tree(manifest["component"], payload_dir, entries)
    return manifest, entries, payload_dir


def load_state(path):
    if not path.exists():
        return []
    rows = []
    with path.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            rows.append(json.loads(line))
    return rows


def append_jsonl(path, row):
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as f:
        f.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")


def state_has_sha(sha):
    return any(row.get("sha256") == sha for row in load_state(STATE_FILE))


def state_has_patch_id(patch_id):
    return any(row.get("id") == patch_id for row in load_state(STATE_FILE))


class DeployLock:
    def __enter__(self):
        try:
            LOCK_DIR.mkdir()
        except FileExistsError:
            die(f"deploy lock is busy: {LOCK_DIR}")
        (LOCK_DIR / "pid").write_text(str(os.getpid()), encoding="utf-8")
        return self

    def __exit__(self, exc_type, exc, tb):
        shutil.rmtree(LOCK_DIR, ignore_errors=True)


def component_root(component):
    return COMPONENTS[component]["payload_root"]


def component_compose_root(component):
    return COMPONENTS[component].get("compose_root", component_root(component))


def component_compose_project(component):
    return COMPONENTS[component].get("compose_project")


def component_services(component, entries=None):
    if component == "dc-cms" and entries is not None:
        selected = []

        def add(*services):
            for service in services:
                if service not in selected:
                    selected.append(service)

        touches_compose = any(rel == "infra/docker-compose.yml" for rel in entries)
        touches_proxy = any(rel == "infra/reverse-proxy" or rel.startswith("infra/reverse-proxy/") for rel in entries)
        touches_authentik = any(rel == "infra/authentik" or rel.startswith("infra/authentik/") for rel in entries)
        touches_app = any(
            rel in ("Dockerfile", "package.json", "package-lock.json", "README.md")
            or rel.startswith(("admin/", "projects/", "server/"))
            for rel in entries
        )

        if touches_compose:
            return COMPONENTS[component]["services"]
        if touches_authentik:
            add("postgresql-authentik", "authentik-server", "authentik-worker", "authentik-bootstrap", "cms-app", "reverse-proxy")
        if touches_app:
            add("cms-app")
        if touches_proxy:
            add("reverse-proxy")
        if selected:
            return tuple(selected)

    if component == "tasker" and entries is not None:
        selected = ["api", "worker", "beat-worker", "web"]
        touches_compose = any(
            rel in ("plane-app/docker-compose.yaml", "plane-app/docker-compose.synology.override.yml")
            for rel in entries
        )
        touches_proxy = any(rel == "plane-src/apps/proxy/Caddyfile.ce" for rel in entries)
        if touches_compose or touches_proxy:
            selected.append("proxy")
        return tuple(selected)

    if component == "platform" and entries is not None:
        selected = []

        def add(*services):
            for service in services:
                if service not in selected:
                    selected.append(service)

        touches_compose = any(rel == "platform/docker-compose.platform-http.yml" for rel in entries)
        touches_caddy = any(rel == "platform/Caddyfile.http" for rel in entries)
        touches_notification = any(rel == "platform/notification-core" or rel.startswith("platform/notification-core/") for rel in entries)
        touches_ai_workspace = any(rel == "platform/ai-workspace-hub" or rel.startswith("platform/ai-workspace-hub/") for rel in entries)
        touches_ai_workspace_assistant = any(rel == "platform/ai-workspace-assistant" or rel.startswith("platform/ai-workspace-assistant/") for rel in entries)
        touches_authentik = any(rel == "authentik/custom-templates" or rel.startswith("authentik/custom-templates/") for rel in entries)

        if touches_notification or touches_compose:
            add("notification-postgres", "notification-core", "launcher", "reverse-proxy")
        if touches_ai_workspace or touches_compose:
            add("ai-workspace-hub", "reverse-proxy")
        if touches_ai_workspace_assistant or touches_compose:
            add("ai-workspace-postgres", "ai-workspace-assistant")
        if touches_authentik:
            add("authentik-server", "authentik-worker", "reverse-proxy")
        if touches_caddy:
            add("reverse-proxy")

        if selected:
            return tuple(selected)

    return COMPONENTS[component]["services"]


def component_compose_no_deps(component, entries=None):
    if component == "dc-cms" and entries is not None:
        touches_compose = any(rel == "infra/docker-compose.yml" for rel in entries)
        touches_proxy = any(rel == "infra/reverse-proxy" or rel.startswith("infra/reverse-proxy/") for rel in entries)
        touches_authentik = any(rel == "infra/authentik" or rel.startswith("infra/authentik/") for rel in entries)
        return not (touches_compose or touches_proxy or touches_authentik)

    return bool(COMPONENTS[component].get("compose_no_deps"))


def component_compose_files(component):
    return COMPONENTS[component].get("compose_files", ())


def component_compose_env_file(component):
    return COMPONENTS[component].get("compose_env_file")


def component_build_root(component):
    return COMPONENTS[component].get("build_root")


def component_build_args(component, entries=None):
    if component == "platform" and entries is not None:
        touches_notification = any(rel == "platform/notification-core" or rel.startswith("platform/notification-core/") for rel in entries)
        return COMPONENTS[component].get("build") if touches_notification else None

    if component == "bim-viewer" and entries is not None:
        touches_converter = any(rel == "converter" or rel.startswith("converter/") for rel in entries)
        return COMPONENTS[component].get("build") if touches_converter else None

    return COMPONENTS[component].get("build")


def component_builds(component, entries=None):
    if component == "platform" and entries is not None:
        touches_compose = any(rel == "platform/docker-compose.platform-http.yml" for rel in entries)
        touches_notification = any(rel == "platform/notification-core" or rel.startswith("platform/notification-core/") for rel in entries)
        touches_ai_workspace = any(rel == "platform/ai-workspace-hub" or rel.startswith("platform/ai-workspace-hub/") for rel in entries)
        touches_ai_workspace_assistant = any(rel == "platform/ai-workspace-assistant" or rel.startswith("platform/ai-workspace-assistant/") for rel in entries)
        builds = []
        if touches_notification or touches_compose:
            builds.append((
                Path("/volume1/docker/nodedc-platform/platform/notification-core"),
                ("build", "--no-cache", "-t", "nodedc/notification-core:local", "."),
            ))
        if touches_ai_workspace or touches_compose:
            builds.append((
                Path("/volume1/docker/nodedc-platform/platform/ai-workspace-hub"),
                ("build", "--no-cache", "-t", "nodedc/ai-workspace-hub:local", "."),
            ))
        if touches_ai_workspace_assistant or touches_compose:
            builds.append((
                Path("/volume1/docker/nodedc-platform/platform/ai-workspace-assistant"),
                ("build", "--no-cache", "-t", "nodedc/ai-workspace-assistant:local", "."),
            ))
        return tuple(builds)

    if component == "tasker":
        plane_src = Path("/volume1/docker/nodedc-platform/tasker/plane-src")
        return (
            (
                plane_src / "apps/api",
                (
                    "build",
                    "--network=host",
                    "-t",
                    "nodedc/plane-backend:local",
                    "-f",
                    "Dockerfile.api",
                    ".",
                ),
            ),
            (
                plane_src,
                (
                    "build",
                    "--network=host",
                    "--build-arg",
                    "VITE_NODEDC_LAUNCHER_URL=https://hub.nodedc.ru",
                    "--build-arg",
                    "VITE_NODEDC_OIDC_LOGIN_ENABLED=1",
                    "-t",
                    "nodedc/plane-frontend:ru",
                    "-f",
                    "apps/web/Dockerfile.web.nas-legacy",
                    ".",
                ),
            ),
        )

    build_root = component_build_root(component)
    build_args = component_build_args(component, entries)
    if build_root and build_args:
        return ((build_root, build_args),)
    return ()


def plan_artifact(artifact):
    validate_artifact_location(artifact)
    ensure_layout()

    sha = sha256_file(artifact)
    with tempfile.TemporaryDirectory(prefix="plan-", dir=TMP_DIR) as tmp:
        manifest, entries, _payload_dir = load_artifact(artifact, Path(tmp))

    component = manifest["component"]
    root = component_root(component)
    compose_root = component_compose_root(component)
    services = component_services(component, entries)
    builds = component_builds(component, entries)

    print("== plan ==")
    print(f"artifact={artifact.name}")
    print(f"sha256={sha}")
    print(f"id={manifest['id']}")
    print(f"component={component}")
    print(f"type={manifest['type']}")
    print(f"payload_root={root}")
    print(f"compose_root={compose_root}")
    compose_project = component_compose_project(component)
    if compose_project:
        print(f"compose_project={compose_project}")
    for build_root, build_args in builds:
        print(f"build_root={build_root}")
        print(f"build={' '.join((str(DOCKER),) + tuple(build_args))}")
    print(f"services={' '.join(services)}")
    if state_has_sha(sha):
        print("state=sha-already-applied")
    elif state_has_patch_id(manifest["id"]):
        print("state=patch-id-already-applied")
    else:
        print("state=new")
    print("== files ==")
    for rel in entries:
        print(f"  {rel}")


def add_backup_path(tar, root, rel):
    src = root / rel
    if src.exists():
        tar.add(src, arcname=rel, recursive=True)
        return True
    return False


def create_backup(root, backup_dir, entries, include_nginx_html):
    existing = []
    missing = []

    with tarfile.open(backup_dir / "source-before.tgz", "w:gz") as tar:
        for rel in entries:
            if add_backup_path(tar, root, rel):
                existing.append(rel)
            else:
                missing.append(rel)

        if include_nginx_html and (root / "nginx-html").exists():
            tar.add(root / "nginx-html", arcname="nginx-html", recursive=True)
            existing.append("nginx-html")

    (backup_dir / "existing-files.txt").write_text("\n".join(existing) + ("\n" if existing else ""), encoding="utf-8")
    (backup_dir / "missing-files.txt").write_text("\n".join(missing) + ("\n" if missing else ""), encoding="utf-8")


def replace_file(src, dst, current_stamp):
    dst.parent.mkdir(parents=True, exist_ok=True)
    tmp = dst.with_name(f"{dst.name}.next-{current_stamp}")
    if tmp.exists():
        die(f"staging path already exists: {tmp}")
    shutil.copy2(src, tmp)
    os.replace(tmp, dst)


def replace_directory(src, dst, current_stamp):
    dst.parent.mkdir(parents=True, exist_ok=True)
    next_path = dst.with_name(f"{dst.name}.next-{current_stamp}")
    prev_path = dst.with_name(f"{dst.name}.prev-{current_stamp}")
    if next_path.exists() or prev_path.exists():
        die(f"staging path already exists near: {dst}")
    shutil.copytree(src, next_path)
    if dst.exists():
        dst.rename(prev_path)
    next_path.rename(dst)


def copy_payload_path(payload_dir, root, rel, current_stamp):
    src = payload_dir / rel
    dst = root / rel
    root_real = root.resolve()
    dst_real = dst.resolve(strict=False)
    if not is_relative_to(dst_real, root_real):
        die(f"target escaped component root: {rel}")

    if src.is_dir():
        replace_directory(src, dst, current_stamp)
    elif src.is_file():
        replace_file(src, dst, current_stamp)
    else:
        die(f"payload path is neither file nor directory: {rel}")


def publish_engine_dist(root, current_stamp):
    dist = root / "nodedc-source" / "dist"
    if not dist.is_dir():
        return

    dst = root / "nginx-html"
    next_path = root / f"nginx-html.next-{current_stamp}"
    prev_path = root / f"nginx-html.prev-{current_stamp}"
    if next_path.exists() or prev_path.exists():
        die("nginx-html staging path already exists")

    shutil.copytree(dist, next_path)
    if dst.exists():
        dst.rename(prev_path)
    next_path.rename(dst)


def run_build(component, entries=None):
    for build_root, build_args in component_builds(component, entries):
        dockerfile = "Dockerfile"
        for idx, arg in enumerate(build_args):
            if arg == "-f" and idx + 1 < len(build_args):
                dockerfile = build_args[idx + 1]
                break

        if not (build_root / dockerfile).is_file():
            die(f"Dockerfile not found in build root: {build_root / dockerfile}")

        cmd = [str(DOCKER), *build_args]
        env = os.environ.copy()
        if component == "tasker":
            env["DOCKER_BUILDKIT"] = "0"
        subprocess.run(cmd, cwd=str(build_root), env=env, check=True)


def compose_base_cmd(component):
    cmd = [str(DOCKER), "compose"]
    compose_project = component_compose_project(component)
    if compose_project:
        cmd.extend(["-p", compose_project])
    env_file = component_compose_env_file(component)
    if env_file:
        cmd.extend(["--env-file", str(env_file)])
    for compose_file in component_compose_files(component):
        cmd.extend(["-f", str(compose_file)])
    return cmd


def run_compose(component, services, entries=None):
    compose_root = component_compose_root(component)
    cmd = [*compose_base_cmd(component), "up", "-d", "--force-recreate"]
    if COMPONENTS[component].get("compose_build"):
        cmd.append("--build")
    if component_compose_no_deps(component, entries):
        cmd.append("--no-deps")
    cmd.extend(services)
    try:
        subprocess.run(cmd, cwd=str(compose_root), check=True)
    except subprocess.CalledProcessError:
        subprocess.run(
            [*compose_base_cmd(component), "logs", "--no-color", "--tail=180", *services],
            cwd=str(compose_root),
            check=False,
        )
        raise
    subprocess.run([*compose_base_cmd(component), "ps"], cwd=str(compose_root), check=True)


def prepare_component_runtime(component):
    if component in ("dc-cms", "dc-cms-site-nodedc"):
        (Path("/volume1/docker/dc-cms/sites") / "nodedc").mkdir(parents=True, exist_ok=True)
        return

    if component != "bim-viewer":
        return

    data_root = component_root(component) / "server" / "data"
    for rel in ("uploads", "projects", "shares", "models", "comments"):
        (data_root / rel).mkdir(parents=True, exist_ok=True)


def run_component_runtime(component, entries, services):
    run_build(component, entries)
    prepare_component_runtime(component)
    run_compose(component, services, entries)


def healthcheck_url(check):
    headers = {}
    if isinstance(check, dict):
        url = check.get("url")
        headers = check.get("headers") or {}
    else:
        url = check

    last_error = None
    for _attempt in range(1, 61):
        try:
            request = urllib.request.Request(url, headers=headers)
            with NO_REDIRECT_OPENER.open(request, timeout=10) as response:
                if 200 <= response.status < 400:
                    return
                last_error = f"HTTP {response.status}"
        except urllib.error.HTTPError as exc:
            if 200 <= exc.code < 400:
                return
            last_error = f"HTTP {exc.code}"
        except Exception as exc:
            last_error = str(exc)
        time.sleep(5)
    die(f"healthcheck failed for {url}: {last_error}")


def component_healthchecks(component, entries=None):
    checks = list(COMPONENTS[component].get("healthchecks", ()))
    if component == "platform" and entries is not None:
        touches_compose = any(rel == "platform/docker-compose.platform-http.yml" for rel in entries)
        touches_ai_workspace_assistant = any(rel == "platform/ai-workspace-assistant" or rel.startswith("platform/ai-workspace-assistant/") for rel in entries)
        if touches_compose or touches_ai_workspace_assistant:
            checks.append("http://127.0.0.1:18082/healthz")
    return tuple(checks)


def run_healthchecks(component, entries=None):
    for url in component_healthchecks(component, entries):
        healthcheck_url(url)


def move_artifact(src, target_dir, suffix=""):
    target_dir.mkdir(parents=True, exist_ok=True)
    name = src.name + suffix
    dst = target_dir / name
    if dst.exists():
        dst = target_dir / f"{src.name}.{stamp()}"
    src.rename(dst)
    return dst


def apply_artifact(artifact):
    require_root()
    validate_artifact_location(artifact)
    ensure_layout()

    sha = sha256_file(artifact)
    backup_id = None
    manifest = None
    apply_started = False
    current_stamp = stamp()

    with DeployLock():
        try:
            with tempfile.TemporaryDirectory(prefix=f"apply-{current_stamp}-", dir=TMP_DIR) as tmp:
                work = Path(tmp)
                manifest, entries, payload_dir = load_artifact(artifact, work)
                patch_id = manifest["id"]
                component = manifest["component"]
                root = component_root(component)
                compose_root = component_compose_root(component)
                bootstrap_root = bool(COMPONENTS[component].get("bootstrap_root"))
                services = component_services(component, entries)

                if state_has_sha(sha):
                    die(f"artifact sha already applied: {sha}")
                if state_has_patch_id(patch_id):
                    die(f"patch id already applied: {patch_id}")
                if not root.is_dir():
                    if bootstrap_root:
                        root.mkdir(parents=True, exist_ok=True)
                    else:
                        die(f"component payload root not found: {root}")
                if not compose_root.is_dir():
                    if bootstrap_root and is_relative_to(compose_root.resolve(strict=False), root.resolve(strict=False)):
                        compose_root.mkdir(parents=True, exist_ok=True)
                    else:
                        die(f"component compose root not found: {compose_root}")
                compose_files = component_compose_files(component)
                if compose_files:
                    for compose_file in compose_files:
                        if not compose_file.is_file():
                            compose_entry = None
                            if is_relative_to(compose_file.resolve(strict=False), root.resolve(strict=False)):
                                compose_entry = compose_file.resolve(strict=False).relative_to(root.resolve(strict=False)).as_posix()
                            if not bootstrap_root or compose_entry not in entries:
                                die(f"compose file not found: {compose_file}")
                elif not (compose_root / "docker-compose.yml").is_file():
                    die(f"docker-compose.yml not found in component compose root: {compose_root}")
                compose_env_file = component_compose_env_file(component)
                if compose_env_file and not compose_env_file.is_file():
                    die(f"compose env file not found: {compose_env_file}")
                if not DOCKER.is_file():
                    die(f"docker not found: {DOCKER}")

                backup_id = safe_name(f"{component}-{patch_id}-{current_stamp}")
                backup_dir = BACKUPS_DIR / backup_id
                backup_dir.mkdir(parents=True, exist_ok=False)
                (backup_dir / "manifest.env").write_text((work / "manifest.env").read_text(encoding="utf-8"), encoding="utf-8")
                (backup_dir / "files.txt").write_text((work / "files.txt").read_text(encoding="utf-8"), encoding="utf-8")

                include_nginx_html = component == "engine" and any(
                    rel == "nodedc-source/dist" or rel.startswith("nodedc-source/dist/")
                    for rel in entries
                )
                create_backup(root, backup_dir, entries, include_nginx_html)

                apply_started = True
                for rel in entries:
                    copy_payload_path(payload_dir, root, rel, current_stamp)

                if COMPONENTS[component].get("publish_dist"):
                    publish_engine_dist(root, current_stamp)

                run_component_runtime(component, entries, services)
                run_healthchecks(component, entries)

                applied_path = move_artifact(artifact, APPLIED_DIR)
                append_jsonl(STATE_FILE, {
                    "applied_at": utc_now(),
                    "artifact": applied_path.name,
                    "backup_id": backup_id,
                    "component": component,
                    "id": patch_id,
                    "sha256": sha,
                    "status": "ok",
                })
                print(f"deploy-ok patch={patch_id} component={component} backup={backup_id}")
        except Exception as exc:
            failed_path = None
            if artifact.exists():
                try:
                    failed_path = move_artifact(artifact, FAILED_DIR, suffix=f".{current_stamp}")
                except Exception:
                    failed_path = None

            append_jsonl(FAILED_STATE_FILE, {
                "artifact": str(failed_path.name if failed_path else artifact.name),
                "backup_id": backup_id,
                "component": manifest.get("component") if manifest else None,
                "failed_at": utc_now(),
                "id": manifest.get("id") if manifest else None,
                "message": str(exc),
                "sha256": sha,
                "started_apply": apply_started,
                "status": "failed",
            })
            if backup_id:
                print(f"backup={backup_id}", file=sys.stderr)
            raise


def main(argv):
    parser = argparse.ArgumentParser(prog="nodedc-deploy")
    sub = parser.add_subparsers(dest="cmd", required=True)

    plan = sub.add_parser("plan")
    plan.add_argument("artifact")

    apply = sub.add_parser("apply")
    apply.add_argument("artifact")

    sub.add_parser("verify-install")

    args = parser.parse_args(argv)

    if args.cmd == "verify-install":
        verify_install()
        return 0

    artifact = Path(args.artifact).absolute()
    if args.cmd == "plan":
        plan_artifact(artifact)
        return 0
    if args.cmd == "apply":
        apply_artifact(artifact)
        return 0

    parser.print_help()
    return 2


if __name__ == "__main__":
    try:
        raise SystemExit(main(sys.argv[1:]))
    except DeployError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        raise SystemExit(1)
