from __future__ import annotations

import argparse
import inspect
from pathlib import Path
from typing import Any

import orjson
import yaml

from tracecat.registry.repository import Repository

ROOT_DIR = Path(__file__).resolve().parent.parent
DOCS_DIR = ROOT_DIR / "docs" / "automations" / "core-actions"
MANIFEST_PATH = DOCS_DIR / "_manifest.yaml"
EXAMPLES_PATH = DOCS_DIR / "_examples.yaml"
AUTO_GENERATED_COMMENT = "{/* Auto-generated by scripts/generate_core_action_docs.py; do not edit by hand. */}"
ENTERPRISE_BADGE = (
    '<Badge icon="lock" color="blue" size="lg" shape="pill">Enterprise Edition</Badge>'
)


def _escape_text(text: str) -> str:
    return (
        text.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace("{", "&#123;")
        .replace("}", "&#125;")
    )


def _clean_docstring(text: str | None) -> str | None:
    if not text:
        return None

    lines: list[str] = []
    seen_content = False
    for raw in text.splitlines():
        line = raw.rstrip()
        stripped = line.strip()
        if stripped in {"Args:", "Returns:", "Raises:"}:
            break
        if seen_content and not stripped:
            break
        if stripped:
            seen_content = True
        lines.append(line)

    while lines and not lines[0].strip():
        lines.pop(0)
    while lines and not lines[-1].strip():
        lines.pop()
    if not lines:
        return None

    normalized: list[str] = []
    for line in lines:
        stripped = line.strip()
        if not stripped:
            continue
        if normalized:
            normalized[-1] = f"{normalized[-1]} {stripped}"
        else:
            normalized.append(stripped)

    return "\n".join(normalized) if normalized else None


