#!/usr/bin/env python3
"""APK SDK Analyzer CLI.  Usage: apk-analyzer [OPTIONS] <apk_file|--batch dir>"""
import argparse, re, sys, time, unicodedata
from pathlib import Path

from apk_analyzer.analyzer.apk_parser import APKParser
from apk_analyzer.analyzer.dex_parser import DEXParser
from apk_analyzer.analyzer.manifest_parser import ManifestParser
from apk_analyzer.analyzer.native_parser import NativeParser
from apk_analyzer.analyzer.pyaxml_logging import suppress_pyaxmlparser_warnings
from apk_analyzer.analyzer.version_extractor import VersionExtractor
from apk_analyzer.signature_loader import SignatureLoader
from apk_analyzer.matcher import SDKMatcher
from apk_analyzer.reporter import Reporter
from apk_analyzer.models import AnalysisResult, ManifestData, ResourceData
from apk_analyzer.so_database import load_so_file_descriptions, normalize_so_name


def _default_signatures_dir() -> Path:
    if getattr(sys, "frozen", False):
        return Path(sys.executable).parent / "sdk_signatures"
    pkg_dir = Path(__file__).parent
    candidate = pkg_dir / "sdk_signatures"
    if candidate.exists():
        return candidate
    return candidate


def resolve_output_paths(apk_path: Path, args) -> tuple[Path, Path]:
    stem = apk_path.stem
    if args.output_dir:
        out_dir = Path(args.output_dir)
        default_json = out_dir / f"{stem}_report.json"
        default_html = out_dir / f"{stem}_report.html"
    else:
        default_json = apk_path.parent / f"{stem}_report.json"
        default_html = apk_path.parent / f"{stem}_report.html"
    out_json = Path(args.output_json) if args.output_json else default_json
    out_html = Path(args.output_html) if args.output_html else default_html
    return out_json, out_html


def _safe_int(value, default: int = 0) -> int:
    try:
        if value is None or value == "":
            return default
        return int(str(value), 0)
    except Exception:
        return default


def _to_string_list(values) -> list[str]:
    if not values:
        return []
    if isinstance(values, (str, bytes)):
        values = [values]
    result = []
    for v in values:
        if v is None:
            continue
        s = str(v).strip()
        if s:
            result.append(s)
    return list(dict.fromkeys(result))


_CHANNEL_KEYWORDS = ("channel", "umeng", "distribution")
_CHANNEL_ALIASES = {
    "googleplay": "google_play",
    "google_play": "google_play",
    "play": "google_play",
    "huawei": "huawei_appgallery",
    "huawei_appgallery": "huawei_appgallery",
    "appgallery": "huawei_appgallery",
    "xiaomi": "xiaomi_store",
    "xiaomi_store": "xiaomi_store",
    "vivo": "vivo_store",
    "vivo_store": "vivo_store",
    "oppo": "oppo_store",
    "oppo_store": "oppo_store",
    "heytap": "oppo_store",
    "yyb": "tencent_appstore",
    "yingyongbao": "tencent_appstore",
    "tencent_appstore": "tencent_appstore",
    "tencent": "tencent_appstore",
    "official": "official",
    "web": "official",
    "website": "official",
    "debug": "debug",
}

# Extend this map for private channels when fingerprints are known.
_KNOWN_SIGNER_CHANNELS: dict[str, str] = {}


def _normalize_channel_value(value: str) -> str:
    normalized = re.sub(r"[^a-z0-9]+", "_", (value or "").strip().lower()).strip("_")
    if not normalized:
        return ""
    return _CHANNEL_ALIASES.get(normalized, normalized)


def _looks_like_channel_key(key: str) -> bool:
    lowered = (key or "").strip().lower()
    return any(token in lowered for token in _CHANNEL_KEYWORDS)


