#!/usr/bin/env python3
"""
Odoo Pre-Migration Analyzer
===========================

Scan a self-hosted Odoo addons path (and optionally a database dump listing)
and produce a plain-text report: module count, custom vs third-party heuristics,
manifest versions, and risk flags.

Designed for Odoo Community operators who want a quick inventory before asking
for a fixed upgrade quote.

Repo: https://github.com/lurnake/odoo-pre-migration-analyzer

Usage:
  python3 analyze.py /path/to/extra-addons
  python3 analyze.py /path/to/extra-addons --odoo-version 14.0
  python3 analyze.py /path/to/extra-addons -o report.txt

Send the report to support@odooupgradedone.com (or upload via
https://www.odooupgradedone.com/upload) for a fixed-price upgrade quote.
You only pay after you've verified the upgraded instance works.

Requires: Python 3.8+  (stdlib only — no pip install)
"""

from __future__ import annotations

import argparse
import ast
import datetime as dt
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

MANIFEST_NAMES = ("__manifest__.py", "__openerp__.py")

# Common author / path signals that a module is not "your" custom code.
THIRD_PARTY_AUTHOR_HINTS = (
    "oca",
    "odoo community association",
    "odoo s.a",
    "odoo sa",
    "openerp",
    "therp",
    "acsone",
    "camptocamp",
    "tecnativa",
    "initos",
    "braintec",
    "it-projects",
    "ventor",
    "muk",
    # common app-store vendors
    "browseinfo",
    "cybrosys",
    "softhealer",
    "webkul",
    "odoo mates",
    "odoomates",
    "silent infotech",
    "sodexis",
    "yiduoit",
    "app garage",
    "flectra",
)
THIRD_PARTY_PATH_HINTS = (
    "/oca/",
    "/o-spreadsheet",
    "enterprise",
)

RISKY_DEPENDENCIES = {
    "point_of_sale",
    "pos_restaurant",
    "website",
    "website_sale",
    "stock",
    "stock_account",
    "mrp",
    "account",
    "l10n_",
    "payment",
    "delivery",
}


def _safe_literal_dict(text: str) -> Optional[Dict[str, Any]]:
    """Parse a manifest file that is a Python dict literal."""
    try:
        value = ast.literal_eval(text)
        if isinstance(value, dict):
            return value
    except (SyntaxError, ValueError, MemoryError):
        pass
    # Fallback: strip comments-ish lines and retry once
    cleaned = "\n".join(
        line for line in text.splitlines() if not line.strip().startswith("#")
    )
    try:
        value = ast.literal_eval(cleaned)
        if isinstance(value, dict):
            return value
    except (SyntaxError, ValueError, MemoryError):
        return None
    return None


def read_manifest(module_dir: Path) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
    for name in MANIFEST_NAMES:
        path = module_dir / name
        if path.is_file():
            try:
                text = path.read_text(encoding="utf-8", errors="replace")
            except OSError:
                return None, name
            return _safe_literal_dict(text), name
    return None, None


def classify_module(module_dir: Path, manifest: Dict[str, Any]) -> str:
    author = str(manifest.get("author") or "").lower()
    path_l = str(module_dir).lower()
    for hint in THIRD_PARTY_AUTHOR_HINTS:
        if hint in author:
            return "third_party"
    for hint in THIRD_PARTY_PATH_HINTS:
        if hint in path_l:
            return "third_party"
    # installable False often = leftover / abandoned
    if manifest.get("installable") is False:
        return "disabled"
    return "custom_or_unknown"


