#!/usr/bin/env python
"""Connector code generator.

Single source of truth: tools/connectors/<name>.yaml

Generates:
  Core  → src/domain/connectors/catalog/<name>.py          (always regenerated)
  Cloud → ../P2HSCloud/.../connectors/<name>.ts             (stub on create; ACTIONS block synced on update)
  Cloud → ../P2HSCloud/.../connectors/index.ts             (idempotent line append)

Usage:
  python tools/connector_gen.py --connector hubspot
  python tools/connector_gen.py --all
  python tools/connector_gen.py --connector hubspot --only core
  python tools/connector_gen.py --connector hubspot --only cloud
  python tools/connector_gen.py --new mailchimp
  python tools/connector_gen.py --list
  python tools/connector_gen.py --dry-run --all
  python tools/connector_gen.py --force --connector hubspot   # overwrite existing TS file
"""
from __future__ import annotations

import argparse
import re
import sys
from datetime import date
from pathlib import Path
from typing import Any

try:
    import yaml
except ImportError:
    print("ERROR: pyyaml required. Run: pip install pyyaml")
    sys.exit(1)

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------

TOOLS_DIR = Path(__file__).parent
ROOT = TOOLS_DIR.parent
YAML_DIR = TOOLS_DIR / "connectors"
CORE_CATALOG = ROOT / "src/domain/connectors/catalog"
CLOUD_ROOT = ROOT.parent / "P2HSCloud/apps/pc-processor/src/handlers/connectors"
CLOUD_INDEX = CLOUD_ROOT / "index.ts"

# Marker that delimits the generated ACTIONS block inside a TS file.
ACTIONS_MARKER = "// Action definitions — generated, do not edit manually"

# ---------------------------------------------------------------------------
# Helpers — value formatters
# ---------------------------------------------------------------------------

def _q(s: str) -> str:
    """Double-quote a string, escaping backslashes and quotes."""
    return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'


def _py_val(v: Any, depth: int = 0) -> str:
    """Recursively format a Python value as Python source code."""
    pad = "    " * depth
    inner = "    " * (depth + 1)

    if v is None:
        return "None"
    if isinstance(v, bool):
        return "True" if v else "False"
    if isinstance(v, int):
        return str(v)
    if isinstance(v, float):
        return repr(v)
    if isinstance(v, str):
        return _q(v)

    if isinstance(v, (list, tuple)):
        is_tuple = isinstance(v, tuple)
        if not v:
            return "()" if is_tuple else "[]"
        rendered = [_py_val(item, depth + 1) for item in v]
        # Inline if all items are simple and short
        if all("\n" not in r for r in rendered) and sum(len(r) for r in rendered) + len(rendered) * 2 < 72:
            joined = ", ".join(rendered)
            return f"({joined},)" if is_tuple else f"[{joined}]"
        sep = "\n"
        lines = "".join(f"{inner}{r},\n" for r in rendered)
        close = ")" if is_tuple else "]"
        return f"(\n{lines}{pad}{close}" if is_tuple else f"[\n{lines}{pad}]"

    if isinstance(v, dict):
        if not v:
            return "{}"
        rendered = {k: _py_val(val, depth + 1) for k, val in v.items()}
        # Inline if all values are simple and short
        if all("\n" not in r for r in rendered.values()) and sum(len(k) + len(r) for k, r in rendered.items()) < 60:
            pairs = ", ".join(f"{_q(k)}: {r}" for k, r in rendered.items())
            return "{" + pairs + "}"
        lines = "".join(f"{inner}{_q(k)}: {r},\n" for k, r in rendered.items())
        return "{\n" + lines + pad + "}"

    return repr(v)


