"""``spens init`` -- generate a ``.spens.config.json`` for the workspace.

The command asks a short questionnaire (interactively, or via CLI flags for
non-interactive use) and merges the matching partial configuration templates
from ``data/init_templates`` into a single valid spens config:

1. Stack used (node/python/dotnet/rust/go/java/manual) -- contributes
   ``domain_rules`` for the stack's package registries and tooling CDNs.
2. Allow public GET access? -- adds a wildcard GET rule.
3. Allow the stack's common tooling URLs? (omitted for ``manual``)
4. Model provider(s) (fireworks/anthropic/openrouter/openai/manual) --
   contributes ``domain_rules``, ``addition_capture_urls`` and
   ``exclude_capture_urls`` for the provider's API endpoints.
5. Configure the provider's common env vars? (omitted for ``manual``) --
   turns inject vars into ``inject_headers`` rules and forward vars into the
   ``env`` list.

After writing the config it also makes sure ``.spens`` is ignored by git.
"""

from __future__ import annotations

import argparse
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from spens.config import CONFIG_FILENAME

# Questionnaire options (``manual`` means "nothing is configured").
STACKS = ("node", "python", "dotnet", "rust", "go", "java", "manual")
PROVIDERS = ("fireworks", "anthropic", "openrouter", "openai", "manual")

_TEMPLATE_DIR = Path(__file__).resolve().parent / "data" / "init_templates"
STACK_DIR = _TEMPLATE_DIR / "stacks"
PROVIDER_DIR = _TEMPLATE_DIR / "providers"

# Entry that init guarantees is present in the workspace .gitignore.
GITIGNORE_ENTRY = ".spens"

# Defaults used by ``--yes`` (and for unspecified questionnaire answers).
DEFAULT_PUBLIC_GET = False
DEFAULT_TOOLING_URLS = True
DEFAULT_INJECT_ENV = True


class InitError(Exception):
    """Fatal ``spens init`` error (bad flags, unknown template, aborted)."""


# ---------------------------------------------------------------------------
# Answers
# ---------------------------------------------------------------------------


@dataclass
class InitAnswers:
    """Resolved answers to the init questionnaire."""

    stack: str = "manual"
    providers: list[str] = field(default_factory=lambda: ["manual"])
    public_get: bool = DEFAULT_PUBLIC_GET
    tooling_urls: bool = DEFAULT_TOOLING_URLS
    inject_env: bool = DEFAULT_INJECT_ENV


# ---------------------------------------------------------------------------
# Templates
# ---------------------------------------------------------------------------


def _load_template(directory: Path, name: str, kind: str) -> dict[str, Any]:
    """Load the ``name`` template from ``directory`` (``manual`` is empty)."""
    if name == "manual":
        return {}
    path = directory / f"{name}.json"
    if not path.is_file():
        known = ", ".join(o for o in (STACKS if kind == "stack" else PROVIDERS))
        raise InitError(f"Unknown {kind} '{name}' (expected one of: {known})")
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def load_stack_template(name: str) -> dict[str, Any]:
    """Load the stack template for ``name`` (empty dict for ``manual``)."""
    return _load_template(STACK_DIR, name, "stack")


def load_provider_template(name: str) -> dict[str, Any]:
    """Load the provider template for ``name`` (empty dict for ``manual``)."""
    return _load_template(PROVIDER_DIR, name, "provider")


# ---------------------------------------------------------------------------
# Config generation
# ---------------------------------------------------------------------------


def _merge_unique(target: list[Any], source: list[Any]) -> None:
    """Append items from ``source`` to ``target`` preserving order, no dups."""
    for item in source:
        if item not in target:
            target.append(item)


def _merge_domain_rule(rules: list[dict[str, Any]], new: dict[str, Any]) -> None:
    """Merge ``new`` into ``rules``; rules for the same pattern union their
    allowed methods (order-preserving)."""
    pattern = new.get("pattern", "")
    for rule in rules:
        if rule.get("pattern") == pattern:
            _merge_unique(rule["allow"], new.get("allow", []))
            return
    rules.append({"pattern": pattern, "allow": list(new.get("allow", []))})