def risk_flags(
    module_name: str,
    manifest: Dict[str, Any],
    module_dir: Path,
    declared_major: Optional[int] = None,
) -> List[str]:
    flags: List[str] = []
    deps = [str(d) for d in (manifest.get("depends") or [])]
    for dep in deps:
        for risky in RISKY_DEPENDENCIES:
            if dep == risky or dep.startswith(risky):
                flags.append(f"depends:{dep}")
                break

    # JS / assets often break across versions
    try:
        if next(module_dir.rglob("*.js"), None) is not None:
            flags.append("has_js")
    except OSError:
        pass

    # Manifest version vs the declared Odoo major. Manifests like "16.0.1.2.0"
    # carry the Odoo major; plain "1.0"-style versions carry no signal.
    version = str(manifest.get("version") or "")
    m = re.match(r"^(\d+)\.\d", version)
    if m:
        major = int(m.group(1))
        if 8 <= major <= 30:  # looks like an Odoo major, not a module counter
            if declared_major is not None and major != declared_major:
                flags.append(f"version_mismatch:{version}")
            elif declared_major is None and major < 13:
                flags.append(f"ancient_version:{version}")

    if manifest.get("installable") is False:
        flags.append("installable_false")

    license_ = str(manifest.get("license") or "")
    if license_ and "proprietary" in license_.lower():
        flags.append("proprietary_license")

    return flags


def find_modules(addons_path: Path) -> List[Path]:
    modules: List[Path] = []
    if not addons_path.is_dir():
        return modules
    # Pointed directly at a single module instead of an addons dir? Handle it.
    if any((addons_path / name).is_file() for name in MANIFEST_NAMES):
        return [addons_path]
    for child in sorted(addons_path.iterdir()):
        if not child.is_dir():
            continue
        if child.name.startswith(".") or child.name.startswith("__"):
            continue
        if any((child / name).is_file() for name in MANIFEST_NAMES):
            modules.append(child)
    return modules


def analyze(addons_paths: List[Path], declared_version: Optional[str]) -> Dict[str, Any]:
    declared_major: Optional[int] = None
    if declared_version:
        m = re.match(r"^(\d+)", declared_version.strip())
        if m:
            declared_major = int(m.group(1))

    modules_out: List[Dict[str, Any]] = []
    for root in addons_paths:
        for module_dir in find_modules(root):
            manifest, manifest_file = read_manifest(module_dir)
            if manifest is None:
                modules_out.append(
                    {
                        "name": module_dir.name,
                        "path": str(module_dir),
                        "error": f"could not parse {manifest_file or 'manifest'}",
                        "class": "parse_error",
                        "flags": ["parse_error"],
                    }
                )
                continue
            klass = classify_module(module_dir, manifest)
            flags = risk_flags(module_dir.name, manifest, module_dir, declared_major)
            modules_out.append(
                {
                    "name": module_dir.name,
                    "path": str(module_dir),
                    "summary": str(manifest.get("summary") or manifest.get("name") or ""),
                    "author": str(manifest.get("author") or ""),
                    "version": str(manifest.get("version") or ""),
                    "depends": [str(d) for d in (manifest.get("depends") or [])],
                    "installable": manifest.get("installable", True),
                    "class": klass,
                    "flags": flags,
                }
            )

    # Custom modules first — they drive the upgrade effort.
    order = {"custom_or_unknown": 0, "third_party": 1, "disabled": 2, "parse_error": 3}
    modules_out.sort(key=lambda m: (order.get(m["class"], 9), m["name"]))

    custom = [m for m in modules_out if m["class"] == "custom_or_unknown"]
    third = [m for m in modules_out if m["class"] == "third_party"]
    disabled = [m for m in modules_out if m["class"] == "disabled"]
    errors = [m for m in modules_out if m["class"] == "parse_error"]
    with_js = [m for m in modules_out if "has_js" in m.get("flags", [])]
    risky_deps = [
        m
        for m in modules_out
        if any(f.startswith("depends:") for f in m.get("flags", []))
    ]

    return {
        "generated_at": dt.datetime.now(dt.timezone.utc).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "tool": "odoo-pre-migration-analyzer",
        "tool_version": "1.1.0",
        "declared_odoo_version": declared_version,
        "addons_paths": [str(p) for p in addons_paths],
        "counts": {
            "total_modules": len(modules_out),
            "custom_or_unknown": len(custom),
            "third_party_heuristic": len(third),
            "disabled": len(disabled),
            "parse_errors": len(errors),
            "with_javascript": len(with_js),
            "touching_pos_website_stock_mrp_account": len(risky_deps),
        },
        "modules": modules_out,
    }