def _infer_source_channel(apk_info, manifest: ManifestData) -> tuple[str, str, list[str], str]:
    evidence: list[str] = []

    for key, value in (manifest.meta_data or {}).items():
        if not _looks_like_channel_key(key):
            continue
        raw = str(value).strip()
        if not raw or raw.startswith("@"):
            continue
        normalized = _normalize_channel_value(raw)
        evidence.append(f"manifest-meta:{key}={raw}")
        if normalized:
            return normalized, "high", evidence, raw

    raw_archive_channel = (apk_info.source_channel_raw or "").strip()
    if raw_archive_channel:
        normalized = _normalize_channel_value(raw_archive_channel)
        evidence.append(f"archive-channel:{raw_archive_channel}")
        if normalized:
            return normalized, "high", evidence, raw_archive_channel

    for hint in apk_info.channel_hints or []:
        if not hint.startswith("store-marker:"):
            continue
        marker_channel = hint.split(":", 1)[1].strip()
        if not marker_channel:
            continue
        evidence.append(f"archive-marker:{marker_channel}")
        return marker_channel, "medium", evidence, marker_channel

    for cert in apk_info.signing_certificates or []:
        subject = (cert.get("subject") or "").lower()
        signer_sha256 = (cert.get("sha256") or "").lower()
        if "android debug" in subject:
            evidence.append("signer-subject:android-debug")
            return "debug", "high", evidence, signer_sha256
        if signer_sha256 in _KNOWN_SIGNER_CHANNELS:
            channel = _KNOWN_SIGNER_CHANNELS[signer_sha256]
            evidence.append(f"signer-sha256:{signer_sha256}")
            return channel, "medium", evidence, signer_sha256

    if not apk_info.signing_schemes:
        evidence.append("apk-signature:none")
        return "unsigned", "high", evidence, ""

    return "unknown", "low", evidence, ""


def _display_width(text: str) -> int:
    width = 0
    for ch in text:
        if unicodedata.combining(ch):
            continue
        width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
    return width


def _truncate_display(text: str, max_width: int) -> str:
    if max_width <= 0:
        return ""
    out = []
    width = 0
    for ch in text:
        ch_w = 0 if unicodedata.combining(ch) else (2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1)
        if width + ch_w > max_width:
            break
        out.append(ch)
        width += ch_w
    return "".join(out)


def _ljust_display(text: str, width: int) -> str:
    clipped = _truncate_display(text, width)
    pad = width - _display_width(clipped)
    return clipped + (" " * max(pad, 0))
def _manifest_from_apk(apk_path: Path) -> ManifestData:
    """
    Best-effort manifest extraction from pyaxmlparser.APK.
    This path is more tolerant for some protected/irregular manifests.
    """
    md = ManifestData()
    try:
        with suppress_pyaxmlparser_warnings():
            import pyaxmlparser
            apk = pyaxmlparser.APK(str(apk_path))
    except Exception:
        return md

    try:
        md.package_name = (apk.get_package() or getattr(apk, "packagename", "") or "").strip()
    except Exception:
        pass
    try:
        md.version_name = str(apk.get_androidversion_name() or getattr(apk, "version_name", "") or "")
    except Exception:
        pass
    try:
        md.version_code = _safe_int(apk.get_androidversion_code() or getattr(apk, "version_code", 0))
    except Exception:
        pass
    try:
        md.min_sdk = _safe_int(apk.get_min_sdk_version())
    except Exception:
        pass
    try:
        md.target_sdk = _safe_int(apk.get_target_sdk_version())
    except Exception:
        pass
    try:
        app_name = getattr(apk, "application", None) or apk.get_app_name()
        if app_name:
            md.app_name = str(app_name).strip()
    except Exception:
        pass
    try:
        md.permissions = _to_string_list(apk.get_permissions() or apk.get_requested_permissions())
    except Exception:
        pass
    try:
        md.services = _to_string_list(apk.get_services())
    except Exception:
        pass
    try:
        md.receivers = _to_string_list(apk.get_receivers())
    except Exception:
        pass
    try:
        md.providers = _to_string_list(apk.get_providers())
    except Exception:
        pass
    try:
        root = apk.get_android_manifest_xml()
        if root is not None:
            app = root.find("application")
            if app is not None:
                ns = "{http://schemas.android.com/apk/res/android}"
                for comp in ("activity", "service", "receiver", "provider"):
                    for el in app.findall(comp):
                        for intent_filter in el.findall("intent-filter"):
                            for action in intent_filter.findall("action"):
                                action_name = (action.get(f"{ns}name", "") or "").strip()
                                if action_name:
                                    md.intent_actions.append(action_name)
                for meta in app.findall("meta-data"):
                    key = (meta.get(f"{ns}name", "") or "").strip()
                    if not key:
                        continue
                    value = (
                        meta.get(f"{ns}value")
                        or meta.get(f"{ns}resource")
                        or ""
                    ).strip()
                    if value:
                        md.meta_data[key] = value
    except Exception:
        pass
    return md