def _ts_val(v: Any, depth: int = 0) -> str:
    """Recursively format a Python value as TypeScript source code."""
    pad = "  " * depth
    inner = "  " * (depth + 1)

    if v is None:
        return "undefined"
    if isinstance(v, bool):
        return "true" if v else "false"
    if isinstance(v, (int, float)):
        return str(v)
    if isinstance(v, str):
        return _q(v)

    if isinstance(v, list):
        if not v:
            return "[]"
        rendered = [_ts_val(item, depth + 1) for item in v]
        if all("\n" not in r for r in rendered) and sum(len(r) for r in rendered) + len(rendered) * 2 < 72:
            return "[" + ", ".join(rendered) + "]"
        lines = "".join(f"{inner}{r},\n" for r in rendered)
        return f"[\n{lines}{pad}]"

    if isinstance(v, dict):
        if not v:
            return "{}"
        rendered = {k: _ts_val(val, depth + 1) for k, val in v.items()}
        if all("\n" not in r for r in rendered.values()) and sum(len(k) + len(r) for k, r in rendered.items()) < 60:
            pairs = ", ".join(f"{k}: {r}" for k, r in rendered.items())
            return "{ " + pairs + " }"
        lines = "".join(f"{inner}{k}: {r},\n" for k, r in rendered.items())
        return "{\n" + lines + pad + "}"

    return repr(v)


def _ts_input_schema(schema: dict, depth: int) -> str:
    """Convert a JSON Schema dict to a ConnectorInputSchema TS literal.

    ConnectorInputSchema only has required?: string[] and
    properties?: Record<string, { type: string | string[] }>.
    """
    pad = "  " * depth
    inner = "  " * (depth + 1)
    inner2 = "  " * (depth + 2)

    parts: list[str] = []

    required = schema.get("required", [])
    if required:
        items = ", ".join(_q(r) for r in required)
        parts.append(f"{inner}required: [{items}],")

    props = schema.get("properties", {})
    if props:
        prop_lines = [f"{inner}properties: {{"]
        for name, defn in props.items():
            raw_type = defn.get("type", "unknown") if isinstance(defn, dict) else "unknown"
            # Normalise integer → number for TS
            if raw_type == "integer":
                raw_type = "number"
            if isinstance(raw_type, list):
                ts_type = "[" + ", ".join(_q(t) for t in raw_type) + "]"
                prop_lines.append(f"{inner2}{name}: {{ type: {ts_type} }},")
            else:
                minimum = (
                    f", minLength: {defn['minLength']}"
                    if isinstance(defn, dict) and isinstance(defn.get("minLength"), int)
                    else ""
                )
                prop_lines.append(f"{inner2}{name}: {{ type: {_q(raw_type)}{minimum} }},")
        prop_lines.append(f"{inner}}},")
        parts.extend(prop_lines)

    if not parts:
        return "{}"
    return "{\n" + "\n".join(parts) + f"\n{pad}}}"


def _slug_to_camel(slug: str) -> str:
    """hubspot.create_contact → hubspotCreateContact"""
    parts = slug.replace(".", "_").split("_")
    return parts[0] + "".join(p.capitalize() for p in parts[1:])


# ---------------------------------------------------------------------------
# Python (Core) generator
# ---------------------------------------------------------------------------

