#!/usr/bin/env bash
# Tomcat 9；依赖 apt/dpkg、systemd 和 amd64，不限制发行版名称或版本。请使用 sudo bash install_ubuntu.sh，不要使用 sh。
# UAT: 8080(app+sso),8082(alumni),8083(scsi),8090(utmscmp)
# Production: 8080(app),8081(sso),8082(alumni),8083(scsi),8084(scsicv),8090(utmscmp)
# 已安装不重装；配置一致不修改；修改前备份。不会部署或删除业务 WAR。
set -Eeuo pipefail
umask 022

ENVIRONMENT=''
OSS_ZIP=''
JAVA_HOME='/usr/lib/jvm/java-8-openjdk-amd64'
TOMCAT_VERSION='9.0.123'
TMP_DIR=''
RUN_ID="$(date +%Y%m%d-%H%M%S)-$$"
BACKUP_ROOT="/var/backups/install-tomcats/$RUN_ID"
STATE_ROOT='/var/lib/install-tomcats'
SUMMARY_READY=0
REPORTING=0
declare -a PORTS=() NAMES=() HEAP_MS=() HEAP_MX=()
declare -A SOURCES=() OLD_PIDS=() CHANGED=() NEW_INSTALL=()

log() { printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
die() { printf '\n[失败] %s\n' "$*" >&2; exit 1; }
usage() {
    cat <<'HELP'
用法：
  sudo bash install_ubuntu.sh
  sudo bash install_ubuntu.sh --oss-zip /上传目录/oss.zip
  sudo bash install_ubuntu.sh --env uat --oss-zip /上传目录/oss.zip

--env uat|production  明确选择环境；不传时必须在交互菜单选择。
--oss-zip 文件        指定已上传的 ZIP；不传时默认查找 /home/maadmin/oss.zip。
--help               显示说明，不执行安装。

已有 /opt/oss 时不解压、不覆盖数据；不存在时使用指定或默认 ZIP，文件不存在则停止。
保留原文件管理账号/密码及远程 Manager 访问设置；Tomcat 目录统一 chmod -R 755。
配置备份：/var/backups/install-tomcats/时间戳-PID/
不会修改所选环境以外的实例，也不会自动开放防火墙或部署 WAR。
HELP
}

# Python 标准库：XML 使用字节位置做局部修改，保留注释、缩进和其他配置。
helper() {
    python3 - "$@" <<'PY'
import hashlib
import html
import os
from pathlib import Path, PurePosixPath
import re
import shlex
import shutil
import stat
import sys
import zipfile
from xml.parsers import expat

class XML:
    def __init__(self, data):
        self.data = data
        self.nodes = []
        self.stack = []
        self.edits = []
        parser = expat.ParserCreate()
        self.parser = parser
        parser.StartElementHandler = self.start
        parser.EndElementHandler = self.end
        parser.ExternalEntityRefHandler = lambda *args: 0
        def reject_doctype(*args):
            raise ValueError('不支持带 DOCTYPE 的配置，请人工检查')
        parser.StartDoctypeDeclHandler = reject_doctype
        parser.Parse(data, True)

    def tag_end(self, pos):
        quote = None
        for i in range(pos, len(self.data)):
            char = self.data[i]
            if quote:
                if char == quote:
                    quote = None
            elif char in (34, 39):
                quote = char
            elif char == 62:
                return i + 1
        raise ValueError('XML 标签不完整')

    def start(self, name, attrs):
        start = self.parser.CurrentByteIndex
        end = self.tag_end(start)
        node = dict(name=name.split(':')[-1], attrs=attrs, start=start, open_end=end,
                    parent=self.stack[-1] if self.stack else None,
                    empty=self.data[start:end].rstrip().endswith(b'/>'))
        self.nodes.append(node)
        self.stack.append(node)

    def end(self, name):
        node = self.stack.pop()
        node['close_start'] = node['open_end'] if node['empty'] else self.parser.CurrentByteIndex
        node['end'] = node['open_end'] if node['empty'] else self.tag_end(node['close_start'])

    def find(self, name, parent=None):
        return [n for n in self.nodes if n['name'] == name and (parent is None or n['parent'] is parent)]

    def one(self, name, parent=None):
        nodes = self.find(name, parent)
        if len(nodes) != 1:
            raise ValueError(f'需要唯一 {name} 节点，实际为 {len(nodes)} 个')
        return nodes[0]

    def attrs(self, node, wanted):
        tag = self.data[node['start']:node['open_end']]
        for key, value in wanted.items():
            value = str(value)
            if node['attrs'].get(key) == value:
                continue
            pattern = rb'(\s' + re.escape(key.encode()) + rb'\s*=\s*)([\x22\x27])(.*?)\2'
            match = re.search(pattern, tag, flags=re.S)
            encoded = html.escape(value, quote=True).encode()
            if match:
                tag = tag[:match.start(2)] + b'"' + encoded + b'"' + tag[match.end():]
            else:
                offset = tag.rfind(b'/>') if node['empty'] else tag.rfind(b'>')
                tag = tag[:offset] + b' ' + key.encode() + b'="' + encoded + b'"' + tag[offset:]
        if tag != self.data[node['start']:node['open_end']]:
            self.edits.append((node['start'], node['open_end'], tag))

    def insert(self, parent, text):
        if parent['empty']:
            raise ValueError('不自动展开自闭合根节点，请人工检查配置')
        self.edits.append((parent['close_start'], parent['close_start'], text.encode()))

    def text(self, node, wanted):
        current = self.data[node['open_end']:node['close_start']]
        if current.strip() != wanted.encode():
            self.edits.append((node['open_end'], node['close_start'], wanted.encode()))

    def result(self):
        result = self.data
        last = len(result) + 1
        for start, end, replacement in sorted(self.edits, reverse=True):
            if end > last:
                raise ValueError('配置修改区域重叠，已停止')
            result = result[:start] + replacement + result[end:]
            last = start
        XML(result)
        return result

def patch_server(data, port):
    xml = XML(data)
    root = xml.one('Server')
    xml.attrs(root, {'port': str(int(port) + 10000), 'address': '127.0.0.1'})
    connectors = [n for n in xml.find('Connector')
                  if (n['attrs'].get('protocol', 'HTTP/1.1') == 'HTTP/1.1'
                      or 'http11' in n['attrs'].get('protocol', ''))
                  and n['attrs'].get('SSLEnabled', 'false').lower() != 'true'
                  and n['attrs'].get('secure', 'false').lower() != 'true']
    if len(connectors) != 1:
        raise ValueError('无法唯一确定普通 HTTP Connector，不自动猜测')
    xml.attrs(connectors[0], {'port': port, 'protocol': 'HTTP/1.1',
        'connectionTimeout': '20000', 'redirectPort': '8443',
        'maxPostSize': '-1', 'maxSwallowSize': '-1'})
    host = xml.one('Host')
    engine = xml.one('Engine')
    if (host['attrs'].get('appBase', 'webapps') != 'webapps'
            or host['attrs'].get('name') != 'localhost'
            or engine['attrs'].get('name') != 'Catalina'):
        raise ValueError('检测到自定义 Host/Engine/appBase，需人工确认 Manager 路径')
    return xml.result()

def patch_users(data):
    xml = XML(data)
    root = xml.one('tomcat-users')
    roles = ['admin-gui', 'admin-script', 'manager-gui', 'manager-script', 'manager-jmx', 'manager-status']
    existing = {n['attrs'].get('rolename') for n in xml.find('role', root)}
    additions = ''.join(f'  <role rolename="{r}"/>\n' for r in roles if r not in existing)
    users = [n for n in xml.find('user', root) if n['attrs'].get('username') == 'tomcat']
    if len(users) > 1:
        raise ValueError('存在重复 tomcat 账号，请人工处理')
    wanted = {'username': 'tomcat', 'password': '63553308', 'roles': ','.join(roles)}
    if users:
        # 角色顺序不同但集合相同也视为一致。
        if set(users[0]['attrs'].get('roles', '').split(',')) == set(roles):
            wanted['roles'] = users[0]['attrs']['roles']
        xml.attrs(users[0], wanted)
    else:
        additions += '  <user ' + ' '.join(f'{k}="{v}"' for k, v in wanted.items()) + '/>\n'
    if additions:
        xml.insert(root, additions)
    return xml.result()

def patch_context(data):
    xml = XML(data)
    xml.one('Context')
    for node in xml.find('Valve'):
        if node['attrs'].get('className') in (
                'org.apache.catalina.valves.RemoteAddrValve',
                'org.apache.catalina.valves.RemoteCIDRValve'):
            xml.edits.append((node['start'], node['end'], b''))
    return xml.result()

def patch_upload(data):
    xml = XML(data)
    candidates = []
    for servlet in xml.find('servlet'):
        names = xml.find('servlet-name', servlet)
        if names and xml.data[names[0]['open_end']:names[0]['close_start']].strip() == b'HTMLManager':
            candidates.append(servlet)
    if len(candidates) != 1:
        raise ValueError('无法唯一定位 HTMLManager servlet')
    multipart = xml.one('multipart-config', candidates[0])
    for tag in ('max-file-size', 'max-request-size'):
        xml.text(xml.one(tag, multipart), '524288000')
    return xml.result()

def patch_unit(data, base, java):
    text = data.decode('utf-8') if data else '[Unit]\n\n[Service]\n\n[Install]\n'
    # 续行可能跨多个变量，不在不明确的情况下重写。
    if re.search(r'\\\r?\n', text):
        raise ValueError('现有 systemd 单元含续行，请先展开后重试')
    required = {
        'Unit': {'Description': 'Apache Tomcat 9 Web Application Server ' + base.name[6:]},
        'Service': {'Type': 'forking', 'User': 'tomcat', 'Group': 'tomcat',
                    'PIDFile': str(base / 'tomcat.pid'),
                    'ExecStart': str(base / 'bin/startup.sh'),
                    'ExecStop': str(base / 'bin/shutdown.sh'),
                    'RestartSec': '10', 'Restart': 'on-failure',
                    'TimeoutStartSec': '120', 'TimeoutStopSec': '90', 'UMask': '0022'},
        'Install': {}}
    env = {'JAVA_HOME': java, 'CATALINA_PID': str(base / 'tomcat.pid'),
           'CATALINA_HOME': str(base), 'CATALINA_BASE': str(base)}
    sections = {}
    section = None
    lines = text.splitlines(keepends=True)
    # EnvironmentFile 和 drop-in 可覆盖配置，因此显式拦截，不假装配置成功。
    for line in lines:
        match = re.match(r'^\s*\[([^]]+)\]\s*$', line)
        if match:
            section = match.group(1)
            if section in sections:
                raise ValueError('systemd 单元存在重复 section，请人工整理')
            sections[section] = []
        elif section:
            sections[section].append(line)
            if section == 'Service' and re.match(r'^\s*EnvironmentFile\s*=\s*\S', line):
                raise ValueError('现有服务使用 EnvironmentFile，需先确认其覆盖关系')
    for name in required:
        if name not in sections:
            lines.extend([f'\n[{name}]\n'])
    output = []
    section = None
    seen = set()
    seen_env = set()
    def finish():
        if section in required:
            for key, value in required[section].items():
                if key not in seen:
                    output.append(f'{key}={value}\n')
            if section == 'Service':
                for key, value in env.items():
                    if key not in seen_env:
                        output.append(f'Environment="{key}={value}"\n')
            if section == 'Unit' and 'After' not in seen:
                output.append('After=network.target\n')
            if section == 'Install' and 'WantedBy' not in seen:
                output.append('WantedBy=multi-user.target\n')
    for line in lines:
        match = re.match(r'^\s*\[([^]]+)\]\s*$', line)
        if match:
            finish()
            section = match.group(1)
            seen = set()
            output.append(line if line.endswith('\n') else line + '\n')
            continue
        match = re.match(r'^\s*([A-Za-z][A-Za-z0-9]*)\s*=(.*?)\s*$', line)
        if not match or section not in required:
            output.append(line)
            continue
        key, value = match.groups()
        if key in required[section]:
            if key not in seen:
                output.append(line if value == required[section][key] else f'{key}={required[section][key]}\n')
            seen.add(key)
        elif (section, key) in (('Unit', 'After'), ('Install', 'WantedBy')):
            needed = 'network.target' if section == 'Unit' else 'multi-user.target'
            tokens = value.split()
            if needed not in tokens:
                tokens.append(needed)
                line = f'{key}={" ".join(tokens)}\n'
            output.append(line)
            seen.add(key)
        elif section == 'Service' and key == 'Environment':
            tokens = shlex.split(value)
            # 禁止 reset 清空已写环境。
            if not tokens:
                continue
            new = []
            for token in tokens:
                variable, _, content = token.partition('=')
                if variable in env:
                    if variable not in seen_env:
                        new.append(variable + '=' + env[variable])
                        seen_env.add(variable)
                elif variable in ('JAVA_OPTS', 'CATALINA_OPTS'):
                    # 堆参数只在 setenv.sh 设置，保留服务中的其他 JVM 参数。
                    cleaned = re.sub(r'(^|\s)-Xm[sx]\S+', '', content)
                    if cleaned == content:
                        new.append(token)
                    elif cleaned.strip():
                        new.append(variable + '=' + cleaned.strip())
                else:
                    if variable in ('_JAVA_OPTIONS', 'JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS') and re.search(r'-Xm[sx]', content):
                        raise ValueError('现有 JVM 注入环境变量包含堆设置，需人工处理')
                    new.append(token)
            if new == tokens:
                output.append(line)
            elif new:
                def quote(value):
                    return '"' + value.replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%') + '"'
                output.append('Environment=' + ' '.join(quote(token) for token in new) + '\n')
        else:
            output.append(line)
    finish()
    return ''.join(output).encode()

def patch_setenv(data, java, ms, mx):
    start = '# BEGIN install_ubuntu.sh managed JVM settings'
    end = '# END install_ubuntu.sh managed JVM settings'
    text = data.decode('utf-8') if data else '#!/usr/bin/env bash\n'
    if text.count(start) != text.count(end) or text.count(start) > 1:
        raise ValueError('setenv.sh 中的托管标记不完整或重复')
    block = f'''{start}
# 保留其他 JVM 参数，在文件末尾统一 Java 和堆设置。
export JAVA_HOME="{java}"
export JRE_HOME="$JAVA_HOME"
if printf '%s' "${{_JAVA_OPTIONS:-}} ${{JAVA_TOOL_OPTIONS:-}} ${{JDK_JAVA_OPTIONS:-}}" | grep -Eq -- '-Xm[sx]'; then
    printf '%s\\n' 'JVM 注入环境变量包含额外堆设置，请先移除其中的 -Xms/-Xmx。' >&2
    exit 1
fi
JAVA_OPTS="$(printf '%s' "${{JAVA_OPTS:-}}" | sed -E 's/(^|[[:space:]])-Xm[sx][^[:space:]]+//g')"
CATALINA_OPTS="$(printf '%s' "${{CATALINA_OPTS:-}}" | sed -E 's/(^|[[:space:]])-Xm[sx][^[:space:]]+//g') -Xms{ms}m -Xmx{mx}m"
export JAVA_OPTS CATALINA_OPTS
{end}
'''
    if start in text:
        a, b = text.index(start), text.index(end) + len(end)
        if text[b:b+1] == '\n':
            b += 1
        if text[b:].strip():
            raise ValueError('setenv.sh 托管段后存在自定义命令，请将其移到托管段之前')
        return (text[:a] + block).encode()
    return (text + ('' if text.endswith('\n') else '\n') + '\n' + block).encode()

def plan(base, source, port, ms, mx, java, stage, unit_source):
    base, source, stage = Path(base), Path(source), Path(stage)
    mappings = []
    def add(relative, transform):
        origin = source / relative
        destination = base / relative
        if origin.is_symlink() or destination.is_symlink():
            raise ValueError(f'配置为符号链接，不自动替换：{destination}')
        data = origin.read_bytes() if origin.exists() else b''
        new = transform(data)
        target = stage / relative
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(new)
        if not destination.exists() or destination.read_bytes() != new:
            mappings.append((str(destination), str(target)))
    add('conf/server.xml', lambda d: patch_server(d, port))
    add('conf/tomcat-users.xml', patch_users)
    add('webapps/manager/META-INF/context.xml', patch_context)
    override = Path('conf/Catalina/localhost/manager.xml')
    if (source / override).exists():
        existing = XML((source / override).read_bytes())
        context = existing.one('Context')
        docbase = context['attrs'].get('docBase')
        if docbase and docbase not in ('manager', str(base / 'webapps/manager')):
            raise ValueError('manager.xml 使用自定义 docBase，需人工确认')
        add(str(override), patch_context)
    add('webapps/manager/WEB-INF/web.xml', patch_upload)
    add('bin/setenv.sh', lambda d: patch_setenv(d, java, ms, mx))
    destination = Path(f'/etc/systemd/system/tomcat{port}.service')
    unit = Path(unit_source) if unit_source else destination
    if destination.is_symlink():
        raise ValueError(f'服务单元为符号链接或已屏蔽：{destination}')
    data = unit.read_bytes() if unit.exists() else b''
    new = patch_unit(data, base, java)
    target = stage / destination.name
    target.write_bytes(new)
    if not destination.exists() or destination.read_bytes() != new:
        mappings.append((str(destination), str(target)))
    (stage / 'changes.tsv').write_text(''.join(f'{a}\t{b}\n' for a, b in mappings), encoding='utf-8')
    final = XML((stage / 'conf/server.xml').read_bytes())
    listeners = [int(port) + 10000]
    for node in final.find('Connector'):
        number = node['attrs'].get('port', '')
        if not number.isdecimal() or not 1 <= int(number) <= 65535:
            raise ValueError('发现动态或无效 Connector 端口，需人工确认')
        listeners.append(int(number))
    (stage / 'ports.txt').write_text(''.join(f'{p}\n' for p in listeners), encoding='ascii')

def zip_action(action, name, destination=None):
    with zipfile.ZipFile(name) as archive:
        seen = set()
        total = 0
        for item in archive.infolist():
            p = PurePosixPath(item.filename)
            mode = item.external_attr >> 16
            if (p.is_absolute() or '..' in p.parts or '\\' in item.filename
                    or not p.parts or p.parts[0] != 'oss' or stat.S_ISLNK(mode)
                    or (stat.S_IFMT(mode) and not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)))):
                raise ValueError(f'不安全或非 oss/ 路径：{item.filename}')
            if str(p) in seen:
                raise ValueError(f'ZIP 存在重复路径：{item.filename}')
            seen.add(str(p))
            total += item.file_size
        if not any(p == 'oss/iftmsa' or p.startswith('oss/iftmsa/') for p in seen):
            raise ValueError('ZIP 必须包含 oss/iftmsa/ 目录')
        if total + 256 * 1024**2 > shutil.disk_usage('/opt').free:
            raise ValueError('/opt 磁盘空间不足以解压 ZIP 并保留 256MiB 余量')
        if archive.testzip():
            raise ValueError('ZIP CRC 校验失败')
        if action == 'zip-extract':
            archive.extractall(destination)