def _merge_manifest(primary: ManifestData, fallback: ManifestData) -> ManifestData:
    if not primary.package_name:
        primary.package_name = fallback.package_name
    if not primary.version_name:
        primary.version_name = fallback.version_name
    if not primary.version_code:
        primary.version_code = fallback.version_code
    if not primary.min_sdk:
        primary.min_sdk = fallback.min_sdk
    if not primary.target_sdk:
        primary.target_sdk = fallback.target_sdk
    if not primary.app_name:
        primary.app_name = fallback.app_name

    primary.permissions = _to_string_list(primary.permissions + fallback.permissions)
    primary.activities = _to_string_list(primary.activities + fallback.activities)
    primary.services = _to_string_list(primary.services + fallback.services)
    primary.receivers = _to_string_list(primary.receivers + fallback.receivers)
    primary.providers = _to_string_list(primary.providers + fallback.providers)
    primary.intent_actions = _to_string_list(primary.intent_actions + fallback.intent_actions)
    merged_meta = dict(fallback.meta_data)
    merged_meta.update(primary.meta_data)
    primary.meta_data = merged_meta
    return primary


def analyze_one(apk_path: Path, sig_dir: Path, args) -> AnalysisResult:
    categories = [c.strip() for c in args.categories.split(",")] if args.categories else None

    apk_info = APKParser(apk_path).parse()
    dex_data = DEXParser(apk_info.dex_files).parse()
    apk_info.class_count = len(dex_data.class_names)
    manifest = ManifestParser(apk_info.manifest_data).parse()
    needs_apk_fallback = (
        (not manifest.package_name)
        or (not manifest.services and not manifest.receivers and not manifest.providers)
        or (not manifest.app_name)
    )
    if needs_apk_fallback:
        manifest = _merge_manifest(manifest, _manifest_from_apk(apk_path))
    native = NativeParser(apk_info.native_libs).parse()
    versions = VersionExtractor(dex_data, apk_info.metainf_versions).extract()

    loader = SignatureLoader(sig_dir)
    signatures = loader.load(categories=categories)
    detected = SDKMatcher(signatures, dex_data, manifest, native, versions).match()

    # Extract resource references from DEX string pool
    from apk_analyzer.analyzer.apk_parser import _extract_resource_references_from_strings
    dex_refs = _extract_resource_references_from_strings(dex_data.string_pool)
    all_refs = apk_info.resource_references | dex_refs

    # Create ResourceData from apk_info. Resource DPI best-practice advice is disabled.
    resource_data = ResourceData(
        resource_dirs=apk_info.resource_dirs,
        all_dpis=apk_info.resource_dpis,
        dpi_support=apk_info.dpi_support,
        resource_references=all_refs,
        string_dirs=apk_info.string_dirs,
        string_locales=apk_info.string_locales,
        string_support=apk_info.string_support,
    )

    channel, confidence, channel_evidence, channel_raw = _infer_source_channel(apk_info, manifest)
    apk_info.source_channel = channel
    apk_info.source_channel_confidence = confidence
    apk_info.source_channel_evidence = channel_evidence
    if channel_raw and not apk_info.source_channel_raw:
        apk_info.source_channel_raw = channel_raw

    # Enrich native libs with human-friendly descriptions for report display.
    so_db_override = Path(args.so_db_file) if getattr(args, "so_db_file", None) else None
    so_db = load_so_file_descriptions(so_db_override)
    if so_db and apk_info.native_lib_arches:
        enriched: dict[str, str] = {}
        for lib in apk_info.native_lib_arches.keys():
            desc = so_db.get(lib)
            if not desc:
                normalized = normalize_so_name(lib)
                if normalized != lib:
                    desc = so_db.get(normalized)
            if desc:
                enriched[lib] = desc
        apk_info.native_lib_descriptions = enriched

    return AnalysisResult(
        apk_path=str(apk_path),
        apk_info=apk_info,
        manifest=manifest,
        detected_sdks=detected,
        resource_data=resource_data,
        duration_seconds=0.0,
        signature_db_version=loader.db_version,
    )


