#!/usr/bin/env python3
"""
qualify-addon.py — Pillar 1 of the addon qualification gate.

Static checks against an Odoo addon source tree:
  manifest      __manifest__.py parses, has 'name', 'version' starts with '<digit>.0.'
  pip-deps      every non-stdlib import is declared in external_dependencies['python']
  app-name      every <app> element in any view XML has a name= attribute
  menu-icon     top-level <menuitem web_icon=> is set OR the addon ships
                static/description/icon.png
  hoot-import   no JS file under static/src/ imports from '@odoo/hoot' or '@odoo/hoot-dom'
  webpack-name  no JS file under static/lib/ uses self.webpackChunk_<unprefixed-name> —
                chunk array names must be addon-namespaced (e.g. webpackChunk_am5_<addon>)

Usage:
  python3 scripts/qualify-addon.py <addon-dir> [<addon-dir> ...]
  python3 scripts/qualify-addon.py --json <addon-dir>

Exit codes:
  0   all checks passed for every addon
  1   at least one addon failed at least one check
  2   bad usage / I/O error

Each finding is (severity, check, message). Severity:
  ERROR    — the addon is broken-by-construction; refuse to admit to catalog
  WARN     — likely problem but could be intentional; admit-with-warning posture
"""
from __future__ import annotations

import ast
import json
import re
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from xml.etree import ElementTree as ET


# Python stdlib modules. Conservative — anything imported NOT in here AND not in
# ODOO_BUILTINS is flagged as needing declaration. Better to false-positive (fixable
# by adding to external_dependencies) than miss a real missing dep.
STDLIB = frozenset({
    'abc', 'argparse', 'ast', 'asyncio', 'base64', 'binascii', 'bisect', 'calendar',
    'collections', 'configparser', 'contextlib', 'contextvars', 'copy', 'csv', 'ctypes',
    'dataclasses', 'datetime', 'decimal', 'difflib', 'dis', 'email', 'enum', 'errno',
    'fcntl', 'fnmatch', 'functools', 'gc', 'getpass', 'gettext', 'glob', 'gzip', 'hashlib',
    'heapq', 'hmac', 'html', 'http', 'imaplib', 'importlib', 'inspect', 'io', 'ipaddress',
    'itertools', 'json', 'keyword', 'locale', 'logging', 'math', 'mimetypes',
    'multiprocessing', 'numbers', 'operator', 'os', 'pathlib', 'pickle', 'pkgutil',
    'platform', 'pprint', 'queue', 'random', 're', 'select', 'selectors', 'shlex',
    'shutil', 'signal', 'smtplib', 'socket', 'sqlite3', 'ssl', 'stat', 'string', 'struct',
    'subprocess', 'sys', 'tempfile', 'textwrap', 'threading', 'time', 'timeit', 'token',
    'tokenize', 'traceback', 'types', 'typing', 'unicodedata', 'unittest', 'urllib',
    'uuid', 'warnings', 'weakref', 'xml', 'xmlrpc', 'zipfile', 'zlib', 'zoneinfo',
    '__future__',
})

# Modules shipped by Odoo's base image. Never need declaration.
ODOO_BUILTINS = frozenset({
    'odoo', 'psycopg2', 'lxml', 'PIL', 'requests', 'dateutil', 'pytz', 'passlib',
    'werkzeug', 'jinja2', 'markupsafe', 'docutils', 'reportlab', 'babel', 'xlsxwriter',
    'xlrd', 'xlwt', 'qrcode', 'vobject', 'polib', 'PyPDF2', 'cryptography', 'pyOpenSSL',
    'OpenSSL', 'suds', 'num2words', 'pyldap', 'ldap', 'xmltodict', 'zeep', 'gevent',
    'greenlet', 'libsass', 'idna', 'pyusb', 'serial', 'qrcode', 'mock', 'freezegun',
    'phonenumbers',
})

# Bare-name webpackChunk arrays we know collide. Detector flags any
# `self.webpackChunk_<name>` where <name> doesn't have an addon-derived suffix.
# We allow the canonical chunk array names listed here ONLY if the addon's
# directory name matches — i.e. we suggest namespacing.
WEBPACK_CHUNK_RE = re.compile(r'self\.(webpackChunk[a-zA-Z0-9_]+)\b')