def _load_yaml(path: Path) -> dict[str, Any]:
    data = yaml.safe_load(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError(f"Expected {path} to contain a mapping at the top level.")
    return data


def _format_value(value: Any) -> str:
    return orjson.dumps(value, option=orjson.OPT_INDENT_2).decode("utf-8")


def _format_type(schema: dict[str, Any] | None) -> str:
    if not schema:
        return "any"

    if enum_values := schema.get("enum"):
        base_type = schema.get("type")
        if base_type:
            return str(base_type)
        if enum_values:
            return "enum"

    variants = schema.get("anyOf") or schema.get("oneOf")
    if isinstance(variants, list) and variants:
        parts: list[str] = []
        for variant in variants:
            part = _format_type(variant)
            if part not in parts:
                parts.append(part)
        return " | ".join(parts)

    if ref := schema.get("$ref"):
        return str(ref).rsplit("/", maxsplit=1)[-1]

    schema_type = schema.get("type")
    if isinstance(schema_type, list):
        return " | ".join(str(item) for item in schema_type)
    if schema_type == "array":
        return f"array[{_format_type(schema.get('items'))}]"
    if schema_type == "object":
        additional = schema.get("additionalProperties")
        if isinstance(additional, dict):
            return f"map[string, {_format_type(additional)}]"
        return "object"
    if isinstance(schema_type, str):
        return schema_type

    return "any"


def _find_object_schema(schema: dict[str, Any] | None) -> dict[str, Any] | None:
    if not schema:
        return None
    if schema.get("type") == "object" or schema.get("properties"):
        return schema
    for key in ("anyOf", "oneOf"):
        variants = schema.get(key)
        if not isinstance(variants, list):
            continue
        for variant in variants:
            if obj_schema := _find_object_schema(variant):
                return obj_schema
    return None


def _render_nested_fields(schema: dict[str, Any]) -> list[str]:
    obj_schema = _find_object_schema(schema)
    if not obj_schema:
        return []

    properties = obj_schema.get("properties", {})
    if not isinstance(properties, dict) or not properties:
        return []

    required = set(obj_schema.get("required", []))
    lines = ["", "Nested fields:"]
    for name in sorted(properties):
        child = properties[name]
        if not isinstance(child, dict):
            continue
        child_type = _format_type(child)
        label = f"- `{name}` (`{child_type}`"
        if name in required:
            label += ", required"
        label += ")"
        if description := child.get("description"):
            label += f": {_escape_text(str(description).strip())}"
        lines.append(label)
    return lines


def _order_field_names(
    properties: dict[str, Any], required_fields: set[str]
) -> list[str]:
    required = sorted(name for name in properties if name in required_fields)
    optional = sorted(name for name in properties if name not in required_fields)
    return [*required, *optional]


def _render_field(name: str, schema: dict[str, Any], required: bool) -> list[str]:
    field_type = _escape_text(_format_type(schema))
    required_attr = " required" if required else ""
    lines = [f'<ParamField path="{name}" type="{field_type}"{required_attr}>', ""]

    if description := schema.get("description"):
        lines.append(_escape_text(str(description).strip()))

    if "default" in schema:
        lines.extend(["", f"Default: `{_format_value(schema['default'])}`."])

    if enum_values := schema.get("enum"):
        allowed_values = ", ".join(f"`{value}`" for value in enum_values)
        lines.extend(["", f"Allowed values: {allowed_values}."])

    lines.extend(_render_nested_fields(schema))
    lines.extend(["", "</ParamField>", ""])
    return lines


def _render_input_fields(expects_schema: dict[str, Any]) -> list[str]:
    properties = expects_schema.get("properties", {})
    if not isinstance(properties, dict) or not properties:
        return ["This action does not take input fields.", ""]

    required_fields = set(expects_schema.get("required", []))
    lines: list[str] = []
    for name in _order_field_names(properties, required_fields):
        schema = properties[name]
        if not isinstance(schema, dict):
            continue
        lines.extend(
            _render_field(name=name, schema=schema, required=name in required_fields)
        )
    return lines


def _render_secret(secret: Any) -> str:
    secret_type = getattr(secret, "type", None)
    name = f"`{_escape_text(str(secret.name))}`"

    if secret_type == "custom":
        required_keys = getattr(secret, "keys", None) or []
        optional_keys = getattr(secret, "optional_keys", None) or []
        details: list[str] = []
        if required_keys:
            details.append(
                "required values "
                + ", ".join(f"`{_escape_text(str(key))}`" for key in required_keys)
            )
        if optional_keys:
            details.append(
                "optional values "
                + ", ".join(f"`{_escape_text(str(key))}`" for key in optional_keys)
            )
        return f"- {name}: {'; '.join(details)}."

    if secret_type == "oauth":
        return f"- {name}: OAuth token `{_escape_text(str(secret.token_name))}`."

    raise ValueError(f"Unsupported secret type: {secret_type!r}")


def _render_secrets(action: Any) -> list[str]:
    secrets = getattr(action, "secrets", None) or []
    if not secrets:
        return []

    lines = ["### Secrets", ""]
    required_secrets = [
        secret for secret in secrets if not getattr(secret, "optional", False)
    ]
    optional_secrets = [
        secret for secret in secrets if getattr(secret, "optional", False)
    ]

    if required_secrets:
        lines.extend(["Required secrets:", ""])
        lines.extend(_render_secret(secret) for secret in required_secrets)
        lines.append("")

    if optional_secrets:
        lines.extend(["Optional secrets:", ""])
        lines.extend(_render_secret(secret) for secret in optional_secrets)
        lines.append("")

    return lines


def _get_description(action: Any) -> str:
    docstring = _clean_docstring(inspect.getdoc(action.fn))
    return docstring or action.description


def _render_examples(example_ids: list[str], examples: dict[str, Any]) -> list[str]:
    lines: list[str] = []
    for example_id in example_ids:
        example = examples.get(example_id)
        if not isinstance(example, dict):
            raise ValueError(f"Unknown example {example_id!r}.")
        language = str(example.get("language", "yaml"))
        title = example.get("title")
        code = str(example.get("code", "")).rstrip()
        if not code:
            raise ValueError(f"Example {example_id!r} is missing code.")
        if title:
            lines.extend([f"**{_escape_text(str(title))}**", ""])
        lines.extend([f"```{language}", code, "```", ""])
    return lines


def _render_page(
    page: dict[str, Any], actions_by_id: dict[str, Any], examples: dict[str, Any]
) -> str:
    title = page["title"]
    action_entries = page["actions"]
    lines = [
        "---",
        f'title: "{title}"',
        "---",
        "",
        AUTO_GENERATED_COMMENT,
        "",
    ]

    for action_entry in action_entries:
        action_id = action_entry["id"]
        action = actions_by_id[action_id]
        interface = action.get_interface()
        expects_schema = interface["expects"]

        lines.extend(
            [
                f"## `{action_id}`",
                "",
                _escape_text(_get_description(action)),
                "",
            ]
        )

        if action.required_entitlements:
            lines.extend([ENTERPRISE_BADGE, ""])

        if image_path := action_entry.get("image"):
            lines.extend([f"![{action_id}]({image_path})", ""])

        if info_block := action_entry.get("info"):
            lines.extend([str(info_block).strip(), ""])

        lines.extend(_render_secrets(action))

        lines.extend(["### Inputs", ""])
        lines.extend(_render_input_fields(expects_schema))

        lines.extend(["### Examples", ""])
        lines.extend(_render_examples(action_entry["examples"], examples))

    return "\n".join(lines).rstrip() + "\n"


def _write_if_changed(path: Path, content: str, check: bool) -> bool:
    existing = path.read_text(encoding="utf-8") if path.exists() else None
    changed = existing != content
    if changed and not check:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")
    return changed


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--check",
        action="store_true",
        help="Fail if generated output differs from the checked-in files.",
    )
    args = parser.parse_args()

    manifest = _load_yaml(MANIFEST_PATH)
    examples_data = _load_yaml(EXAMPLES_PATH)
    pages = manifest.get("pages")
    examples = examples_data.get("examples")
    if not isinstance(pages, list):
        raise ValueError("Manifest must contain a 'pages' list.")
    if not isinstance(examples, dict):
        raise ValueError("Examples file must contain an 'examples' mapping.")

    repo = Repository()
    repo.init(include_base=True, include_templates=False)

    actions_by_id: dict[str, Any] = {}
    seen_action_ids: set[str] = set()
    for page in pages:
        if not isinstance(page, dict):
            raise ValueError("Each page entry must be a mapping.")
        action_entries = page.get("actions")
        if not isinstance(action_entries, list):
            raise ValueError(f"Page {page!r} is missing an actions list.")
        for action_entry in action_entries:
            if not isinstance(action_entry, dict):
                raise ValueError("Action entry must be a mapping.")
            action_id = action_entry.get("id")
            if not isinstance(action_id, str):
                raise ValueError(f"Action entry {action_entry!r} is missing an id.")
            if not action_id.startswith("core."):
                raise ValueError(f"Expected a core action id, got {action_id!r}.")
            if action_id in seen_action_ids:
                raise ValueError(f"Duplicate action id in manifest: {action_id!r}.")
            seen_action_ids.add(action_id)
            if action_id not in actions_by_id:
                actions_by_id[action_id] = repo.get(action_id)
            example_ids = action_entry.get("examples")
            if not isinstance(example_ids, list) or not example_ids:
                raise ValueError(
                    f"Action {action_id!r} must define at least one example."
                )

    registered_core_actions = {
        action.action
        for action in repo.store.values()
        if action.action.startswith("core.")
    }
    undocumented_actions = sorted(registered_core_actions - seen_action_ids)
    unknown_actions = sorted(seen_action_ids - registered_core_actions)
    if undocumented_actions or unknown_actions:
        problems: list[str] = []
        if undocumented_actions:
            problems.append(
                "Undocumented core actions: "
                + ", ".join(f"`{action_id}`" for action_id in undocumented_actions)
            )
        if unknown_actions:
            problems.append(
                "Unknown core actions in manifest: "
                + ", ".join(f"`{action_id}`" for action_id in unknown_actions)
            )
        raise ValueError("\n".join(problems))

    changed_paths: list[str] = []
    for page in pages:
        slug = page.get("slug")
        if not isinstance(slug, str):
            raise ValueError(f"Page {page!r} is missing a slug.")
        output_path = DOCS_DIR / f"{slug}.mdx"
        content = _render_page(
            page=page, actions_by_id=actions_by_id, examples=examples
        )
        if _write_if_changed(output_path, content, check=args.check):
            changed_paths.append(str(output_path.relative_to(ROOT_DIR)))

    if args.check and changed_paths:
        print("Generated core action docs are out of date:")
        for path in changed_paths:
            print(f"- {path}")
        raise SystemExit(1)


if __name__ == "__main__":
    main()