def gen_python(data: dict) -> str:
    key = data["key"]
    actions = data.get("actions", [])
    triggers = data.get("triggers", [])
    changelog = data.get("changelog", [])

    L: list[str] = []

    # Imports
    has_field_defs = bool(data.get("config_field_defs") or data.get("secret_field_defs"))
    L.append("from src.domain.connectors.catalog._base import (")
    L.append("    ConnectorActionDef,")
    L.append("    ConnectorChangelogEntry,")
    L.append("    ConnectorDef,")
    if has_field_defs:
        L.append("    ConnectorFieldDef,")
    if triggers:
        L.append("    ConnectorTriggerDef,")
    L.append(")")
    L.append("")
    L.append("")

    # ConnectorDef
    L.append("CONNECTOR = ConnectorDef(")
    L.append(f'    key="{key}",')
    L.append(f'    name="{data["name"]}",')
    L.append(f'    category="{data["category"]}",')
    L.append(f'    auth_type="{data["auth_type"]}",')
    L.append(f'    version="{data.get("version", "01.00.00")}",')

    status = data.get("status", "active")
    if status != "active":
        L.append(f'    status="{status}",')

    if data.get("client_side", False):
        L.append("    client_side=True,")

    icon = data.get("icon")
    if icon:
        L.append(f'    icon="{icon}",')

    image_url = data.get("image_url")
    if image_url:
        L.append(f'    image_url="{image_url}",')
    else:
        L.append("    image_url=None,  # set to your CDN URL")

    description = data.get("description", "")
    L.append(f'    description="{description}",')

    docs_url = data.get("docs_url")
    if docs_url:
        L.append(f'    docs_url="{docs_url}",')

    config_fields = data.get("config_fields", [])
    if config_fields:
        items = ", ".join(f'"{f}"' for f in config_fields)
        L.append(f"    config_fields=({items},),")

    secret_fields = data.get("secret_fields", [])
    if secret_fields:
        items = ", ".join(f'"{f}"' for f in secret_fields)
        L.append(f"    secret_fields=({items},),")

    def _emit_field_defs(name: str, defs: list[dict]) -> None:
        if not defs:
            return
        L.append(f"    {name}=(")
        for fdef in defs:
            L.append("        ConnectorFieldDef(")
            L.append(f'            key="{fdef["key"]}",')
            L.append(f'            label="{fdef["label"]}",')
            if fdef.get("description"):
                L.append(f'            description="{fdef["description"]}",')
            if fdef.get("placeholder"):
                L.append(f'            placeholder="{fdef["placeholder"]}",')
            if fdef.get("required", False):
                L.append("            required=True,")
            if fdef.get("input_type", "text") != "text":
                L.append(f'            input_type="{fdef["input_type"]}",')
            if fdef.get("default") is not None:
                L.append(f'            default="{fdef["default"]}",')
            L.append("        ),")
        L.append("    ),")

    _emit_field_defs("config_field_defs", data.get("config_field_defs", []))
    _emit_field_defs("secret_field_defs", data.get("secret_field_defs", []))

    # Actions
    if actions:
        L.append("    actions=(")
        for a in actions:
            L.append("        ConnectorActionDef(")
            L.append(f'            key="{a["key"]}",')
            L.append(f'            slug="{a["slug"]}",')
            L.append(f'            name="{a["name"]}",')
            L.append(f'            description="{a.get("description", "")}",')

            schema = a.get("input_schema", {})
            if schema:
                L.append(f"            input_schema={_py_val(schema, 3)},")

            out_schema = a.get("output_schema", {})
            if out_schema:
                L.append(f"            output_schema={_py_val(out_schema, 3)},")

            exec_mode = a.get("execution_mode", "sync")
            if exec_mode != "sync":
                L.append(f'            execution_mode="{exec_mode}",')

            scopes = a.get("required_scopes", [])
            if scopes:
                items = ", ".join(f'"{s}"' for s in scopes)
                L.append(f"            required_scopes=({items},),")

            # Absent key defaults to the near-universal integrations.execute; an explicit
            # empty list (e.g. cloudflare_r2's actions gate on plan/entitlement instead of
            # a capability flag) must be respected, not silently defaulted back to it.
            caps = a["required_capabilities"] if "required_capabilities" in a else ["integrations.execute"]
            if caps:
                items = ", ".join(f'"{c}"' for c in caps)
                L.append(f"            required_capabilities=({items},),")

            implements = a.get("implements") or []
            if implements:
                items = ", ".join(f'"{c}"' for c in implements)
                L.append(f"            implements=({items},),")

            L.append("        ),")
        L.append("    ),")

    # Triggers
    if triggers:
        L.append("    triggers=(")
        for t in triggers:
            L.append("        ConnectorTriggerDef(")
            L.append(f'            key="{t["key"]}",')
            L.append(f'            event_type="{t["event_type"]}",')
            L.append(f'            name="{t["name"]}",')
            if not t.get("webhook_required", True):
                L.append("            webhook_required=False,")
            if t.get("polling_supported", False):
                L.append("            polling_supported=True,")
            t_schema = t.get("schema", {})
            if t_schema:
                L.append(f"            schema={_py_val(t_schema, 3)},")
            L.append("        ),")
        L.append("    ),")

    # Changelog
    if changelog:
        L.append("    changelog=(")
        for entry in changelog:
            L.append("        ConnectorChangelogEntry(")
            L.append(f'            version="{entry["version"]}",')
            L.append(f'            date="{entry["date"]}",')
            notes = entry["notes"].replace('"', '\\"')
            L.append(f'            notes="{notes}",')
            L.append("        ),")
        L.append("    ),")

    L.append(")")
    L.append("")

    return "\n".join(L)