def pids(base):
    matched = []
    for folder in Path('/proc').iterdir():
        if not folder.name.isdecimal():
            continue
        try:
            args = (folder / 'cmdline').read_bytes().split(b'\0')
            if any(a.startswith(b'-Dcatalina.base=') and os.path.realpath(os.fsdecode(a.split(b'=', 1)[1])) == os.path.realpath(base) for a in args):
                executable = os.path.basename(os.readlink(folder / 'exe'))
                if executable == 'java':
                    matched.append(folder.name)
        except (OSError, ValueError):
            pass
    if len(matched) > 1:
        raise ValueError(f'{base} 有多个 Java 进程，不自动选择')
    print(matched[0] if matched else '')

def effective_memory():
    values = []
    mem = Path('/proc/meminfo').read_text()
    values.append(int(re.search(r'^MemTotal:\s+(\d+)', mem, re.M)[1]) * 1024)
    # 当前 cgroup 及各父级限制；适用于 cgroup v2 和常见 v1 memory 布局。
    for line in Path('/proc/self/cgroup').read_text().splitlines():
        _, controllers, relative = line.split(':', 2)
        if not controllers:
            root, filename = Path('/sys/fs/cgroup'), 'memory.max'
        elif 'memory' in controllers.split(','):
            root, filename = Path('/sys/fs/cgroup/memory'), 'memory.limit_in_bytes'
        else:
            continue
        current = root / relative.lstrip('/')
        while current == root or root in current.parents:
            try:
                raw = (current / filename).read_text().strip()
                if raw.isdecimal():
                    values.append(int(raw))
            except OSError:
                pass
            if current == root:
                break
            current = current.parent
    print(min(values) // (1024 * 1024))

def main():
    action, *args = sys.argv[1:]
    if action == 'plan':
        plan(*args)
    elif action in ('zip-check', 'zip-extract'):
        zip_action(action, *args)
    elif action == 'pids':
        pids(*args)
    elif action == 'memory':
        effective_memory()
    elif action == 'heap-check':
        pid, ms, mx, java = args
        proc = Path('/proc') / pid
        tokens = proc.joinpath('cmdline').read_bytes().decode(errors='replace').split('\0')
        if [x for x in tokens if x.startswith('-Xms')] != [f'-Xms{ms}m'] or [x for x in tokens if x.startswith('-Xmx')] != [f'-Xmx{mx}m']:
            raise ValueError('实际 Java 启动参数与指定堆配置不一致')
        if os.path.realpath(proc / 'exe') != os.path.realpath(Path(java) / 'bin/java'):
            raise ValueError('实际 Java 可执行文件与 JAVA_HOME 不一致')
    else:
        raise ValueError('未知内部操作')

if __name__ == '__main__':
    try:
        main()
    except Exception as exc:
        print(f'[配置检查失败] {exc}', file=sys.stderr)
        sys.exit(1)
PY
}