# JS imports of the hoot test framework that should never appear in production code.
HOOT_IMPORT_RE = re.compile(
    r'''(?:from\s+['"]@odoo/hoot[a-z\-]*['"]|require\s*\(\s*['"]@odoo/hoot[a-z\-]*['"]\s*\))'''
)


@dataclass
class Finding:
    severity: str           # 'ERROR' | 'WARN'
    check: str              # short check id
    message: str            # human-readable
    file: str | None = None # relative path, if applicable
    line: int | None = None # 1-indexed, if applicable


# ---------------------------------------------------------------------------- #
# Check 1 — manifest parses + has required keys
# ---------------------------------------------------------------------------- #
def check_manifest(addon_dir: Path) -> tuple[list[Finding], dict | None]:
    findings: list[Finding] = []
    mf_path = addon_dir / '__manifest__.py'
    if not mf_path.exists():
        findings.append(Finding('ERROR', 'manifest', 'no __manifest__.py'))
        return findings, None
    try:
        manifest = ast.literal_eval(mf_path.read_text())
    except (SyntaxError, ValueError) as e:
        findings.append(Finding('ERROR', 'manifest',
                                f'__manifest__.py does not parse as Python literal: {e}',
                                file='__manifest__.py'))
        return findings, None
    if not isinstance(manifest, dict):
        findings.append(Finding('ERROR', 'manifest',
                                '__manifest__.py top-level is not a dict',
                                file='__manifest__.py'))
        return findings, None
    if not manifest.get('name'):
        findings.append(Finding('ERROR', 'manifest',
                                "missing 'name' key (Odoo refuses install)",
                                file='__manifest__.py'))
    version = manifest.get('version', '')
    if not re.match(r'^\d+\.0\.\d+\.\d+\.\d+$', version):
        findings.append(Finding('WARN', 'manifest',
                                f"version {version!r} is not in '<odoo_major>.0.x.y.z' form — "
                                "Odoo will prepend the running Odoo major and may refuse install "
                                "on a different major (incident #9)",
                                file='__manifest__.py'))
    return findings, manifest


# ---------------------------------------------------------------------------- #
# Check 2 — pip deps: every non-stdlib import is in external_dependencies
# ---------------------------------------------------------------------------- #
def check_pip_deps(addon_dir: Path, manifest: dict) -> list[Finding]:
    findings: list[Finding] = []
    declared = set(manifest.get('external_dependencies', {}).get('python', []))
    addon_name = addon_dir.name

    # Pre-scan: collect this addon's submodule names so we don't flag intra-addon imports.
    own_submodules = {p.stem for p in addon_dir.rglob('*.py') if p.stem != '__init__'}
    own_submodules.add(addon_name)

    seen_imports: set[tuple[str, str, int]] = set()  # (toplevel, file, line)

    for py_file in addon_dir.rglob('*.py'):
        if any(part.startswith('.') for part in py_file.parts):
            continue
        try:
            tree = ast.parse(py_file.read_text())
        except (SyntaxError, UnicodeDecodeError):
            continue
        rel = py_file.relative_to(addon_dir).as_posix()
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    seen_imports.add((alias.name.split('.')[0], rel, node.lineno))
            elif isinstance(node, ast.ImportFrom):
                if node.level:  # relative import — intra-addon, skip
                    continue
                if node.module:
                    seen_imports.add((node.module.split('.')[0], rel, node.lineno))

    for top, rel, lineno in sorted(seen_imports):
        if top in STDLIB or top in ODOO_BUILTINS or top in own_submodules:
            continue
        if top in declared:
            continue
        # PEP 8 names that are clearly local helpers (e.g. utils, models) — skip if
        # they look like a sibling module we missed in own_submodules.
        if (addon_dir / top).is_dir() or (addon_dir / f'{top}.py').exists():
            continue
        findings.append(Finding(
            'ERROR', 'pip-deps',
            f"imports '{top}' but it is not in external_dependencies['python'] "
            "(install will fail with ModuleNotFoundError — incident #5)",
            file=rel, line=lineno,
        ))
    return findings


