"""APK Parser - extracts DEX, manifest, native libs, resources.arsc from APK (ZIP)."""
import hashlib
import zipfile
import re
from pathlib import Path
from apk_analyzer.models import APKInfo
from apk_analyzer.analyzer.pyaxml_logging import suppress_pyaxmlparser_warnings

_REQUIRED_32 = "armeabi-v7a"
_REQUIRED_64 = "arm64-v8a"
_ABI_ALIASES = {
    "arm64_v8a": "arm64-v8a",
    "armeabi_v7a": "armeabi-v7a",
    "x86-64": "x86_64",
    "aarch64": "arm64-v8a",
}

_CHANNEL_FILE_PATTERNS = [
    re.compile(r"^meta-inf/(?:channel|mtchannel)[_-]([a-z0-9._-]+)$", re.IGNORECASE),
    re.compile(r"^meta-inf/(?:channel|mtchannel)/([a-z0-9._-]+)$", re.IGNORECASE),
]

_CHANNEL_TEXT_FILES = {
    "meta-inf/channel",
    "meta-inf/channel.txt",
    "meta-inf/wallechannel",
    "assets/channel",
    "assets/channel.txt",
}

_STORE_MARKER_FILES = {
    "meta-inf/com.android.vending": "google_play",
    "meta-inf/com.huawei.appmarket": "huawei_appgallery",
    "meta-inf/com.xiaomi.market": "xiaomi_store",
    "meta-inf/com.tencent.android.qqdownloader": "tencent_appstore",
    "meta-inf/com.bbk.appstore": "vivo_store",
    "meta-inf/com.oppo.market": "oppo_store",
}


def _normalize_zip_name(name: str) -> str:
    normalized = name.replace("\\", "/")
    while normalized.startswith("./"):
        normalized = normalized[2:]
    return normalized.lstrip("/")


def _normalize_abi_name(abi: str) -> str:
    normalized = abi.strip().lower()
    return _ABI_ALIASES.get(normalized, normalized)


def _classify_abi_support(lib_abis: dict[str, set[str]]) -> str:
    if not lib_abis:
        return "Other"

    all_have_32 = all(_REQUIRED_32 in abis for abis in lib_abis.values())
    all_have_64 = all(_REQUIRED_64 in abis for abis in lib_abis.values())

    if all_have_32 and all_have_64:
        return "32/64-bit"
    if all_have_64:
        return "64-bit"
    if all_have_32:
        return "32-bit"
    return "Other"


# Common DPI qualifiers in order
_DPI_ORDER = ["ldpi", "mdpi", "tvdpi", "hdpi", "xhdpi", "xxhdpi", "xxxhdpi", "nodpi", "anydpi"]
_DPI_ALIASES = {
    "ldpi": "ldpi",
    "mdpi": "mdpi",
    "tvdpi": "tvdpi",
    "hdpi": "hdpi",
    "xhdpi": "xhdpi",
    "xxhdpi": "xxhdpi",
    "xxxhdpi": "xxxhdpi",
    "nodpi": "nodpi",
    "anydpi": "anydpi",
}


def _normalize_dpi_name(dpi: str) -> str:
    """Normalize DPI qualifier name."""
    normalized = dpi.strip().lower()
    return _DPI_ALIASES.get(normalized, normalized)


def _extract_dpi_from_dir(dir_name: str) -> str | None:
    """Extract DPI qualifier from directory name like 'drawable-mdpi' or 'mipmap-xxhdpi'."""
    parts = dir_name.split("-")
    if len(parts) < 2:
        return None
    dpi_part = "-".join(parts[1:])  # Handle cases like "drawable-anydpi-v21"
    # Extract just the DPI part (before any additional qualifiers like -v21)
    dpi_base = dpi_part.split("-")[0]
    normalized = _normalize_dpi_name(dpi_base)
    return normalized if normalized in _DPI_ORDER else None