prop() { systemctl show "$1" --property="$2" --value 2>/dev/null || true; }
verify_units() {
    local logfile=$1 missing_port=$2 rc=0 line recognized=0 unexpected=0
    local xfs_warning='^/(usr/lib|lib)/systemd/system/system-xfs_scrub\.slice:[0-9]+: Support for option CPUAccounting= has been removed and it is ignored$'
    shift 2
    systemd-analyze verify "$@" 2>"$logfile" || rc=$?
    while IFS= read -r line || [[ -n "$line" ]]; do
        [[ "$line" =~ [^[:space:]] ]] || continue
        if [[ "$line" =~ $xfs_warning ]]; then
            recognized=1
        elif [[ -n "$missing_port" ]] &&
                { [[ "$line" == "tomcat${missing_port}.service: Command /server/tomcat${missing_port}/bin/startup.sh is not executable: No such file or directory" ]] ||
                  [[ "$line" == "tomcat${missing_port}.service: Command /server/tomcat${missing_port}/bin/shutdown.sh is not executable: No such file or directory" ]]; }; then
            # 仅新实例的安装前检查允许这两个文件尚未落盘。
            recognized=1
        else
            printf '%s\n' "$line" >&2
            unexpected=1
        fi
    done < "$logfile"
    if (( rc != 0 )); then
        (( recognized == 1 && unexpected == 0 )) || return "$rc"
    fi
    return 0
}
http_code() {
    local code
    code=$(curl --noproxy '*' -s -o /dev/null -w '%{http_code}' --max-time 2 "http://127.0.0.1:$1/" 2>/dev/null) || true
    printf '%s' "${code:-000}"
}
owned_listener() {
    local port=$1 pid=$2 result
    [[ "$pid" =~ ^[1-9][0-9]*$ ]] || return 1
    result=$(ss -H -ltnp "sport = :$port") || return 1
    [[ "$result" == *"pid=$pid,"* ]]
}
report_status() {
    local i port unit active enabled pid code heap_ok result=0
    local -a failed=()
    REPORTING=1
    printf '\n环境：%s\n' "$ENVIRONMENT"
    printf '%-18s %-8s %-14s %-14s %-8s %-10s\n' 'SERVICE' 'PORT' 'STATE' 'AUTOSTART' 'HTTP' 'JVM'
    for i in "${!PORTS[@]}"; do
        port=${PORTS[$i]}; unit="tomcat${port}.service"
        active=$(prop "$unit" ActiveState); enabled=$(prop "$unit" UnitFileState)
        pid=$(prop "$unit" MainPID); code=$(http_code "$port")
        heap_ok='FAIL'
        if [[ "$active" == active && "$pid" =~ ^[1-9][0-9]*$ ]] &&
                helper heap-check "$pid" "${HEAP_MS[$i]}" "${HEAP_MX[$i]}" "$JAVA_HOME" 2>/dev/null; then
            heap_ok='OK'
        fi
        printf '%-18s %-8s %-14s %-14s %-8s %-10s\n' "$unit" "$port" "${active:-not-found}" "${enabled:-unknown}" "$code" "$heap_ok"
        if [[ "$active" != active || "$enabled" != enabled || "$heap_ok" != OK || ! "$code" =~ ^[1-4][0-9][0-9]$ ]] || ! owned_listener "$port" "$pid"; then
            failed+=("$unit"); result=1
        fi
    done
    for unit in "${failed[@]}"; do
        printf '\n--- %s 诊断 ---\n' "$unit"
        systemctl status "$unit" --no-pager -l || true
        journalctl -u "$unit" -n 50 --no-pager || true
    done
    REPORTING=0
    return "$result"
}
cleanup() {
    local rc=$?
    trap - EXIT
    if (( rc != 0 )); then
        printf '\n[失败] 脚本未全部完成（退出码 %s）。已完成的步骤保留；修复原因后可重跑。\n' "$rc" >&2
        [[ ! -d "$BACKUP_ROOT" ]] || printf '配置备份：%s\n' "$BACKUP_ROOT" >&2
        if (( SUMMARY_READY == 1 && REPORTING == 0 )); then report_status || true; fi
    fi
    if [[ "$TMP_DIR" == /tmp/install-tomcats.* && -d "$TMP_DIR" && ! -L "$TMP_DIR" ]]; then
        rm -rf -- "$TMP_DIR"
    fi
    exit "$rc"
}