def _merge_inject_rule(
    rules: list[dict[str, Any]], new: dict[str, Any]
) -> None:
    """Append ``new`` unless an identical rule is already present."""
    key = (new.get("placeholder"), new.get("env_var"), tuple(new.get("for_domains", [])))
    for rule in rules:
        existing = (
            rule.get("placeholder"), rule.get("env_var"), tuple(rule.get("for_domains", []))
        )
        if existing == key:
            return
    rules.append({
        "placeholder": new.get("placeholder"),
        "env_var": new.get("env_var"),
        "for_domains": list(new.get("for_domains", [])),
    })


def generate_config(answers: InitAnswers) -> dict[str, Any]:
    """Build the spens config matching ``answers`` (pure, no I/O)."""
    domain_rules: list[dict[str, Any]] = []
    addition_capture_urls: list[str] = []
    exclude_capture_urls: list[str] = []
    inject_headers: list[dict[str, Any]] = []
    env: list[str] = []

    # Question 2: public GET access gets a wildcard rule.
    if answers.public_get:
        domain_rules.append({"pattern": "*", "allow": ["GET"]})

    # Question 1 + 3: stack tooling domain rules.
    if answers.stack != "manual" and answers.tooling_urls:
        stack_tmpl = load_stack_template(answers.stack)
        for rule in stack_tmpl.get("domain_rules", []):
            _merge_domain_rule(domain_rules, rule)

    # Question 4: provider domain rules and capture URLs (multiple merge).
    for provider in answers.providers:
        if provider == "manual":
            continue
        tmpl = load_provider_template(provider)
        for rule in tmpl.get("domain_rules", []):
            _merge_domain_rule(domain_rules, rule)
        _merge_unique(addition_capture_urls, tmpl.get("addition_capture_urls", []))
        _merge_unique(exclude_capture_urls, tmpl.get("exclude_capture_urls", []))
        # Question 5: inject vars become inject_headers, forward vars become env.
        if answers.inject_env:
            for rule in tmpl.get("inject_headers", []):
                _merge_inject_rule(inject_headers, rule)
            _merge_unique(env, tmpl.get("forward_env", []))

    config: dict[str, Any] = {
        "addition_capture_urls": addition_capture_urls,
        "exclude_capture_urls": exclude_capture_urls,
    }
    if env:
        config["env"] = env
    config["domain_rules"] = domain_rules
    if inject_headers:
        config["inject_headers"] = inject_headers
    return config


# ---------------------------------------------------------------------------
# .gitignore
# ---------------------------------------------------------------------------


def update_gitignore(directory: str | Path) -> bool:
    """Ensure ``.spens`` is ignored in ``directory/.gitignore``.

    Returns True if the file was created or modified, False if the entry was
    already present.
    """
    gitignore = Path(directory) / ".gitignore"
    if not gitignore.exists():
        gitignore.write_text(GITIGNORE_ENTRY + "\n", encoding="utf-8")
        return True
    content = gitignore.read_text(encoding="utf-8")
    if any(line.strip() == GITIGNORE_ENTRY for line in content.splitlines()):
        return False
    if content and not content.endswith("\n"):
        content += "\n"
    gitignore.write_text(content + GITIGNORE_ENTRY + "\n", encoding="utf-8")
    return True


# ---------------------------------------------------------------------------
# Interactive prompts
# ---------------------------------------------------------------------------