def _classify_dpi_support(resource_dpis: dict[str, set[str]]) -> str:
    """Classify DPI support summary."""
    if not resource_dpis:
        return "No image resources"
    
    all_dpis = set()
    for dpis in resource_dpis.values():
        all_dpis.update(dpis)
    
    if not all_dpis:
        return "No image resources"
    
    # Common DPI sets
    common_dpis = {"mdpi", "hdpi", "xhdpi", "xxhdpi"}
    has_all_common = common_dpis.issubset(all_dpis)
    
    if has_all_common and len(all_dpis) >= 4:
        return "Full DPI support"
    
    # Sort by standard order
    sorted_dpis = sorted(all_dpis, key=lambda d: _DPI_ORDER.index(d) if d in _DPI_ORDER else 999)
    return "/".join(sorted_dpis)


def _extract_resource_references_from_xml(xml_content: bytes) -> set[str]:
    """Extract resource references from XML content (layout files, etc.)."""
    references = set()
    try:
        text = xml_content.decode("utf-8", errors="ignore")
        # Match @drawable/resource_name or @mipmap/resource_name
        patterns = [
            r'@drawable/([a-zA-Z0-9_]+)',
            r'@mipmap/([a-zA-Z0-9_]+)',
            r'android:src="@drawable/([a-zA-Z0-9_]+)"',
            r'android:icon="@mipmap/([a-zA-Z0-9_]+)"',
            r'android:background="@drawable/([a-zA-Z0-9_]+)"',
        ]
        for pattern in patterns:
            matches = re.findall(pattern, text)
            references.update(matches)
    except Exception:
        pass
    return references


def _extract_resource_references_from_strings(strings: list[str]) -> set[str]:
    """Extract resource name references from DEX string pool."""
    references = set()
    # Look for resource names in strings (common patterns)
    resource_patterns = [
        r'R\.(drawable|mipmap)\.([a-zA-Z0-9_]+)',
        r'/(drawable|mipmap)/([a-zA-Z0-9_]+)',
    ]
    for s in strings:
        for pattern in resource_patterns:
            matches = re.findall(pattern, s)
            for match in matches:
                if isinstance(match, tuple) and len(match) == 2:
                    references.add(match[1])  # Resource name
                elif isinstance(match, str):
                    references.add(match)
    return references


def _normalize_digest(value) -> str:
    if value is None:
        return ""
    if isinstance(value, bytes):
        return value.hex()
    text = str(value).strip().lower().replace(":", "")
    return text


def _human_friendly_name(name_obj) -> str:
    if name_obj is None:
        return ""
    human = getattr(name_obj, "human_friendly", None)
    if human:
        return str(human)
    native = getattr(name_obj, "native", None)
    if isinstance(native, dict):
        return ", ".join(f"{k}={v}" for k, v in native.items())
    if native is not None:
        return str(native)
    return str(name_obj)


def _extract_organization_from_subject(subject_obj) -> str:
    if subject_obj is None:
        return ""

    native = getattr(subject_obj, "native", None)
    if isinstance(native, dict):
        org = native.get("organization_name")
        if isinstance(org, list):
            org = ", ".join(str(i).strip() for i in org if str(i).strip())
        if org:
            return str(org).strip()

    text = _human_friendly_name(subject_obj)
    if not text:
        return ""
    for pattern in (
        r"(?:^|[,/])\s*O\s*=\s*([^,/]+)",
        r"organization_name\s*=\s*([^,]+)",
        r"Organization\s*:\s*([^,]+)",
    ):
        match = re.search(pattern, text, flags=re.IGNORECASE)
        if match:
            return match.group(1).strip()
    return ""