while (( $# )); do
    case "$1" in
        --env) (( $# >= 2 )) || die '--env 缺少参数'; ENVIRONMENT=$2; shift 2 ;;
        --oss-zip) (( $# >= 2 )) || die '--oss-zip 缺少文件路径'; OSS_ZIP=$2; shift 2 ;;
        --help|-h) usage; exit 0 ;;
        *) die "未知参数：$1；使用 --help 查看说明" ;;
    esac
done
if [[ -z "$ENVIRONMENT" ]]; then
    [[ -t 0 ]] || die '环境必选：非交互运行请传入 --env uat 或 --env production'
    while :; do
        printf '\n请选择当前环境：\n1) UAT\n2) Production\n'
        read -r -p '请输入 1 或 2：' selection || die '未选择环境，已退出'
        case "$selection" in
            1) ENVIRONMENT=uat; break ;;
            2) ENVIRONMENT=production; break ;;
            *) printf '必须输入 1 或 2，没有默认值。\n' ;;
        esac
    done
fi
case "$ENVIRONMENT" in
    uat)
        PORTS=(8080 8082 8083 8090); NAMES=('app+sso' alumni scsi utmscmp)
        HEAP_MS=(2048 1024 2048 2048); HEAP_MX=(4096 2048 2048 4096); MIN_MEMORY_MB=15360 ;;
    production)
        PORTS=(8080 8081 8082 8083 8084 8090); NAMES=(app sso alumni scsi scsicv utmscmp)
        HEAP_MS=(8192 2048 4096 4096 4096 2048); HEAP_MX=(16384 4096 8192 8192 8192 4096); MIN_MEMORY_MB=32768 ;;
    *) die '环境只能是 uat 或 production' ;;
