"""IOC (Indicator-of-Compromise) analyzers.

Three v1 features:

- **Strings extraction** — `strings`-equivalent over case files;
  filters by min length, optional regex.
- **Hash batch** — SHA-256 + MD5 + SHA-1 of every file the analyst
  asks for (sample, captured artifacts, extracted files).
- **YARA matching** — optional, gated on `python3-yara` being
  installed. Missing dep returns an empty result + INFO log.

Per FN-A0.1 decision #10: YARA rules live at
``~/.local/share/vmanager/forensics/yara-rules/`` by default. No
rules shipped in the wheel.
"""

from __future__ import annotations

import hashlib
import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Sequence

__all__ = [
    "DEFAULT_MIN_STRING_LENGTH",
    "FileHashes",
    "StringHit",
    "YaraMatch",
    "default_yara_rule_dir",
    "extract_strings",
    "hash_file",
    "scan_yara",
]


LOG = logging.getLogger("mgf.vm.forensics.analysis.ioc")


DEFAULT_MIN_STRING_LENGTH = 6


# ---------------------------------------------------------------------------
# Path resolution (lazy CF-04)
# ---------------------------------------------------------------------------


def default_yara_rule_dir() -> Path:
    return Path("~/.local/share/vmanager/forensics/yara-rules").expanduser()


# ---------------------------------------------------------------------------
# Strings
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class StringHit:
    """One ASCII / UTF-16 string found in a file."""

    file: str  # relative path
    offset: int
    encoding: str  # "ascii" | "utf16le"
    text: str


_ASCII_PRINTABLE = set(range(32, 127))


def extract_strings(
    file_path: Path,
    *,
    min_length: int = DEFAULT_MIN_STRING_LENGTH,
    pattern: str | None = None,
    max_hits: int = 5000,
) -> list[StringHit]:
    """Stream-extract printable strings from ``file_path``.

    Walks the file byte-by-byte (chunked) looking for runs of
    printable ASCII >= ``min_length``. Also recognises null-padded
    UTF-16-LE (Windows-style) by detecting alternating
    printable+null pairs.

    ``pattern`` is an optional regex applied as a post-filter on
    each candidate string.

    ``max_hits`` caps the result list so a 4 GB binary doesn't OOM.
    """
    if min_length < 1:
        msg = "min_length must be >= 1"
        raise ValueError(msg)

    regex = re.compile(pattern) if pattern else None
    hits: list[StringHit] = []
    file_label = str(file_path)

    chunk_size = 1 << 16
    pending_ascii: list[int] = []
    pending_ascii_offset = 0
    pending_utf16: list[int] = []
    pending_utf16_offset = 0
    file_offset = 0

    with file_path.open("rb") as f:
        while len(hits) < max_hits:
            chunk = f.read(chunk_size)
            if not chunk:
                break
            i = 0
            while i < len(chunk):
                b = chunk[i]
                # ASCII run.
                if b in _ASCII_PRINTABLE:
                    if not pending_ascii:
                        pending_ascii_offset = file_offset + i
                    pending_ascii.append(b)
                else:
                    # Non-printable terminates the run. Emit if long
                    # enough; reset unconditionally so the NEXT run
                    # starts fresh (a sub-min-length run must not
                    # silently merge with the next).
                    if len(pending_ascii) >= min_length:
                        text = bytes(pending_ascii).decode("ascii", errors="ignore")
                        if regex is None or regex.search(text):
                            hits.append(
                                StringHit(
                                    file=file_label,
                                    offset=pending_ascii_offset,
                                    encoding="ascii",
                                    text=text,
                                ),
                            )
                            if len(hits) >= max_hits:
                                break
                    pending_ascii = []

                # UTF-16-LE run (printable-byte + null pattern). Only
                # enter UTF-16 mode if we're NOT already accumulating
                # an ASCII run — otherwise the trailing null byte at
                # the end of an ASCII string would falsely start a
                # UTF-16 run mid-stream.
                if (
                    not pending_ascii
                    and b in _ASCII_PRINTABLE
                    and i + 1 < len(chunk)
                    and chunk[i + 1] == 0
                ):
                    if not pending_utf16:
                        pending_utf16_offset = file_offset + i
                    pending_utf16.append(b)
                    i += 2  # skip the null byte too
                    continue
                # Anything else terminates a UTF-16 run.
                if pending_utf16:
                    if len(pending_utf16) >= min_length:
                        text = bytes(pending_utf16).decode("ascii", errors="ignore")
                        if regex is None or regex.search(text):
                            hits.append(
                                StringHit(
                                    file=file_label,
                                    offset=pending_utf16_offset,
                                    encoding="utf16le",
                                    text=text,
                                ),
                            )
                            if len(hits) >= max_hits:
                                break
                    pending_utf16 = []

                i += 1
            file_offset += len(chunk)

    # Flush trailing pending runs.
    if pending_ascii and len(pending_ascii) >= min_length and len(hits) < max_hits:
        text = bytes(pending_ascii).decode("ascii", errors="ignore")
        if regex is None or regex.search(text):
            hits.append(
                StringHit(
                    file=file_label,
                    offset=pending_ascii_offset,
                    encoding="ascii",
                    text=text,
                ),
            )

    return hits


