#!/usr/bin/env python3
"""门店前端目录的实际访问量（aaden-cli appcenter-usage 的执行体）。只读 Apache access.log，结果 JSON 打到 stdout。

统计两类目录，应用名用 docroot 下的相对路径：
  - AppCenter 下的应用 → "AppCenter/Admin"、"AppCenter/App" …
  - docroot 根目录下带 index.html 的前端（老的 /Admin、/App 等）→ "Admin"、"App" …
一个请求归到某个目录，满足其一即可：
  - 请求路径在该目录下（页面和静态资源）
  - Referer 指向该目录（页面里发出的 PHP 接口请求；index.html 被浏览器缓存时只能靠这个看出有人在用）
来源按 Referer 主机名分：ngrok（远程。经 ik%04d.ngrok 进来的客户端 IP 都是 127.0.0.1，只能看 Referer）、
local（localhost/127.x，收银机本机）、lan（局域网 IP，平板等）、none（没有 Referer，一般是直接打开页面）。
只统计真实存在的目录，扫描器乱打的路径不算。

另外按「客户端类型」统计打到 /PHP/ 的请求，应用名以 "@client/" 开头（原生 App 不带 Referer，上面那套看不到）：
  - User-Agent 是 Aaden-<应用>/<版本> 的（约定的统一格式）→ "@client/Aaden-<应用>/<版本>"
  - 否则按 HTTP 库分：okhttp（快消版安卓收银 2.0/3.0、K2）、ktor（点单 / 老板端）、
    electron-<系统>（餐厅版桌面，windows / linux）、browser-<系统>（android 多半是商米机）、
    system（门店主机自己的 cron / PHP / worker、云端经 ngrok 的请求）、other
  - okhttp 另外数几个只有某一代收银才调的接口（MARKERS），用来区分 2.0 和 3.x，比如 "@client/okhttp:DashboardData.php"
  客户端类的 sources 按客户端 IP 分（没有 Referer）：local=127.x/::1（含经 ngrok 进来的），lan=其它。

用法: appCenterUsage.py [日志目录，默认 /var/log/apache2]；需要能读 access.log*（root 或 adm 组）。
"""
import datetime
import glob
import hashlib
import gzip
import json
import os
import re
import sys
from collections import defaultdict