esac
[[ $EUID == 0 ]] || die '请使用 sudo bash install_ubuntu.sh 执行'
[[ -d /run/systemd/system ]] || die '当前系统未运行 systemd'
[[ "$(dpkg --print-architecture)" == amd64 ]] || die '固定 JAVA_HOME 对应 amd64；当前架构不匹配'
[[ ! -L /opt/oss ]] || die '/opt/oss 是符号链接，需人工确认实际数据位置'
if [[ -e /opt/oss ]]; then
    [[ -d /opt/oss/iftmsa ]] || die '/opt/oss 已存在但缺少 iftmsa/ 目录；请先补齐，不会覆盖解压'
    log '[跳过] /opt/oss 已存在，不解压、不覆盖数据'
else
    if [[ -z "$OSS_ZIP" ]]; then
        OSS_ZIP='/home/maadmin/oss.zip'
        log "未指定 --oss-zip，查找默认文件：$OSS_ZIP"
    fi
    [[ -f "$OSS_ZIP" ]] || die "找不到 OSS ZIP：$OSS_ZIP；请上传到该路径，或通过 --oss-zip 指定文件路径；尚未开始安装"
fi
trap cleanup EXIT
trap 'printf "\n[错误] 第 %s 行执行失败。\n" "$LINENO" >&2' ERR
trap 'exit 130' INT
trap 'exit 143' TERM
command -v flock >/dev/null || die '缺少 flock（util-linux），请先安装'
exec 9>/run/lock/install-tomcats.lock
flock -n 9 || die '另一个安装脚本正在运行'
TMP_DIR=$(mktemp -d /tmp/install-tomcats.XXXXXXXX)
chmod 700 "$TMP_DIR"
log "已选择 $ENVIRONMENT；本次只处理 ${PORTS[*]}"
for i in "${!PORTS[@]}"; do
    printf '  %s %-10s -Xms%sm -Xmx%sm\n' "${PORTS[$i]}" "${NAMES[$i]}" "${HEAP_MS[$i]}" "${HEAP_MX[$i]}"