# ---------------------------------------------------------------------------- #
# Check 3 — every <app> element has a name= attribute
# ---------------------------------------------------------------------------- #
def check_app_name(addon_dir: Path) -> list[Finding]:
    findings: list[Finding] = []
    # XML files in views/ + data/ may contain res_config_settings <app> elements.
    for xml_file in list(addon_dir.rglob('views/*.xml')) + list(addon_dir.rglob('data/*.xml')):
        try:
            text = xml_file.read_text()
        except UnicodeDecodeError:
            continue
        rel = xml_file.relative_to(addon_dir).as_posix()
        # Multi-line tolerant regex: <app ... > with everything between.
        for m in re.finditer(r'<app\b[^>]*?>', text, re.DOTALL):
            tag = m.group()
            if 'name=' in tag:
                continue
            line = text[:m.start()].count('\n') + 1
            findings.append(Finding(
                'ERROR', 'app-name',
                "<app> element missing name= attribute. Odoo 18 SettingsFormCompiler "
                "calls toStringExpression(null) and crashes the entire Settings page "
                "(incident #7)",
                file=rel, line=line,
            ))
    return findings


# ---------------------------------------------------------------------------- #
# Check 4 — top-level menus declare web_icon OR addon ships static/description/icon.png
# ---------------------------------------------------------------------------- #
def check_menu_icon(addon_dir: Path) -> list[Finding]:
    findings: list[Finding] = []
    has_default_icon = (addon_dir / 'static' / 'description' / 'icon.png').exists()
    for xml_file in addon_dir.rglob('*.xml'):
        try:
            text = xml_file.read_text()
        except UnicodeDecodeError:
            continue
        rel = xml_file.relative_to(addon_dir).as_posix()
        # Find <menuitem ... > whose XML has no parent= attribute (top-level menu).
        for m in re.finditer(r'<menuitem\b[^>]*?/?>', text, re.DOTALL):
            tag = m.group()
            if 'parent=' in tag:
                continue
            if 'web_icon=' in tag:
                continue
            if has_default_icon:
                # Odoo 18's auto-fallback path. Soft warning since it works for top-level
                # menus that get web_icon auto-populated from the module's icon.png.
                # But our incident #6 showed even with icon.png present, web_icon often
                # ends up empty in DB. So WARN, not ERROR.
                line = text[:m.start()].count('\n') + 1
                findings.append(Finding(
                    'WARN', 'menu-icon',
                    "top-level <menuitem> has no web_icon=. Will fall back to "
                    "static/description/icon.png IF Odoo's auto-populate fires; "
                    "if not, menu shows blank (incident #6). Set web_icon explicitly.",
                    file=rel, line=line,
                ))
            else:
                line = text[:m.start()].count('\n') + 1
                findings.append(Finding(
                    'ERROR', 'menu-icon',
                    "top-level <menuitem> has no web_icon= AND addon ships no "
                    "static/description/icon.png — menu will render blank.",
                    file=rel, line=line,
                ))
    return findings


# ---------------------------------------------------------------------------- #
# Check 5 — no @odoo/hoot* imports in static/src/
# ---------------------------------------------------------------------------- #
def check_hoot_import(addon_dir: Path) -> list[Finding]:
    findings: list[Finding] = []
    src_dir = addon_dir / 'static' / 'src'
    if not src_dir.exists():
        return findings
    for js_file in src_dir.rglob('*.js'):
        try:
            text = js_file.read_text()
        except UnicodeDecodeError:
            continue
        rel = js_file.relative_to(addon_dir).as_posix()
        for m in HOOT_IMPORT_RE.finditer(text):
            line = text[:m.start()].count('\n') + 1
            findings.append(Finding(
                'ERROR', 'hoot-import',
                "imports from @odoo/hoot* in production code (static/src/). "
                "@odoo/hoot is the test framework; the production bundle does not "
                "register it. Page will white-screen (incident #3 class)",
                file=rel, line=line,
            ))
    return findings