LOG_DIR = sys.argv[1] if len(sys.argv) > 1 else '/var/log/apache2'
DOCROOT = '/var/www/html'
APPCENTER = os.path.join(DOCROOT, 'AppCenter')
# 根目录下不是前端的目录
NOT_FRONTEND = {'AppCenter', 'PHP', 'Resource', 'TseHost', 'docs', 'scripts', 'ngrok', 'vendor', 'node_modules'}
LINE = re.compile(
    r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<method>[A-Z]+) (?P<path>\S+)[^"]*" (?P<status>\d{3}) \S+ '
    r'"(?P<ref>[^"]*)" "(?P<ua>[^"]*)"'
)
# /AppCenter/<App>/... 或 /<根目录前端>/...；第一组是 "AppCenter/"（可空），第二组是目录名
APP_IN_PATH = re.compile(r'^/(AppCenter/)?([^/?#]+)')
APP_IN_REF = re.compile(r'^https?://([^/]+)/(AppCenter/)?([^/?#]+)')
MONTHS = {m: i + 1 for i, m in enumerate('Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split())}


def source_of(ref_host):
    if ref_host is None:
        return 'none'
    host = ref_host.split(':')[0].lower()
    if 'ngrok' in host:
        return 'ngrok'
    if host == 'localhost' or host.startswith('127.'):
        return 'local'
    return 'lan'


# 统一 User-Agent 约定：Aaden-<应用>/<版本> (<平台>; build <提交>; <渠道>)
AADEN_UA = re.compile(r'^Aaden[- ]([A-Za-z0-9_.-]{1,24})/([A-Za-z0-9_.+-]{1,20})')  # 也认老的 "Aaden Buffet/2.16"
# 门店主机自己（cron 的 wget/curl、PHP 的 Guzzle、node worker 的 axios）和云端经 ngrok 来的请求，不是收银前端
SYSTEM_UA = ('wget/', 'curl/', 'guzzlehttp', 'axios/', 'reactornetty', 'java/', 'apache-httpclient', 'undici',
             'python-requests', 'go-http-client', 'node-fetch', 'php/')
# 只有某一代安卓收银会调的 PHP 接口：DashboardData.php 只在 2.0，QuickOrder.php 只在较新的 3.x
MARKERS = ('DashboardData.php', 'QuickOrder.php')
CLIENT = '@client/'


def client_kind(ua):
    """User-Agent → 客户端类型（不含 "@client/" 前缀）"""
    m = AADEN_UA.match(ua)
    if m:
        return f'Aaden-{m.group(1)}/{m.group(2)}'
    low = ua.lower()
    if low in ('', '-') or low.startswith(SYSTEM_UA):
        return 'system'
    if low.startswith('okhttp'):
        return 'okhttp'
    if 'ktor' in low:
        return 'ktor'
    if 'electron' in low:
        return 'electron-' + ua_os(low)
    if low.startswith('mozilla'):
        return 'browser-' + ua_os(low)
    return 'other'


def ua_os(low):
    """餐厅版要分 Windows / Ubuntu 桌面和安卓平板（商米等）"""
    if 'android' in low:
        return 'android'
    if 'windows' in low:
        return 'windows'
    if 'linux' in low or 'x11' in low:
        return 'linux'
    if 'iphone' in low or 'ipad' in low or 'mac os' in low:
        return 'apple'
    return 'other'


def ip_source(ip):
    return 'local' if ip.startswith('127.') or ip == '::1' else 'lan'


TS = re.compile(r'^(\d{2})/([A-Z][a-z]{2})/(\d{4}):(\d{2}:\d{2}:\d{2})')


def parse_ts(ts):
    """'03/Oct/2026:12:20:40 +0200' → ('2026-10-03', '2026-10-03T12:20:40')，比 strptime 快得多。
    格式不对返回 (None, None)：日志里有乱码行（扫描器发的二进制请求），以前照样切片，
    切出 '��-00-�T…' 这种时间，还因为按字符串比较排到最大成了 logTo，云端解析失败、整份上报被拒。"""
    m = TS.match(ts)
    if not m or m.group(2) not in MONTHS:
        return None, None
    d, mon, year, hms = m.groups()
    day = f'{year}-{MONTHS[mon]:02d}-{d}'
    return day, f'{day}T{hms}'


# index.html 里引用的入口 js（Vue CLI：js/app.1234abcd.js；Vite：assets/index-AbCd1234.js，老版 Vite：assets/index.1234abcd.js）
MAIN_JS = re.compile(r'''src=["']?[^"'\s>]*?((?:js/(?:app|main|index)\.[0-9a-f]{8}|assets/(?:index|main)[.-][\w-]{8,})\.js)''')


def app_version(rel):
    try:
        return _app_version(rel)
    except Exception:  # 兜底：版本信息只是附带的，任何意外都不能让整份访问量上报失败
        return {'version': 'unknown'}


def _app_version(rel):
    """
    某个前端目录装的是哪个版本（前端版本遥测 A 层，云端 device_app_inventory.app_versions）。值一律是对象：
      version / commit  AppCenter 发布时写进产物的 version.json（innerken-gmbh/workflows appcenter-publish）；
                        没有就读目录里的 package.json；都没有是 "unknown"
      treeSha           AppCenter 目录的 .appcenter-manifest 的 sha256，跟 S3 manifest 的 treeSha256 一致
      fingerprint       index.html 的 sha256 前 16 位 + 入口 js 文件名：根目录老前端（不走 AppCenter、没有 version.json）
      mainJs            靠它区分是哪次构建，云端按已知构建换算成版本号
      deployedAt        index.html 的修改时间（≈ 装到这台机器的时间，不是构建时间）
    读不到的字段就不带，任何异常都不能影响访问量上报。
    """
    d = os.path.join(DOCROOT, rel)
    info = {'version': 'unknown'}
    try:
        for name, keys in (('version.json', ('version', 'commit')), ('package.json', ('version',))):
            f = os.path.join(d, name)
            if os.path.isfile(f):
                with open(f, encoding='utf-8', errors='replace') as fh:
                    data = json.load(fh)
                for k in keys:
                    v = str(data.get(k) or '').strip()
                    if v:
                        info[k] = v[:40]
                break
    except (OSError, ValueError, AttributeError):
        pass
    try:
        mf = os.path.join(d, '.appcenter-manifest')
        if os.path.isfile(mf):
            with open(mf, 'rb') as fh:
                info['treeSha'] = hashlib.sha256(fh.read()).hexdigest()
    except OSError:
        pass
    try:
        idx = os.path.join(d, 'index.html')
        with open(idx, 'rb') as fh:
            html = fh.read()
        info['fingerprint'] = hashlib.sha256(html).hexdigest()[:16]
        m = MAIN_JS.search(html.decode('utf-8', 'replace'))
        if m:
            info['mainJs'] = m.group(1)[:64]
        info['deployedAt'] = datetime.datetime.fromtimestamp(os.path.getmtime(idx)).strftime('%Y-%m-%dT%H:%M:%S')
    except OSError:
        pass
    return info


def count_client(stats, m):
    """一条 /PHP/ 请求按客户端类型记一次；okhttp 碰到标记接口再多记一行"""
    day, at = parse_ts(m['ts'])
    if not day:
        return
    kind = client_kind(m['ua'])
    names = [CLIENT + kind]
    if kind == 'okhttp':
        bare = m['path'].split('?')[0]
        names += [f'{CLIENT}okhttp:{mk}' for mk in MARKERS if bare.endswith('/' + mk)]
    for name in names:
        s = stats[(name, day)]
        s['requests'] += 1
        s['clients'].add((m['ip'], '-', m['ua'][:120]))
        s['sources'][ip_source(m['ip'])] += 1
        if at > s['lastSeen']:
            s['lastSeen'] = at


def main():
    installed = []
    if os.path.isdir(APPCENTER):
        installed += ['AppCenter/' + d for d in os.listdir(APPCENTER) if os.path.isdir(os.path.join(APPCENTER, d))]
    if os.path.isdir(DOCROOT):
        installed += [
            d for d in os.listdir(DOCROOT)
            if d not in NOT_FRONTEND and not d.startswith('.') and os.path.isfile(os.path.join(DOCROOT, d, 'index.html'))
        ]
    installed.sort()
    known = set(installed)
    # 快速预筛：行里得出现某个已知目录的路径段，绝大多数 PHP 轮询行直接跳过
    roots = sorted({k.split('/')[0] for k in known}, key=len, reverse=True)
    prefilter = re.compile('/(?:' + '|'.join(re.escape(r) for r in roots) + ')[/?# "]') if roots else None
    stats = defaultdict(lambda: {
        'pageLoads': 0, 'requests': 0, 'clients': set(),
        'sources': {'none': 0, 'local': 0, 'lan': 0, 'ngrok': 0}, 'lastSeen': '',
    })
    log_from = log_to = None
    total = 0
    for f in sorted(glob.glob(os.path.join(LOG_DIR, 'access.log*'))):
        opener = gzip.open if f.endswith('.gz') else open
        try:
            fh = opener(f, 'rt', errors='replace')
        except OSError:
            continue
        with fh:
            for line in fh:
                total += 1
                # 时间范围按所有行算，统计只看和 AppCenter 有关的行
                lb = line.find('[')
                at = parse_ts(line[lb + 1:lb + 21])[1] if lb > 0 else None
                if at:
                    if log_from is None or at < log_from:
                        log_from = at
                    if log_to is None or at > log_to:
                        log_to = at
                php = ' /PHP/' in line
                if not php and (prefilter is None or not prefilter.search(line)):
                    continue
                m = LINE.match(line)
                if not m:
                    continue
                if php and m['path'].startswith('/PHP/'):
                    count_client(stats, m)
                    if prefilter is None or not prefilter.search(line):
                        continue
                path, ref = m['path'], m['ref']
                pm = APP_IN_PATH.match(path)
                rm = APP_IN_REF.match(ref) if ref and ref != '-' else None
                path_app = (pm.group(1) or '') + pm.group(2) if pm else None
                ref_app = (rm.group(2) or '') + rm.group(3) if rm else None
                if path_app not in known:
                    path_app = None
                if ref_app not in known:
                    ref_app, rm = None, None
                app = path_app or ref_app
                if not app:
                    continue
                day, at = parse_ts(m['ts'])
                if not day:
                    continue
                s = stats[(app, day)]
                s['requests'] += 1
                bare = path.split('?')[0].split('#')[0]
                if path_app and m['method'] == 'GET' and m['status'] in ('200', '304') and bare in (
                        f'/{app}', f'/{app}/', f'/{app}/index.html'):
                    s['pageLoads'] += 1
                ref_host = rm.group(1) if rm else None
                s['clients'].add((m['ip'], ref_host or '-', m['ua'][:120]))
                s['sources'][source_of(ref_host)] += 1
                if at > s['lastSeen']:
                    s['lastSeen'] = at
    out = {
        'logFrom': log_from,
        'logTo': log_to,
        'totalRequests': total,
        'installedApps': installed,
        'appVersions': {app: app_version(app) for app in installed},
        'usage': [
            {
                'app': app, 'day': day, 'pageLoads': s['pageLoads'], 'requests': s['requests'],
                'clients': len(s['clients']), 'sources': s['sources'], 'lastSeen': s['lastSeen'],
            }
            for (app, day), s in sorted(stats.items())
        ],
    }
    json.dump(out, sys.stdout, ensure_ascii=False, separators=(',', ':'))


if __name__ == '__main__':
    main()