done

# 先保证配置/ZIP 检查器所需的 python3 可用。
APT_UPDATED=0
apt_update_once() {
    if (( APT_UPDATED == 0 )); then apt-get update; APT_UPDATED=1; fi
}
if ! command -v python3 >/dev/null; then
    apt_update_once
    DEBIAN_FRONTEND=noninteractive apt-get install -y python3
fi
memory_mb=$(helper memory)
(( memory_mb >= MIN_MEMORY_MB )) || die "有效内存 ${memory_mb}MiB，$ENVIRONMENT 保守预检要求至少 ${MIN_MEMORY_MB}MiB；不会调低指定 JVM 内存"
if [[ ! -d /opt/oss ]]; then helper zip-check "$OSS_ZIP"; fi

packages=()
for package in ca-certificates curl unzip tar gzip iproute2 procps; do
    status=$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)
    [[ "$status" == 'install ok installed' ]] || packages+=("$package")
done
if (( ${#packages[@]} )); then
    apt_update_once
    DEBIAN_FRONTEND=noninteractive apt-get install -y "${packages[@]}"
fi
if [[ -x "$JAVA_HOME/bin/java" && -x "$JAVA_HOME/bin/javac" ]]; then
    "$JAVA_HOME/bin/java" -version 2>&1 | head -n 1 | grep -q '"1\.8\.' || die 'JAVA_HOME 存在但不是 Java 8'
    log '[跳过] Java 8 已安装'
else
    # 保留已有系统默认 java / javac 的目标。
    old_java=$(readlink -f /usr/bin/java 2>/dev/null || true)
    old_javac=$(readlink -f /usr/bin/javac 2>/dev/null || true)
    apt_update_once
    candidate=$(apt-cache policy openjdk-8-jdk | awk '/Candidate:/ {print $2}')
    if [[ -z "$candidate" || "$candidate" == '(none)' ]]; then
        DEBIAN_FRONTEND=noninteractive apt-get install -y software-properties-common
        add-apt-repository -y universe
        apt-get update
    fi
    DEBIAN_FRONTEND=noninteractive apt-get install -y openjdk-8-jdk
    [[ -z "$old_java" || ! -x "$old_java" ]] || update-alternatives --set java "$old_java"
    [[ -z "$old_javac" || ! -x "$old_javac" ]] || update-alternatives --set javac "$old_javac"
    [[ -x "$JAVA_HOME/bin/java" ]] || die "安装后未找到 $JAVA_HOME/bin/java"
    "$JAVA_HOME/bin/java" -version 2>&1 | head -n 1 | grep -q '"1\.8\.' || die '安装后的 Java 版本不是 8'
fi
SUMMARY_READY=1

archive_root=''
download_tomcat() {
    [[ -z "$archive_root" ]] || return 0
    local file="apache-tomcat-$TOMCAT_VERSION.tar.gz" endpoint digest actual downloaded=0
    for endpoint in "https://dlcdn.apache.org/tomcat/tomcat-9/v$TOMCAT_VERSION/bin" "https://archive.apache.org/dist/tomcat/tomcat-9/v$TOMCAT_VERSION/bin"; do
        log "下载 Tomcat $TOMCAT_VERSION 及 SHA-512"
        if curl -fL --proto '=https' --tlsv1.2 --retry 2 --connect-timeout 15 --max-time 300 "$endpoint/$file" -o "$TMP_DIR/$file" &&
           curl -fL --proto '=https' --tlsv1.2 --retry 2 --connect-timeout 15 --max-time 60 "$endpoint/$file.sha512" -o "$TMP_DIR/$file.sha512"; then
            downloaded=1; break
        fi
    done
    (( downloaded == 1 )) || die 'Tomcat 下载失败；请检查服务器网络'
    digest=$(grep -Eo '[[:xdigit:]]{128}' "$TMP_DIR/$file.sha512" | head -n 1)
    [[ "$digest" =~ ^[[:xdigit:]]{128}$ ]] || die '官方 SHA-512 文件格式不符合预期'
    actual=$(sha512sum "$TMP_DIR/$file"); actual=${actual%% *}
    [[ "${actual,,}" == "${digest,,}" ]] || die 'Tomcat SHA-512 校验失败，不会解压'
    tar -xzf "$TMP_DIR/$file" -C "$TMP_DIR" --no-same-owner
    archive_root="$TMP_DIR/apache-tomcat-$TOMCAT_VERSION"
    [[ -f "$archive_root/lib/catalina.jar" ]] || die 'Tomcat 压缩包结构异常'
}

declare -A USED_PORTS=()
for i in "${!PORTS[@]}"; do
    port=${PORTS[$i]}; base="/server/tomcat$port"; unit="tomcat$port.service"
    [[ ! -L "$base" ]] || die "$base 是符号链接，请先确认真实安装位置"
    if [[ -e "$base" ]]; then
        [[ -d "$base" && -f "$base/lib/catalina.jar" && -x "$base/bin/startup.sh" && -f "$base/conf/server.xml" ]] || die "$base 是残缺安装或非标准安装，不会覆盖"
        version=$("$JAVA_HOME/bin/java" -cp "$base/lib/catalina.jar" org.apache.catalina.util.ServerInfo)
        [[ "$version" == *'Apache Tomcat/9.'* ]] || die "$base 不是 Tomcat 9，不会替换"
        SOURCES[$port]=$base; NEW_INSTALL[$port]=0
        log "[跳过安装] 复用 $base"
    else
        download_tomcat
        SOURCES[$port]=$archive_root; NEW_INSTALL[$port]=1
    fi
    [[ -z "$(prop "$unit" DropInPaths)" ]] || die "$unit 有 systemd drop-in，需先确认其覆盖设置，不会强行覆盖"
    unit_source=$(prop "$unit" FragmentPath)
    [[ "$unit_source" != /dev/null ]] || die "$unit 已被屏蔽，请先人工处理"
    mkdir -p "$TMP_DIR/plan/$port"
    helper plan "$base" "${SOURCES[$port]}" "$port" "${HEAP_MS[$i]}" "${HEAP_MX[$i]}" "$JAVA_HOME" "$TMP_DIR/plan/$port" "$unit_source"
    bash -n "$TMP_DIR/plan/$port/bin/setenv.sh"
    missing_port=''
    [[ "${NEW_INSTALL[$port]}" != 1 ]] || missing_port=$port
    verify_units "$TMP_DIR/plan/$port/verify.log" "$missing_port" "$TMP_DIR/plan/$port/$unit" || die "$unit 校验失败"
    OLD_PIDS[$port]=$(helper pids "$base")
    old_pid=${OLD_PIDS[$port]}
    service_pid=$(prop "$unit" MainPID)
    if [[ "$service_pid" =~ ^[1-9][0-9]*$ && "$service_pid" != "$old_pid" ]]; then
        die "$unit 正在管理其他目录的进程，不能接管"
    fi
    if [[ -n "$old_pid" ]]; then
        foreign_service=$(grep -oE '[^/]+\.service' "/proc/$old_pid/cgroup" | tail -n 1 || true)
        [[ -z "$foreign_service" || "$foreign_service" == "$unit" ]] || die "$base 由其他服务 $foreign_service 管理，请先人工处理"
    fi
    while IFS= read -r listener; do
        [[ -z "${USED_PORTS[$listener]:-}" ]] || die "端口 $listener 在实例 $port 和 ${USED_PORTS[$listener]} 中重复"
        USED_PORTS[$listener]=$port
        occupied=$(ss -H -ltnp "sport = :$listener")
        if [[ -n "$occupied" ]]; then
            [[ -n "$old_pid" ]] || die "端口 $listener 已被其他进程占用"
            while IFS= read -r socket; do
                [[ "$socket" == *"pid=$old_pid,"* ]] || die "端口 $listener 被非目标 Tomcat 进程占用"
            done <<< "$occupied"
        fi
    done < "$TMP_DIR/plan/$port/ports.txt"
    CHANGED[$port]=0
    [[ ! -s "$TMP_DIR/plan/$port/changes.tsv" && ! -e "$STATE_ROOT/pending-$port" ]] || CHANGED[$port]=1
    if [[ "$service_pid" =~ ^[1-9][0-9]*$ ]] && ! helper heap-check "$service_pid" "${HEAP_MS[$i]}" "${HEAP_MX[$i]}" "$JAVA_HOME" 2>/dev/null; then
        CHANGED[$port]=1
    fi
done

# 所有目标实例配置和端口检查完成后，才开始修改目标文件。
getent group tomcat >/dev/null || groupadd --system tomcat
if id tomcat >/dev/null 2>&1; then
    [[ "$(id -u tomcat)" != 0 && "$(id -gn tomcat)" == tomcat ]] || die '已有 tomcat 用户 UID/主组与要求冲突'
else
    useradd --system --gid tomcat --no-create-home --home-dir /server --shell /usr/sbin/nologin tomcat
fi
if [[ ! -d /opt/oss ]]; then
    oss_stage=$(mktemp -d /opt/.oss-install.XXXXXXXX)
    if ! helper zip-extract "$OSS_ZIP" "$oss_stage"; then
        die "解压失败，临时数据保留在 $oss_stage；请检查后处理"
    fi
    [[ ! -e /opt/oss ]] || die '检查后 /opt/oss 被其他进程创建，停止以避免覆盖'
    chown -R tomcat:tomcat "$oss_stage/oss"
    mv -T -- "$oss_stage/oss" /opt/oss
    rmdir -- "$oss_stage"
    log '[安装] 已导入 /opt/oss'
fi
runuser -u tomcat -- test -r /opt/oss/iftmsa &&
runuser -u tomcat -- test -w /opt/oss/iftmsa &&
runuser -u tomcat -- test -x /opt/oss/iftmsa || die 'tomcat 无法读写 /opt/oss/iftmsa；请先调整数据权限后重跑，不会自动覆盖或改写现有 OSS'

install -d -m 700 "$STATE_ROOT"
install -d -m 755 /server
for port in "${PORTS[@]}"; do
    base="/server/tomcat$port"; unit="tomcat$port.service"; old_pid=${OLD_PIDS[$port]}
    if [[ "${CHANGED[$port]}" == 1 || "${NEW_INSTALL[$port]}" == 1 ]]; then
        touch "$STATE_ROOT/pending-$port"
        if systemctl is-active --quiet "$unit"; then
            log "正常停止 $unit，以便应用配置"
            systemctl stop "$unit"
        elif [[ -n "$old_pid" && -d "/proc/$old_pid" ]]; then
            # 修改关闭端口之前，按已经核对的 PID 正常停止手动启动的 Tomcat。
            [[ "$(helper pids "$base")" == "$old_pid" ]] || die "$base 的运行进程已变化，请重跑"
            log "正常停止手动启动的 $base（PID $old_pid），随后交给 systemd 管理"
            kill -TERM "$old_pid"
            stop_deadline=$((SECONDS + 90))
            while [[ -d "/proc/$old_pid" ]] && (( SECONDS < stop_deadline )); do sleep 1; done
            [[ ! -d "/proc/$old_pid" ]] || die "$base 在 90 秒内未停止；不会强制 kill -9"
        fi
    fi
    if [[ "${NEW_INSTALL[$port]}" == 1 ]]; then
        stage=$(mktemp -d "/server/.tomcat$port.XXXXXXXX")
        cp -a -- "$archive_root/." "$stage/"
        [[ ! -e "$base" ]] || die "$base 被其他进程创建，停止以避免覆盖"
        mv -T -- "$stage" "$base"
        log "[安装] $base"
    fi
    while IFS=$'\t' read -r destination staged; do
        [[ -n "$destination" ]] || continue
        if [[ -e "$destination" ]]; then
            backup="$BACKUP_ROOT$destination"
            install -d -m 700 "$(dirname "$backup")"
            cp -p -- "$destination" "$backup"
            chmod 600 "$backup"
        fi
        install -d -m 755 "$(dirname "$destination")"
        temporary=$(mktemp "$(dirname "$destination")/.install-tomcat.XXXXXXXX")
        cat -- "$staged" > "$temporary"
        chmod 644 "$temporary"
        mv -f -- "$temporary" "$destination"
        log "[修改] $destination"
    done < "$TMP_DIR/plan/$port/changes.tsv"
    if [[ -n "$(find "$base" \( ! -user tomcat -o ! -group tomcat \) -print -quit)" ]]; then
        chown -R tomcat:tomcat "$base"
        log "[修改权限] chown -R tomcat:tomcat $base"
    fi
    if [[ -n "$(find "$base" ! -type l ! -perm 0755 -print -quit)" ]]; then
        chmod -R 755 "$base"
        log "[修改权限] chmod -R 755 $base"
    fi
    if [[ "${CHANGED[$port]}" == 0 && "${NEW_INSTALL[$port]}" == 0 ]]; then
        log "[跳过配置] $unit 的目标配置已经一致"
    fi
done

units=()
for port in "${PORTS[@]}"; do units+=("/etc/systemd/system/tomcat$port.service"); done
verify_units "$TMP_DIR/verify-final.log" '' "${units[@]}" || die '安装后的 systemd 服务校验失败'
systemctl daemon-reload
for port in "${PORTS[@]}"; do
    unit="tomcat$port.service"
    [[ "$(prop "$unit" UnitFileState)" == enabled ]] || systemctl enable "$unit"
    if systemctl is-active --quiet "$unit"; then
        log "[跳过启动] $unit 已运行"
    else
        systemctl reset-failed "$unit" 2>/dev/null || true
        log "[启动] $unit"
        systemctl start --no-block "$unit"
    fi
done

log '等待全部目标实例就绪，最多 120 秒'
deadline=$((SECONDS + 120))
while :; do
    ready=1
    for i in "${!PORTS[@]}"; do
        port=${PORTS[$i]}; unit="tomcat$port.service"; pid=$(prop "$unit" MainPID)
        if [[ "$(prop "$unit" ActiveState)" != active ]] || ! owned_listener "$port" "$pid"; then
            ready=0; continue
        fi
        code=$(http_code "$port")
        [[ "$code" =~ ^[1-4][0-9][0-9]$ ]] || ready=0
    done
    (( ready == 1 || SECONDS >= deadline )) && break
    sleep 2
done
if report_status; then
    for port in "${PORTS[@]}"; do rm -f -- "$STATE_ROOT/pending-$port"; done
    SUMMARY_READY=0
    log '[成功] 所选环境全部 Tomcat 已运行、已启用开机自启，Java 和堆参数检查通过'
    [[ ! -d "$BACKUP_ROOT" ]] || printf '配置备份：%s\n' "$BACKUP_ROOT"
    printf 'HTTP 检查仅确认 Tomcat 可访问，不代表业务系统验证通过。\n'
else
    SUMMARY_READY=0
    die '部分实例未通过运行状态检查，详细状态和日志见上方；修复后可重跑'
fi
