"""
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License,
or (at your option) any later version.


This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 
See the GNU General Public License for more details.


You should have received a copy of the GNU General Public License
 along with this program.  If not, see <https://www.gnu.org/licenses/>.

Copyright © 2019 Cloud Linux Software Inc.

This software is also available under ImunifyAV commercial license,
see <https://www.imunify360.com/legal/eula>

Optional cleaner diagnostics; local paths never form telemetry fields."""

import json
from collections import OrderedDict
import re
import time

ERRNOS = {
    "EACCES",
    "EPERM",
    "ENOENT",
    "ENOTDIR",
    "EIO",
    "ENOSPC",
    "EROFS",
    "ELOOP",
    "ENAMETOOLONG",
    "EISDIR",
    "EMFILE",
    "ENFILE",
}
OPERATIONS = {
    "stat",
    "traverse",
    "read",
    "write",
    "delete",
    "cloud_lookup",
    "cloud_download",
    "match",
    "rescan",
}
SCOPES = {"ancestor", "leaf", "unresolved"}


def parse_reason(data):
    if isinstance(data, str):
        if len(data) > 16384:
            return {}
        try:
            data = json.loads(data)
        except (ValueError, RecursionError):
            return {}
    if not isinstance(data, dict):
        return {}
    version = data.get("reason_version")
    if type(version) is not int or not 1 <= version <= 2**32 - 1:
        return {}
    result = {"reason_version": version}
    for key in ("reason", "cleaner_reason"):
        reason = data.get(key)
        if isinstance(reason, str) and re.fullmatch(r"[ -~]{1,64}", reason):
            result[key] = reason
    path = data.get("reason_path")
    if isinstance(path, str) and len(path) <= 8192:
        result["reason_path"] = path
    for key, allowed in (
        ("reason_errno", ERRNOS),
        ("reason_operation", OPERATIONS),
        ("reason_path_scope", SCOPES),
    ):
        value = data.get(key)
        if isinstance(value, str) and value in allowed:
            result[key] = value
    status = data.get("reason_http_status")
    if type(status) is int and 100 <= status <= 599:
        result["reason_http_status"] = status
    return result


def local_details(result, user, process_error=False):
    reason = result.get("reason")
    if not reason:
        reason = {
            0: "rescan_detected",
            1: "file_too_large",
            2: "read_error",
            3: "write_error",
            4: "legacy_reason_unavailable",
            5: "legacy_reason_unavailable",
            6: "matched_not_cleaned",
            7: "system_owner",
        }.get(result.get("e"), "unsupported_result")
        if "e" not in result:
            reason = (
                "cleanup_process_failed" if process_error else "report_missing"
            )
    details = {"kind": "cleanup", "version": 1, "reason": reason}
    if isinstance(user, str):
        details["cleanup_user"] = user
    for source, target in (
        ("cleaner_reason", "cleaner_reason"),
        ("reason_errno", "errno"),
        ("reason_path", "path"),
        ("reason_path_scope", "path_scope"),
        ("reason_operation", "operation"),
        ("reason_http_status", "http_status"),
        ("curator", "curator"),
        ("curator_version", "curator_version"),
        ("hash_before", "hash_before"),
        ("hash_after", "hash_after"),
    ):
        if result.get(source):
            details[target] = result[source]
    return details


def result_for_server(result, details=None):
    legacy = {
        "d",
        "e",
        "s",
        "r",
        "f",
        "t",
        "mtime_before",
        "mtime_after",
        "hash_before",
        "hash_after",
        "ph",
    }
    projected = {key: value for key, value in result.items() if key in legacy}
    diagnostic = parse_reason(result)
    if isinstance(details, dict) and not diagnostic.get("reason"):
        # Only classified unsuccessful rows have local cleanup details.
        # Successful no-write e=4 rows retain their existing projection.
        diagnostic.update(
            parse_reason(
                {
                    "reason_version": details.get("version"),
                    "reason": details.get("reason"),
                }
            )
        )
    path = diagnostic.pop("reason_path", None)
    if path:
        diagnostic["reason_path_pattern"] = "/{path}"
    projected.update(diagnostic)
    for key in ("curator", "curator_version"):
        value = result.get(key)
        if isinstance(value, str) and re.fullmatch(r"[ -~]{1,64}", value):
            projected[key] = value
    return projected


class DiagnosticLogLimiter:
    """One diagnostic per file and reason per day, with bounded memory."""

    def __init__(self):
        self.entries = OrderedDict()

    def allow(self, path, reason, now):
        while (
            self.entries and next(iter(self.entries.values())) <= now - 86400
        ):
            self.entries.popitem(last=False)
        key = (path, reason)
        if key in self.entries:
            return False
        self.entries[key] = now
        if len(self.entries) > 10000:
            self.entries.popitem(last=False)
        return True


_diagnostic_log_limiter = DiagnosticLogLimiter()


def log_diagnostic(logger, path, details, event):
    if not _diagnostic_log_limiter.allow(
        path, details["reason"], time.monotonic()
    ):
        return
    logger.info(
        "Cleanup %s file=%s reason=%s errno=%s failed_path=%s"
        " user=%s curator=%s",
        event,
        json.dumps(path),
        details["reason"],
        details.get("errno", ""),
        json.dumps(details.get("path", "")),
        json.dumps(details.get("cleanup_user", "")),
        details.get("curator", "unknown"),
    )