class StdinPrompter:
    """Questionnaire prompts on stdin (overridable in tests)."""

    @staticmethod
    def _read(prompt: str) -> str:
        try:
            return input(prompt)
        except EOFError:
            raise InitError(
                "could not read an answer from stdin (no interactive terminal?) -- "
                "answer the questionnaire via CLI flags or use --yes"
            ) from None

    @staticmethod
    def choice(question: str, options: tuple[str, ...] | list[str]) -> str:
        hint = "/".join(options)
        while True:
            raw = StdinPrompter._read(f"{question} [{hint}]: ").strip().lower()
            if raw in options:
                return raw
            print(f"Please answer one of: {hint}")

    @staticmethod
    def multiple_choice(
        question: str, options: tuple[str, ...] | list[str]
    ) -> list[str]:
        hint = "/".join(options)
        while True:
            raw = StdinPrompter._read(
                f"{question} [{hint}] (comma-separated for multiple): "
            )
            parts = [p.strip().lower() for p in raw.split(",") if p.strip()]
            if parts and all(p in options for p in parts):
                seen: list[str] = []
                for p in parts:
                    if p not in seen:
                        seen.append(p)
                return seen
            print(f"Please answer with one or more of (comma-separated): {hint}")

    @staticmethod
    def boolean(question: str, default: bool = False) -> bool:
        hint = "Y/n" if default else "y/N"
        while True:
            raw = StdinPrompter._read(f"{question} [{hint}]: ").strip().lower()
            if not raw:
                return default
            if raw in ("y", "yes"):
                return True
            if raw in ("n", "no"):
                return False
            print("Please answer y or n.")


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


def parse_providers(raw: str) -> list[str]:
    """Parse and validate a comma-separated provider list."""
    parts = [p.strip().lower() for p in raw.split(",") if p.strip()]
    if not parts:
        raise InitError("--provider requires at least one provider")
    invalid = [p for p in parts if p not in PROVIDERS]
    if invalid:
        raise InitError(
            f"Unknown provider(s): {', '.join(invalid)} "
            f"(expected one of: {', '.join(PROVIDERS)})"
        )
    if "manual" in parts and len(parts) > 1:
        raise InitError("'manual' cannot be combined with other providers")
    seen: list[str] = []
    for p in parts:
        if p not in seen:
            seen.append(p)
    return seen