# ---------------------------------------------------------------------------- #
# Check 6 — webpack chunk arrays in static/lib/ must be addon-namespaced
# ---------------------------------------------------------------------------- #
def check_webpack_chunk(addon_dir: Path) -> list[Finding]:
    findings: list[Finding] = []
    lib_dir = addon_dir / 'static' / 'lib'
    if not lib_dir.exists():
        return findings
    addon_name = addon_dir.name
    seen: set[str] = set()
    for js_file in lib_dir.rglob('*.js'):
        try:
            text = js_file.read_text()
        except UnicodeDecodeError:
            continue
        rel = js_file.relative_to(addon_dir).as_posix()
        for m in WEBPACK_CHUNK_RE.finditer(text):
            chunk_name = m.group(1)
            if chunk_name in seen:
                continue
            seen.add(chunk_name)
            # Acceptable if chunk name contains: full addon name OR any 4+ char
            # sub-token of the addon name (e.g. 'ksdn' for 'ks_dashboard_ninja')
            # OR a known-namespaced suffix (anything past the standard library
            # prefix). We just need confidence the chunk array is unique-per-addon.
            addon_lower = addon_name.lower()
            chunk_lower = chunk_name.lower()
            tokens = [addon_lower.replace('_', '')] + [
                t for t in addon_lower.split('_') if len(t) >= 4
            ]
            # Also accept any short 4+ char abbrev derived from initials of
            # underscore-separated parts (ks_dashboard_ninja -> ksdn)
            initials = ''.join(t[0] for t in addon_lower.split('_') if t)
            if len(initials) >= 3:
                tokens.append(initials)
            if any(t in chunk_lower for t in tokens):
                continue
            line = text[:m.start()].count('\n') + 1
            findings.append(Finding(
                'ERROR', 'webpack-chunk',
                f"uses bare webpack chunk array '{chunk_name}'. Two addons that ship "
                f"the same library (e.g. amCharts) collide on this global → bundle "
                f"execution aborts (incident #4). Rename to '{chunk_name}_{addon_name}' "
                "or similar.",
                file=rel, line=line,
            ))
    return findings


# ---------------------------------------------------------------------------- #
# Runner
# ---------------------------------------------------------------------------- #
def qualify_addon(addon_dir: Path) -> dict:
    findings: list[Finding] = []

    manifest_findings, manifest = check_manifest(addon_dir)
    findings.extend(manifest_findings)

    if manifest is not None:
        findings.extend(check_pip_deps(addon_dir, manifest))

    findings.extend(check_app_name(addon_dir))
    findings.extend(check_menu_icon(addon_dir))
    findings.extend(check_hoot_import(addon_dir))
    findings.extend(check_webpack_chunk(addon_dir))

    errors = sum(1 for f in findings if f.severity == 'ERROR')
    warns = sum(1 for f in findings if f.severity == 'WARN')
    return {
        'addon': addon_dir.name,
        'path': str(addon_dir),
        'qualified': errors == 0,
        'errors': errors,
        'warns': warns,
        'findings': [asdict(f) for f in findings],
    }


def main(argv: list[str]) -> int:
    json_out = False
    args: list[str] = []
    for a in argv[1:]:
        if a == '--json':
            json_out = True
        elif a in ('-h', '--help'):
            print(__doc__)
            return 0
        else:
            args.append(a)
    if not args:
        print(__doc__, file=sys.stderr)
        return 2

    results = []
    for path_str in args:
        path = Path(path_str).resolve()
        if not path.is_dir() or not (path / '__manifest__.py').exists():
            print(f'ERROR: {path} is not an Odoo addon directory '
                  '(missing __manifest__.py)', file=sys.stderr)
            return 2
        results.append(qualify_addon(path))

    if json_out:
        print(json.dumps(results, indent=2))
    else:
        for r in results:
            badge = '\033[32mQUALIFIED\033[0m' if r['qualified'] else '\033[31mFAILED\033[0m'
            print(f"\n{badge}  {r['addon']}  ({r['errors']} error(s), {r['warns']} warning(s))")
            if not r['findings']:
                continue
            for f in r['findings']:
                tag = '\033[31m' if f['severity'] == 'ERROR' else '\033[33m'
                loc = ''
                if f['file']:
                    loc = f"  [{f['file']}" + (f":{f['line']}" if f['line'] else '') + ']'
                print(f"  {tag}{f['severity']:5}\033[0m {f['check']:<14} {f['message']}{loc}")

    any_failed = any(not r['qualified'] for r in results)
    return 1 if any_failed else 0


if __name__ == '__main__':
    sys.exit(main(sys.argv))