def write_reports(result: AnalysisResult, out_json: Path, out_html: Path, fmt: str):
    tpl_path = Path(__file__).parent / "templates" / "report.html"
    if not tpl_path.exists():
        tpl_path = Path(__file__).parent.parent.parent / "templates" / "report.html"
    reporter = Reporter(result, str(tpl_path))
    if fmt in ("json", "both"):
        reporter.write_json(out_json)
    if fmt in ("html", "both"):
        reporter.write_html(out_html)


def _print_single_summary(result: AnalysisResult, duration: float):
    m = result.manifest
    app_name = m.app_name or m.package_name
    signer_org = result.apk_info.signing_organization or "unknown"
    schemes = "/".join(result.apk_info.signing_schemes) if result.apk_info.signing_schemes else "unsigned"
    print(f"\n{'='*52}")
    print(f"  {app_name} ({m.package_name}) v{m.version_name}")
    print(f"  Signer Org: {signer_org}  |  Signature: {schemes}")
    print(f"  Detected: {len(result.detected_sdks)} SDKs  |  Time: {duration}s")
    for cat, count in sorted(result.summary_by_category().items(), key=lambda x: -x[1]):
        print(f"    {cat:<28} {count}")
    print(f"{'='*52}")


def _print_batch_summary(rows: list[dict]):
    apk_col_width = 30
    label_col_width = 34
    sdk_col_width = 4
    time_col_width = 6

    print(f"\n{'='*72}")
    print(f"  BATCH SUMMARY — {len(rows)} APKs analyzed")
    print(f"{'='*72}")
    ok   = [r for r in rows if r["error"] is None]
    fail = [r for r in rows if r["error"] is not None]
    if ok:
        print(
            f"  {_ljust_display('APK', apk_col_width)} "
            f"{_ljust_display('App / Package', label_col_width)} "
            f"{'SDKs':>{sdk_col_width}}  {'Time':>{time_col_width}}"
        )
        print(f"  {'-'*apk_col_width} {'-'*label_col_width} {'-'*sdk_col_width}  {'-'*time_col_width}")
        for r in ok:
            apk_name = _ljust_display(Path(r["apk"]).name, apk_col_width)
            label = _ljust_display((r.get("app_name") or r.get("package") or "unknown"), label_col_width)
            time_text = f"{r['duration']:.1f}s"
            print(f"  {apk_name} {label} {r['sdk_count']:>{sdk_col_width}}  {time_text:>{time_col_width}}")
    if fail:
        print(f"\n  FAILED ({len(fail)}):")
        for r in fail:
            print(f"  [!] {Path(r['apk']).name}: {r['error']}")
    total_sdks = sum(r["sdk_count"] for r in ok if r["sdk_count"] is not None)
    total_time = sum(r["duration"] for r in rows)
    print(f"{'='*72}")
    print(f"  Total SDKs detected: {total_sdks}  |  Total time: {total_time:.1f}s")
    print(f"{'='*72}")


def run_single(apk_path: Path, sig_dir: Path, args):
    print(f"[*] Analyzing: {apk_path}")
    t0 = time.time()
    try:
        result = analyze_one(apk_path, sig_dir, args)
        duration = round(time.time() - t0, 2)
        result.duration_seconds = duration
        out_json, out_html = resolve_output_paths(apk_path, args)
        write_reports(result, out_json, out_html, args.format)
        if args.format in ("json", "both"):
            print(f"[+] JSON: {out_json}")
        if args.format in ("html", "both"):
            print(f"[+] HTML: {out_html}")
        _print_single_summary(result, duration)
    except Exception as e:
        print(f"[ERROR] {e}", file=sys.stderr)
        if args.verbose:
            import traceback; traceback.print_exc()
        sys.exit(1)