def _extract_signing_info(apk_path: Path) -> tuple[list[str], list[dict[str, str]]]:
    schemes: list[str] = []
    certs: list[dict[str, str]] = []

    try:
        with suppress_pyaxmlparser_warnings():
            import pyaxmlparser
            apk = pyaxmlparser.APK(str(apk_path))
    except Exception:
        return schemes, certs

    for scheme_name, checker in (
        ("v1", getattr(apk, "is_signed_v1", None)),
        ("v2", getattr(apk, "is_signed_v2", None)),
        ("v3", getattr(apk, "is_signed_v3", None)),
    ):
        if checker is None:
            continue
        try:
            if checker():
                schemes.append(scheme_name)
        except Exception:
            continue

    seen_fingerprints: set[str] = set()
    for getter_name in ("get_certificates_v1", "get_certificates_v2", "get_certificates_v3"):
        getter = getattr(apk, getter_name, None)
        if getter is None:
            continue
        try:
            certificates = getter() or []
        except Exception:
            continue
        for cert in certificates:
            subject = getattr(cert, "subject", None)
            sha256 = _normalize_digest(getattr(cert, "sha256", ""))
            if sha256 and sha256 in seen_fingerprints:
                continue
            if sha256:
                seen_fingerprints.add(sha256)
            certs.append({
                "sha256": sha256,
                "sha1": _normalize_digest(getattr(cert, "sha1", "")),
                "subject": _human_friendly_name(subject),
                "issuer": _human_friendly_name(getattr(cert, "issuer", None)),
                "serial_number": str(getattr(cert, "serial_number", "")),
                "organization": _extract_organization_from_subject(subject),
            })

    return schemes, certs


def _extract_archive_channel_hints(
    zf: zipfile.ZipFile, names: list[str]
) -> tuple[str, list[str]]:
    path_map: dict[str, str] = {}
    for raw_name in names:
        path_map[_normalize_zip_name(raw_name).lower()] = raw_name

    hints: list[str] = []
    channel_value = ""

    for normalized_name in sorted(path_map.keys()):
        for pattern in _CHANNEL_FILE_PATTERNS:
            match = pattern.match(normalized_name)
            if match:
                detected = match.group(1).strip()
                if detected and not channel_value:
                    channel_value = detected
                hints.append(f"archive-file:{normalized_name}")
                break

        marker = _STORE_MARKER_FILES.get(normalized_name)
        if marker:
            hints.append(f"store-marker:{marker}")

    if not channel_value:
        for channel_file in _CHANNEL_TEXT_FILES:
            raw_name = path_map.get(channel_file)
            if not raw_name:
                continue
            try:
                text = zf.read(raw_name).decode("utf-8", errors="ignore").strip()
            except Exception:
                continue
            if not text:
                continue
            first_line = text.splitlines()[0].strip()
            if first_line:
                channel_value = first_line
                hints.append(f"archive-content:{channel_file}")
                break

    return channel_value, list(dict.fromkeys(hints))