# ---------------------------------------------------------------------------
# Hashes
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class FileHashes:
    """Hashes of one file."""

    file: str
    size_bytes: int
    md5: str
    sha1: str
    sha256: str


def hash_file(path: Path) -> FileHashes:
    """Stream-compute MD5 + SHA-1 + SHA-256 in a single pass."""
    md5 = hashlib.md5(usedforsecurity=False)
    sha1 = hashlib.sha1(usedforsecurity=False)
    sha256 = hashlib.sha256()
    chunk_size = 1 << 16
    size = 0
    with path.open("rb") as f:
        while True:
            chunk = f.read(chunk_size)
            if not chunk:
                break
            md5.update(chunk)
            sha1.update(chunk)
            sha256.update(chunk)
            size += len(chunk)
    return FileHashes(
        file=str(path),
        size_bytes=size,
        md5=md5.hexdigest(),
        sha1=sha1.hexdigest(),
        sha256=sha256.hexdigest(),
    )


# ---------------------------------------------------------------------------
# YARA (optional)
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class YaraMatch:
    """One YARA rule match against a file."""

    file: str
    rule: str
    namespace: str = ""
    tags: tuple[str, ...] = field(default_factory=tuple)


def scan_yara(
    targets: Sequence[Path],
    *,
    rules_path: Path | None = None,
) -> list[YaraMatch]:
    """Scan ``targets`` against YARA rules at ``rules_path``.

    Returns an empty list AND logs INFO when:
    - python3-yara is not installed.
    - ``rules_path`` doesn't exist.
    - the rule file has zero rules.

    A YARA rule compilation error raises :class:`OperationError`
    with the offending rule path in the message.
    """
    try:
        import yara
    except ImportError:
        LOG.info(
            "YARA not installed; skipping IOC scan. "
            "Install via `pip install vmanager[forensics-yara]`.",
        )
        return []

    rules_dir = rules_path or default_yara_rule_dir()
    if not rules_dir.exists():
        LOG.info(
            "no YARA rules found at %s; skipping",
            rules_dir,
            extra={"rules_path": str(rules_dir)},
        )
        return []

    # Walk the rules directory; compile every .yar / .yara file.
    rule_files = []
    if rules_dir.is_file():
        rule_files = [rules_dir]
    else:
        for ext in ("*.yar", "*.yara"):
            rule_files.extend(sorted(rules_dir.rglob(ext)))

    if not rule_files:
        LOG.info("no .yar/.yara files under %s; skipping", rules_dir)
        return []

    try:
        compiled = yara.compile(
            filepaths={str(p.relative_to(rules_dir)): str(p) for p in rule_files},
        )
    except yara.SyntaxError as e:
        from mgf.vm.core.exceptions import OperationError

        msg = f"YARA rule compilation failed: {e}"
        raise OperationError(msg) from e

    matches: list[YaraMatch] = []
    for target in targets:
        if not target.exists() or not target.is_file():
            continue
        try:
            for m in compiled.match(str(target)):
                matches.append(
                    YaraMatch(
                        file=str(target),
                        rule=m.rule,
                        namespace=m.namespace,
                        tags=tuple(m.tags),
                    ),
                )
        except yara.Error as e:
            LOG.warning(
                "YARA scan failed on %s: %s",
                target,
                e,
                extra={"target": str(target)},
            )
    return matches
