// Per-install nginx rewrite snippets for CMSes that need subdir-aware
// pretty-URL routing (Drupal, Joomla, others). Snippets live at
// /etc/nginx/jabali/<domain>/<app_type>-<subdir>.conf and are included
// from the domain vhost. Docroot installs don't need a snippet — the
// generic vhost's `try_files $uri $uri/ /index.php?$query_string`
// already handles them.

package commands

import (
	"bytes"
	"context"
	"fmt"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"text/template"
)

// DomainFromSiteURL pulls the bare domain out of a kick-payload
// `site_url` (e.g. "https://123123.com/d" → "123123.com"). Strips a
// `www.` prefix if present — matches the kick side's UseWWW toggle.
func DomainFromSiteURL(siteURL string) (string, error) {
	u, err := url.Parse(siteURL)
	if err != nil {
		return "", fmt.Errorf("parse site_url %q: %w", siteURL, err)
	}
	host := u.Hostname()
	if host == "" {
		return "", fmt.Errorf("site_url %q has no host", siteURL)
	}
	return strings.TrimPrefix(host, "www."), nil
}

const nginxJabaliDir = "/etc/nginx/jabali"

// subdirSlugRE matches a Drupal/Joomla-friendly subdir slug (the portion
// between leading/trailing slashes). Keep in sync with what the panel's
// install flow accepts.
var subdirSlugRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`)

// appRewriteSnippetTemplate renders one per-install include. The nested
// `location ~ \.php$` is what makes this work: the outer `^~` prefix
// match stops regex evaluation at the outer level, so WITHOUT a nested
// PHP handler the subdir's index.php would be served as raw source.
const appRewriteSnippetTemplate = `# Auto-generated by jabali-agent. Do not edit.
# App: {{.AppType}} at {{.SubdirPath}}  Domain: {{.Domain}}
location ^~ {{.SubdirPath}} {
    location ~ \.php$ {
        fastcgi_pass unix:/run/php/jabali-{{.OSUser}}/fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
    try_files $uri $uri/ {{.SubdirPath}}index.php?$query_string;
}
`

type appRewriteData struct {
	AppType    string
	Domain     string
	OSUser     string
	SubdirPath string // always leading + trailing slash, e.g. "/d/"
}

// snippetPath returns the absolute path where an install's include
// lives. Callers validate domain + subdir first.
func snippetPath(domain, appType, subdir string) string {
	return filepath.Join(nginxJabaliDir, domain, appType+"-"+subdir+".conf")
}

// writeAppRewrite writes the per-install nginx include and reloads
// nginx. Safe to call with subdir="" — it's a no-op (docroot installs
// don't need a snippet).
//
// Returns nil even when nginx -t passes but nginx reload fails quietly;
// the per-request failure domain is bounded because the include is
// additive — the domain's main vhost keeps working either way.
func writeAppRewrite(ctx context.Context, appType, domain, osUser, subdir string) error {
	if subdir == "" {
		return nil
	}
	if !domainRegex.MatchString(domain) {
		return fmt.Errorf("invalid domain %q", domain)
	}
	if !subdirSlugRE.MatchString(subdir) {
		return fmt.Errorf("invalid subdir slug %q", subdir)
	}
	if !appTypeSafeRE.MatchString(appType) {
		return fmt.Errorf("invalid app_type %q", appType)
	}
	if !osUserSafeRE.MatchString(osUser) {
		return fmt.Errorf("invalid os_user %q", osUser)
	}

	// JAB-71: serialize write→test→reload against every other nginx op.
	nginxOpMu.Lock()
	defer nginxOpMu.Unlock()

	domainDir := filepath.Join(nginxJabaliDir, domain)
	if err := os.MkdirAll(domainDir, 0o755); err != nil {
		return fmt.Errorf("mkdir %s: %w", domainDir, err)
	}

	tmpl, err := template.New("rewrite").Parse(appRewriteSnippetTemplate)
	if err != nil {
		return fmt.Errorf("parse template: %w", err)
	}
	var buf bytes.Buffer
	if err := tmpl.Execute(&buf, appRewriteData{
		AppType:    appType,
		Domain:     domain,
		OSUser:     osUser,
		SubdirPath: "/" + subdir + "/",
	}); err != nil {
		return fmt.Errorf("render template: %w", err)
	}

	dest := snippetPath(domain, appType, subdir)
	if err := os.WriteFile(dest, buf.Bytes(), 0o644); err != nil {
		return fmt.Errorf("write %s: %w", dest, err)
	}

	return reloadNginxAfterSnippet(ctx, dest)
}

// removeAppRewrite deletes the per-install include and reloads nginx.
// Missing file is fine — idempotent.
func removeAppRewrite(ctx context.Context, appType, domain, subdir string) error {
	if subdir == "" {
		return nil
	}
	if !domainRegex.MatchString(domain) {
		return fmt.Errorf("invalid domain %q", domain)
	}
	if !subdirSlugRE.MatchString(subdir) {
		return fmt.Errorf("invalid subdir slug %q", subdir)
	}
	if !appTypeSafeRE.MatchString(appType) {
		return fmt.Errorf("invalid app_type %q", appType)
	}

	// JAB-71: serialize remove→test→reload against every other nginx op.
	nginxOpMu.Lock()
	defer nginxOpMu.Unlock()

	dest := snippetPath(domain, appType, subdir)
	if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("remove %s: %w", dest, err)
	}
	return reloadNginxAfterSnippet(ctx, dest)
}

// reloadNginxAfterSnippet runs `nginx -t`; on failure, removes the
// offending include so the domain vhost doesn't enter a broken state,
// then surfaces the nginx error. On success, reloads nginx.
func reloadNginxAfterSnippet(ctx context.Context, snippet string) error {
	test := exec.CommandContext(ctx, "nginx", "-t")
	if out, err := test.CombinedOutput(); err != nil {
		// Pull the snippet we just wrote so nginx doesn't stay broken
		// across the next manual reload.
		_ = os.Remove(snippet)
		logNginxTestFailure("nginx.app_rewrite (snippet removed)", string(out))
		return fmt.Errorf("nginx configuration test failed for app rewrite (snippet removed)")
	}
	reload := exec.CommandContext(ctx, "systemctl", "reload", "nginx.service")
	if out, err := reload.CombinedOutput(); err != nil {
		return fmt.Errorf("systemctl reload nginx: %w: %s", err, out)
	}
	return nil
}

// appTypeSafeRE / osUserSafeRE defend the file-path construction against
// a malicious or malformed app-dispatch payload. Both are belt-and-braces
// — callers are trusted panel-api code paths that already validated —
// but filename construction deserves the extra check.
var appTypeSafeRE = regexp.MustCompile(`^[a-z][a-z0-9]{0,31}$`)
var osUserSafeRE = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)

const xmlrpcBlockContent = `# Auto-generated by jabali-agent. Do not edit.
# Blocks XML-RPC for WordPress. xmlrpc.php returns HTTP 200 for valid
# XML payloads, so CrowdSec's brute-force scenarios never fire on it.
# A hard nginx deny is the only reliable defence.
location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
}
`

// writeWordPressXmlrpcBlock writes the nginx deny snippet for xmlrpc.php
// and reloads nginx. Best-effort: callers must not roll back the WP install
// on failure here.
func writeWordPressXmlrpcBlock(ctx context.Context, domain string) error {
	if !domainRegex.MatchString(domain) {
		return fmt.Errorf("invalid domain %q", domain)
	}
	domainDir := filepath.Join(nginxJabaliDir, domain)
	if err := os.MkdirAll(domainDir, 0o755); err != nil {
		return fmt.Errorf("mkdir %s: %w", domainDir, err)
	}
	dest := filepath.Join(domainDir, "wordpress-xmlrpc.conf")
	if err := os.WriteFile(dest, []byte(xmlrpcBlockContent), 0o644); err != nil {
		return fmt.Errorf("write %s: %w", dest, err)
	}
	return reloadNginxAfterSnippet(ctx, dest)
}

// removeWordPressXmlrpcBlock removes the xmlrpc.php deny snippet and
// reloads nginx. Missing file is fine — idempotent.
func removeWordPressXmlrpcBlock(ctx context.Context, domain string) error {
	if domain == "" {
		return nil
	}
	if !domainRegex.MatchString(domain) {
		return fmt.Errorf("invalid domain %q", domain)
	}
	dest := filepath.Join(nginxJabaliDir, domain, "wordpress-xmlrpc.conf")
	if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("remove %s: %w", dest, err)
	}
	return reloadNginxAfterSnippet(ctx, dest)
}