# ---------------------------------------------------------------------------
# TypeScript (Cloud) generator
# ---------------------------------------------------------------------------

def gen_ts_actions_block(data: dict) -> str:
    """Generate the ACTIONS export block (always fully generated)."""
    key = data["key"]
    actions = data.get("actions", [])

    L: list[str] = [
        "// ---------------------------------------------------------------------------",
        ACTIONS_MARKER,
        f"// To regenerate: python tools/connector_gen.py --connector {key}",
        "// ---------------------------------------------------------------------------",
        "",
        "export const ACTIONS: Record<string, ConnectorActionDefinition> = {",
    ]

    for a in actions:
        slug = a["slug"]
        fn = _slug_to_camel(slug)
        scopes = a.get("required_scopes", [])
        idempotent = a.get("idempotent", True)
        retry = a.get("retry_policy", {"max_attempts": 3, "strategy": "exponential"})
        timeout_ms = a.get("timeout_ms")
        schema = a.get("input_schema", {})

        L.append(f'  "{slug}": {{')
        L.append(f'    action_key: "{slug}",')
        L.append(f'    connector_key: "{key}",')
        L.append(f"    handler: {fn},")

        if schema:
            schema_str = _ts_input_schema(schema, 2)
            L.append(f"    input_schema: {schema_str},")

        if scopes:
            items = ", ".join(_q(s) for s in scopes)
            L.append(f"    required_scopes: [{items}],")

        L.append(f'    idempotent: {"true" if idempotent else "false"},')
        L.append(
            f'    retry_policy: {{ max_attempts: {retry["max_attempts"]}, strategy: "{retry["strategy"]}" }},'
        )

        if timeout_ms:
            L.append(f"    timeout_ms: {timeout_ms},")

        L.append("  },")

    L.append("};")
    L.append("")

    return "\n".join(L)


def gen_ts_stub(data: dict) -> str:
    """Generate a full TypeScript stub file for a new connector."""
    key = data["key"]
    actions = data.get("actions", [])

    L: list[str] = [
        'import type { HandlerResult } from "@p2hs-cloud/shared";',
        'import type { Env } from "../../env";',
        'import type { ConnectorActionContext, ConnectorActionDefinition } from "./types";',
        'import { resolveConnectorCredentials } from "../../services/connector-exec";',
        "",
        "",
    ]

    for a in actions:
        slug = a["slug"]
        fn = _slug_to_camel(slug)
        L += [
            "// ---------------------------------------------------------------------------",
            f"// {slug}",
            "// ---------------------------------------------------------------------------",
            "",
            f"export async function {fn}(",
            "  env: Env,",
            "  input: Record<string, unknown>,",
            "  ctx: ConnectorActionContext,",
            "): Promise<HandlerResult> {",
            f"  // TODO: implement {slug}",
            "  // Credentials: await resolveConnectorCredentials(env, ctx.connectionId)",
            f'  throw new Error("Not implemented: {slug}");',
            "}",
            "",
            "",
        ]

    L.append(gen_ts_actions_block(data))

    return "\n".join(L)