def run_batch(apk_paths: list[Path], sig_dir: Path, args):
    total = len(apk_paths)
    print(f"[*] Batch mode — {total} APK(s) found")
    if args.output_dir:
        print(f"[*] Output dir: {args.output_dir}")
    rows = []
    for i, apk_path in enumerate(apk_paths, 1):
        print(f"[{i}/{total}] {apk_path.name} ...", end=" ", flush=True)
        t0 = time.time()
        try:
            result = analyze_one(apk_path, sig_dir, args)
            duration = round(time.time() - t0, 2)
            result.duration_seconds = duration
            out_json, out_html = resolve_output_paths(apk_path, args)
            write_reports(result, out_json, out_html, args.format)
            sdk_count = len(result.detected_sdks)
            pkg = result.manifest.package_name or result.apk_info.package_name
            app_name = result.manifest.app_name
            print(f"OK  ({sdk_count} SDKs, {duration}s)")
            rows.append({"apk": str(apk_path), "package": pkg, "app_name": app_name,
                         "sdk_count": sdk_count, "duration": duration, "error": None})
        except Exception as e:
            duration = round(time.time() - t0, 2)
            print(f"FAILED  ({e})")
            rows.append({"apk": str(apk_path), "package": None, "app_name": None,
                         "sdk_count": None, "duration": duration, "error": str(e)})
    _print_batch_summary(rows)


def main():
    p = argparse.ArgumentParser(prog="apk-analyzer",
        description="Static SDK detection for Android APK files",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Examples:\n"
            "  apk-analyzer app.apk\n"
            "  apk-analyzer app.apk --output-dir ./reports\n"
            "  apk-analyzer --batch /path/to/apks/\n"
            "  apk-analyzer --batch /path/to/apks/ --output-dir ./reports\n"
            "  apk-analyzer app.apk --categories push,ads"
        ))
    input_group = p.add_mutually_exclusive_group(required=True)
    input_group.add_argument("apk_file", nargs="?", default=None, help="Path to APK file")
    input_group.add_argument("--batch", metavar="DIR",
                             help="Directory containing APK files to analyze in batch")
    p.add_argument("--signatures-dir", default=None)
    p.add_argument("--so-db-file", default=None,
                   help="Path to SO descriptions YAML file (overrides built-in DB)")
    p.add_argument("--output-dir", default=None)
    p.add_argument("--output-json", default=None)
    p.add_argument("--output-html", default=None)
    p.add_argument("--format", choices=["json", "html", "both"], default="both")
    p.add_argument("--categories", default=None)
    p.add_argument("--verbose", "-v", action="store_true")
    p.add_argument("--version", action="version",
                   version=f"apk-analyzer {__import__('importlib.metadata', fromlist=['version']).version('apk-analyzer')}")
    args = p.parse_args()

    if args.batch and (args.output_json or args.output_html):
        p.error("--output-json and --output-html cannot be used with --batch; use --output-dir instead")

    sig_dir = Path(args.signatures_dir) if args.signatures_dir else _default_signatures_dir()
    if not sig_dir.exists():
        print(f"[ERROR] Signatures directory not found: {sig_dir}", file=sys.stderr)
        print(f"  Tip: use --signatures-dir <path> to specify manually", file=sys.stderr)
        sys.exit(1)
    if args.so_db_file:
        so_db_path = Path(args.so_db_file)
        if not so_db_path.exists() or not so_db_path.is_file():
            print(f"[ERROR] SO DB file not found: {so_db_path}", file=sys.stderr)
            print(f"  Tip: use --so-db-file <path/to/so_files.yaml>", file=sys.stderr)
            sys.exit(1)

    print(f"[*] APK SDK Analyzer")
    if args.batch:
        batch_dir = Path(args.batch)
        if not batch_dir.is_dir():
            print(f"[ERROR] Not a directory: {batch_dir}", file=sys.stderr); sys.exit(1)
        apk_paths = sorted(batch_dir.glob("*.apk"))
        if not apk_paths:
            print(f"[ERROR] No .apk files found in: {batch_dir}", file=sys.stderr); sys.exit(1)
        run_batch(apk_paths, sig_dir, args)
    else:
        apk_path = Path(args.apk_file)
        if not apk_path.exists():
            print(f"[ERROR] File not found: {apk_path}", file=sys.stderr); sys.exit(1)
        run_single(apk_path, sig_dir, args)


if __name__ == "__main__":
    main()