def _resolve_answers(
    args: argparse.Namespace, prompter: StdinPrompter
) -> InitAnswers:
    """Fill in missing questionnaire answers from prompts (or --yes defaults)."""
    if args.yes:
        if args.stack is None:
            raise InitError("--yes requires --stack to be specified")
        if args.provider is None:
            raise InitError("--yes requires --provider to be specified")
        return InitAnswers(
            stack=args.stack,
            providers=parse_providers(args.provider),
            public_get=DEFAULT_PUBLIC_GET if args.public_get is None else args.public_get,
            tooling_urls=DEFAULT_TOOLING_URLS if args.tooling_urls is None else args.tooling_urls,
            inject_env=DEFAULT_INJECT_ENV if args.inject_env is None else args.inject_env,
        )

    stack = args.stack
    if stack is None:
        stack = prompter.choice("What type of stack is used?", STACKS)

    providers = parse_providers(args.provider) if args.provider else None
    if providers is None:
        providers = prompter.multiple_choice(
            "What model provider are you using?", PROVIDERS
        )

    public_get = args.public_get
    if public_get is None:
        public_get = prompter.boolean(
            "Do you want to allow public GET access from the internet?",
            default=DEFAULT_PUBLIC_GET,
        )

    tooling_urls = args.tooling_urls
    if tooling_urls is None and stack != "manual":
        tooling_urls = prompter.boolean(
            "Do you want to allow access to common URLs for tooling?",
            default=DEFAULT_TOOLING_URLS,
        )
    elif tooling_urls is None:
        tooling_urls = DEFAULT_TOOLING_URLS  # unused for manual stacks

    inject_env = args.inject_env
    providers_manual = all(p == "manual" for p in providers)
    if inject_env is None and not providers_manual:
        inject_env = prompter.boolean(
            "Do you want to configure common env variables for the provider?",
            default=DEFAULT_INJECT_ENV,
        )
    elif inject_env is None:
        inject_env = DEFAULT_INJECT_ENV  # unused for manual providers

    return InitAnswers(
        stack=stack,
        providers=providers,
        public_get=public_get,
        tooling_urls=tooling_urls,
        inject_env=inject_env,
    )


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="spens init",
        description=(
            "Generate a .spens.config.json from a short questionnaire "
            "and update .gitignore to ignore .spens."
        ),
    )
    parser.add_argument(
        "--yes", "-y", action="store_true",
        help="Skip all prompts; use defaults for unspecified answers. "
        "Requires --stack and --provider to be specified.",
    )
    parser.add_argument(
        "--stack", choices=STACKS, default=None,
        help="Stack used: node | python | dotnet | rust | go | java | manual",
    )
    parser.add_argument(
        "--provider", default=None,
        help="Model provider(s): fireworks | anthropic | openrouter | openai | manual "
        "(comma-separated for multiple: openai,fireworks)",
    )
    parser.add_argument(
        "--public-get", dest="public_get", action="store_true", default=None,
        help="Allow public GET access (default: deny)",
    )
    parser.add_argument(
        "--no-public-get", dest="public_get", action="store_false",
        help="Deny public GET access",
    )
    parser.add_argument(
        "--tooling-urls", dest="tooling_urls", action="store_true", default=None,
        help="Allow stack tooling URLs (default: allow)",
    )
    parser.add_argument(
        "--no-tooling-urls", dest="tooling_urls", action="store_false",
        help="Deny stack tooling URLs",
    )
    parser.add_argument(
        "--inject-env", dest="inject_env", action="store_true", default=None,
        help="Configure provider env vars: inject secrets as headers and "
        "forward non-secret vars (default: allow)",
    )
    parser.add_argument(
        "--no-inject-env", dest="inject_env", action="store_false",
        help="Do not configure provider env vars",
    )
    parser.add_argument(
        "--output", default=CONFIG_FILENAME,
        help=f"Config file path (default: {CONFIG_FILENAME})",
    )
    parser.add_argument(
        "--force", action="store_true",
        help="Overwrite an existing config without prompting",
    )
    parser.add_argument(
        "--dry-run", action="store_true",
        help="Print the generated config to stdout, don't write any file",
    )
    return parser


def run_init(
    argv: list[str] | None = None,
    *,
    prompter: StdinPrompter | None = None,
    cwd: str | Path | None = None,
) -> int:
    """Entry point for ``spens init``; returns a process exit code."""
    args = build_parser().parse_args(argv)
    prompter = prompter or StdinPrompter()
    workdir = Path(cwd) if cwd is not None else Path.cwd()

    try:
        answers = _resolve_answers(args, prompter)
        config = generate_config(answers)
    except InitError as exc:
        print(f"[spens] Error: {exc}")
        return 1

    output = Path(args.output)
    if not output.is_absolute():
        output = workdir / output
    text = json.dumps(config, indent=2) + "\n"

    if args.dry_run:
        print(text, end="")
        return 0

    if output.exists() and not args.force:
        if args.yes:
            print(
                f"[spens] Error: {output} already exists; use --force to overwrite it."
            )
            return 1
        try:
            raw = input(
                f"[spens] Warning: {output} already exists; running init will "
                "overwrite it. Proceed? [y/N]: "
            )
        except EOFError:
            raw = ""
        if raw.strip().lower() not in ("y", "yes"):
            print("[spens] Aborted; existing config was left untouched.")
            return 1

    if output.parent and not output.parent.exists():
        output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(text, encoding="utf-8")
    print(f"[spens] Wrote {output}")

    if update_gitignore(output.parent):
        print(f"[spens] Added {GITIGNORE_ENTRY} to {output.parent / '.gitignore'}")
    else:
        print(f"[spens] {output.parent / '.gitignore'} already ignores {GITIGNORE_ENTRY}")
    return 0


if __name__ == "__main__":  # pragma: no cover
    sys.exit(run_init())