# ---------------------------------------------------------------------------
# File operations
# ---------------------------------------------------------------------------

def _sync_ts_actions(ts_path: Path, data: dict, dry_run: bool) -> None:
    """Replace the ACTIONS block in an existing TS file, leaving handlers untouched."""
    raw = ts_path.read_text(encoding="utf-8")
    # Normalise CRLF
    content = raw.replace("\r\n", "\n")

    new_block = gen_ts_actions_block(data)

    # Strategy 1: find our marker comment (file was previously generated)
    # The block starts with the separator line immediately before the marker.
    pattern = re.compile(
        r"// -{74,}\n" + re.escape(ACTIONS_MARKER) + r".*",
        re.DOTALL,
    )
    m = pattern.search(content)
    if m:
        new_content = content[: m.start()].rstrip() + "\n\n" + new_block
    else:
        # Strategy 2: find "export const ACTIONS:" and replace from that line onward
        idx = content.rfind("export const ACTIONS:")
        if idx != -1:
            line_start = content.rfind("\n", 0, idx) + 1
            new_content = content[:line_start].rstrip() + "\n\n" + new_block
        else:
            # Append at end
            new_content = content.rstrip() + "\n\n" + new_block

    verb = "Would update" if dry_run else "Updated"
    print(f"    {verb} ACTIONS block in {ts_path.name}")
    if not dry_run:
        ts_path.write_text(new_content, encoding="utf-8")


def _update_index_ts(key: str, dry_run: bool) -> None:
    """Idempotently add the export line for this connector to index.ts."""
    export_line = f'export {{ ACTIONS as {key} }} from "./{key}";'

    if CLOUD_INDEX.exists():
        content = CLOUD_INDEX.read_text(encoding="utf-8").replace("\r\n", "\n")
        if export_line in content:
            print(f"    index.ts already has {key!r} — skipping")
            return
        new_content = content.rstrip() + "\n" + export_line + "\n"
    else:
        new_content = (
            "// Connector barrel — add one line per new connector provider.\n"
            "// registry.ts auto-collects everything exported from here; no other changes needed.\n"
            + export_line + "\n"
        )

    verb = "Would update" if dry_run else "Updated"
    print(f"    {verb} index.ts  +  {export_line}")
    if not dry_run:
        CLOUD_INDEX.write_text(new_content, encoding="utf-8")


# ---------------------------------------------------------------------------
# Per-connector runner
# ---------------------------------------------------------------------------

def run_connector(name: str, only: str | None, force: bool, dry_run: bool) -> int:
    yaml_path = YAML_DIR / f"{name}.yaml"
    if not yaml_path.exists():
        print(f"  FAIL: {yaml_path} not found. Use --new {name} to create a stub.")
        return 1

    with yaml_path.open(encoding="utf-8") as f:
        data = yaml.safe_load(f)

    key = data["key"]
    ok = 0

    # ── Core (Python) ──────────────────────────────────────────────────────
    if only in (None, "core"):
        py_path = CORE_CATALOG / f"{key}.py"
        verb = "Would write" if dry_run else "Writing"
        print(f"    {verb} {py_path.relative_to(ROOT)}")
        if not dry_run:
            py_path.write_text(gen_python(data), encoding="utf-8")

    # ── Cloud (TypeScript) ─────────────────────────────────────────────────
    if only in (None, "cloud"):
        if not data.get("actions"):
            print(f"    Cloud skipped — no server-side actions (client_side only)")
        elif not CLOUD_ROOT.exists():
            print(f"    WARN: Cloud path not found — {CLOUD_ROOT}")
            print("         Skipping Cloud generation.")
        else:
            ts_path = CLOUD_ROOT / f"{key}.ts"
            if ts_path.exists() and not force:
                _sync_ts_actions(ts_path, data, dry_run)
            else:
                note = " (force overwrite)" if force and ts_path.exists() else " (new stub)"
                verb = "Would write" if dry_run else "Writing"
                print(f"    {verb} {ts_path.name}{note}")
                if not dry_run:
                    ts_path.write_text(gen_ts_stub(data), encoding="utf-8")
            _update_index_ts(key, dry_run)

    return ok