class APKParser:
    def __init__(self, apk_path: Path):
        self.apk_path = apk_path

    def parse(self) -> APKInfo:
        info = APKInfo()
        info.file_size_bytes = self.apk_path.stat().st_size
        sha = hashlib.sha256()
        with open(self.apk_path, "rb") as f:
            for chunk in iter(lambda: f.read(65536), b""):
                sha.update(chunk)
        info.signature_sha256 = sha.hexdigest()

        with zipfile.ZipFile(self.apk_path, "r") as zf:
            names = zf.namelist()
            source_channel_raw, channel_hints = _extract_archive_channel_hints(zf, names)
            info.source_channel_raw = source_channel_raw
            info.channel_hints = channel_hints

            dex_names = sorted(n for n in names if n.endswith(".dex"))
            info.dex_count = len(dex_names)
            info.dex_files = [zf.read(dn) for dn in dex_names]

            if "AndroidManifest.xml" in names:
                info.manifest_data = zf.read("AndroidManifest.xml")

            if "resources.arsc" in names:
                info.resources_arsc = zf.read("resources.arsc")

            seen_libs = set()
            lib_abis: dict[str, set[str]] = {}
            for raw_name in names:
                n = _normalize_zip_name(raw_name)
                if not n.startswith("lib/"):
                    continue

                parts = n.split("/")
                if len(parts) < 3 or not parts[1] or not parts[-1]:
                    continue

                if not n.endswith(".so"):
                    continue

                abi_name = _normalize_abi_name(parts[1])
                lib_name = parts[-1]
                if lib_name not in seen_libs:
                    seen_libs.add(lib_name)
                    info.native_libs.append(lib_name)
                    lib_abis[lib_name] = set()
                lib_abis[lib_name].add(abi_name)

            info.native_lib_arches = {k: sorted(v) for k, v in lib_abis.items()}
            info.native_abis = sorted({abi for abis in lib_abis.values() for abi in abis})
            info.abi_support = _classify_abi_support(lib_abis)

            # Parse resource directories (drawable/mipmap DPI support)
            resource_dirs: dict[str, set[str]] = {}
            image_extensions = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".xml"}
            for raw_name in names:
                n = _normalize_zip_name(raw_name)
                if not n.startswith("res/"):
                    continue
                
                parts = n.split("/")
                if len(parts) < 3:
                    continue
                
                # Check if it's a drawable or mipmap directory
                dir_name = parts[1]  # e.g., "drawable-mdpi" or "mipmap-xxhdpi"
                if not (dir_name.startswith("drawable-") or dir_name.startswith("mipmap-")):
                    continue
                
                # Extract DPI qualifier
                dpi = _extract_dpi_from_dir(dir_name)
                if not dpi:
                    continue
                
                # Get resource file name (without extension for grouping)
                file_name = parts[-1]
                if not any(file_name.lower().endswith(ext) for ext in image_extensions):
                    continue
                
                # Group by base name (without extension and DPI)
                base_name = file_name.rsplit(".", 1)[0] if "." in file_name else file_name
                resource_key = f"{dir_name.split('-')[0]}/{base_name}"  # e.g., "drawable/icon" or "mipmap/ic_launcher"
                
                if resource_key not in resource_dirs:
                    resource_dirs[resource_key] = set()
                resource_dirs[resource_key].add(dpi)
            
            info.resource_dirs = {k: sorted(v, key=lambda d: _DPI_ORDER.index(d) if d in _DPI_ORDER else 999) 
                                 for k, v in resource_dirs.items()}
            info.resource_dpis = sorted({dpi for dpis in resource_dirs.values() for dpi in dpis},
                                        key=lambda d: _DPI_ORDER.index(d) if d in _DPI_ORDER else 999)
            info.dpi_support = _classify_dpi_support(resource_dirs)

            # Extract resource references from XML files and DEX strings
            resource_refs = set()
            # Parse XML layout files
            for raw_name in names:
                n = _normalize_zip_name(raw_name)
                if n.startswith("res/layout") or n.startswith("res/xml") or n.startswith("res/menu"):
                    if n.endswith(".xml"):
                        try:
                            xml_content = zf.read(raw_name)
                            refs = _extract_resource_references_from_xml(xml_content)
                            resource_refs.update(refs)
                        except Exception:
                            pass
            
            # Store resource references (will be matched with resource names later)
            info.resource_references = resource_refs

            for n in names:
                if n.startswith("META-INF/") and n.endswith(".version"):
                    key = n[len("META-INF/"):-len(".version")]
                    try:
                        val = zf.read(n).decode("utf-8", errors="ignore").strip()
                        if val:
                            info.metainf_versions[key] = val
                    except Exception:
                        pass

        signing_schemes, signing_certs = _extract_signing_info(self.apk_path)
        info.signing_schemes = signing_schemes
        info.signing_certificates = signing_certs
        info.signing_organization = next(
            (cert.get("organization", "").strip() for cert in signing_certs if cert.get("organization", "").strip()),
            "",
        )
        return info