def render_text(report: Dict[str, Any]) -> str:
    c = report["counts"]
    lines: List[str] = []
    lines.append("=" * 64)
    lines.append("ODOO PRE-MIGRATION ANALYZER REPORT")
    lines.append("=" * 64)
    lines.append(f"Generated:     {report['generated_at']}")
    lines.append(f"Tool:          {report['tool']} {report['tool_version']}")
    if report.get("declared_odoo_version"):
        lines.append(f"Odoo version:  {report['declared_odoo_version']} (declared)")
    lines.append(f"Addons paths:  {', '.join(report['addons_paths'])}")
    lines.append("")
    lines.append("SUMMARY")
    lines.append("-" * 64)
    lines.append(f"  Total modules found:          {c['total_modules']}")
    lines.append(f"  Likely custom / unknown:      {c['custom_or_unknown']}")
    lines.append(f"  Likely third-party (OCA/etc):  {c['third_party_heuristic']}")
    lines.append(f"  Disabled (installable=False): {c['disabled']}")
    lines.append(f"  With JavaScript assets:       {c['with_javascript']}")
    lines.append(f"  Touching POS/website/stock/…: {c['touching_pos_website_stock_mrp_account']}")
    lines.append(f"  Manifest parse errors:        {c['parse_errors']}")
    lines.append("")
    lines.append("WHAT THIS MEANS FOR AN UPGRADE")
    lines.append("-" * 64)
    lines.append("  Custom module count and JS/POS/website/stock overrides drive")
    lines.append("  most of the effort — more than raw database size.")
    lines.append("  Third-party modules need a matching target-version branch")
    lines.append("  (or a replacement). Disabled leftovers should usually be removed.")
    lines.append("")
    lines.append("MODULES")
    lines.append("-" * 64)
    for m in report["modules"]:
        flags = ",".join(m.get("flags") or []) or "—"
        lines.append(
            f"  [{m['class']}] {m['name']}"
            f"  ver={m.get('version') or '—'}"
            f"  flags={flags}"
        )
        if m.get("author"):
            lines.append(f"           author: {m['author'][:80]}")
        if m.get("error"):
            lines.append(f"           error: {m['error']}")
    lines.append("")
    lines.append("=" * 64)
    lines.append("NEXT STEP — FIXED-PRICE QUOTE")
    lines.append("=" * 64)
    lines.append("  Email this report to support@odooupgradedone.com")
    lines.append("  or upload your DB + addons at:")
    lines.append("  https://www.odooupgradedone.com/upload")
    lines.append("")
    lines.append("  You'll get one fixed price within 24 hours.")
    lines.append("  You pay only after you've tested a working upgraded copy.")
    lines.append("  Site: https://www.odooupgradedone.com/")
    lines.append("=" * 64)
    lines.append("")
    return "\n".join(lines)


def main(argv: Optional[List[str]] = None) -> int:
    parser = argparse.ArgumentParser(
        description="Analyze Odoo addons paths for a pre-migration inventory report."
    )
    parser.add_argument(
        "addons",
        nargs="+",
        type=Path,
        help="One or more addons directories (extra-addons, custom, etc.)",
    )
    parser.add_argument(
        "--odoo-version",
        dest="odoo_version",
        default=None,
        help="Current Odoo major version, e.g. 14.0 or 16.0",
    )
    parser.add_argument(
        "-o",
        "--output",
        type=Path,
        default=None,
        help="Write the text report to this file (also printed to stdout)",
    )
    parser.add_argument(
        "--json",
        dest="json_path",
        type=Path,
        default=None,
        help="Also write machine-readable JSON to this path",
    )
    args = parser.parse_args(argv)

    missing = [p for p in args.addons if not p.is_dir()]
    if missing:
        for p in missing:
            print(f"error: not a directory: {p}", file=sys.stderr)
        return 2

    report = analyze(args.addons, args.odoo_version)
    text = render_text(report)
    print(text)
    if args.output:
        args.output.write_text(text, encoding="utf-8")
        print(f"(wrote {args.output})", file=sys.stderr)
    if args.json_path:
        args.json_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
        print(f"(wrote {args.json_path})", file=sys.stderr)
    return 0


if __name__ == "__main__":
    sys.exit(main())