# ---------------------------------------------------------------------------
# Scaffold new YAML
# ---------------------------------------------------------------------------

def new_yaml(name: str) -> int:
    yaml_path = YAML_DIR / f"{name}.yaml"
    if yaml_path.exists():
        print(f"FAIL: {yaml_path} already exists.")
        return 1

    YAML_DIR.mkdir(parents=True, exist_ok=True)
    today = date.today().isoformat()
    content = f"""\
key: {name}
name: "{name.title()}"
category: "other"          # payments | email | crm | messaging | storage | other
auth_type: "api_key"       # api_key | oauth
version: "01.00.00"
status: "active"
client_side: false         # true if this connector also injects a frontend script
icon: "{name}"
image_url: null            # set to your CDN URL
description: "TODO: describe this connector."
docs_url: null
config_fields: []
secret_fields:
  - api_key

actions:
  - key: example_action
    slug: "{name}.example_action"
    name: "Example action"
    description: "TODO: describe this action."
    idempotent: true
    retry_policy:
      max_attempts: 3
      strategy: exponential   # exponential | linear
    # timeout_ms: 30000       # optional, defaults to worker timeout
    required_scopes: []
    required_capabilities:
      - integrations.execute
    input_schema:
      type: object
      properties:
        param1: {{type: string}}
      required: [param1]
      additionalProperties: false

triggers: []

changelog:
  - version: "01.00.00"
    date: "{today}"
    notes: "Initial release."
"""
    yaml_path.write_text(content, encoding="utf-8")
    print(f"Created: {yaml_path}")
    print(f"\nNext steps:")
    print(f"  1. Edit {yaml_path}")
    print(f"  2. python tools/connector_gen.py --connector {name}")
    return 0


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main() -> int:
    p = argparse.ArgumentParser(
        description="Generate Core (Python) and Cloud (TypeScript) files from connector YAML."
    )
    group = p.add_mutually_exclusive_group(required=True)
    group.add_argument("--connector", metavar="NAME", help="Generate one connector")
    group.add_argument("--all", action="store_true", help="Generate all YAMLs in tools/connectors/")
    group.add_argument("--new", metavar="NAME", help="Scaffold a new YAML definition")
    group.add_argument("--list", action="store_true", help="List available YAML definitions")

    p.add_argument("--only", choices=["core", "cloud"], help="Generate only one side")
    p.add_argument("--force", action="store_true", help="Overwrite existing TS files (full stub)")
    p.add_argument("--dry-run", action="store_true", help="Show what would be written without writing")

    args = p.parse_args()

    if args.list:
        yamls = sorted(YAML_DIR.glob("*.yaml"))
        if not yamls:
            print("No YAMLs found in tools/connectors/")
            return 0
        for y in yamls:
            with y.open() as f:
                d = yaml.safe_load(f)
            print(f"  {y.stem:20s}  v{d.get('version', '?')}  {d.get('name', '')}  ({len(d.get('actions', []))} actions)")
        return 0

    if args.new:
        return new_yaml(args.new.lower())

    if args.all:
        yamls = sorted(YAML_DIR.glob("*.yaml"))
        if not yamls:
            print("No YAMLs found in tools/connectors/")
            return 1
        failed = 0
        for y in yamls:
            print(f"\n[{y.stem}]")
            if run_connector(y.stem, args.only, args.force, args.dry_run) != 0:
                failed += 1
        print(f"\nDone. {len(yamls) - failed}/{len(yamls)} succeeded.")
        return 1 if failed else 0

    print(f"[{args.connector}]")
    return run_connector(args.connector, args.only, args.force, args.dry_run)


if __name__ == "__main__":
    sys.exit(main())
