<?php

declare(strict_types=1);

namespace App\Infrastructure\Http\Middleware;

use App\Domain\Ip\Cidr;
use App\Domain\Ip\InvalidCidrException;
use App\Domain\Ip\InvalidIpException;
use App\Domain\Ip\IpAddress;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * Belt-and-suspenders network gate for `/internal/*`. Caddyfile already
 * 404s out-of-policy sources before the request reaches PHP, but this
 * middleware enforces the same rule when Slim is invoked directly
 * (tests, misconfigurations, future HTTP servers).
 *
 * Returns `404` rather than `403` so external observers can't tell the
 * endpoint exists.
 *
 * SEC_REVIEW F25: defaults to loopback only (`127.0.0.1/32` + `::1/128`).
 * Trusting the entire RFC1918 universe meant a neighbour container on a
 * shared docker bridge could reach `:8081` from a 172.16/12 source and —
 * combined with the wide `trusted_proxies private_ranges` Caddy default
 * — forge `REMOTE_ADDR=127.0.0.1` via `X-Forwarded-For`. The bundled
 * sidecar scheduler (`compose.scheduler.yml`) now joins the api's
 * network namespace (`network_mode: "service:api"`) so its calls land
 * on `127.0.0.1`. Production topologies that genuinely need extra
 * source CIDRs (e.g. a host-cron VM with a private bridge) opt in via
 * the `INTERNAL_CIDR_ALLOWLIST` env var.
 */
final class InternalNetworkMiddleware implements MiddlewareInterface
{
    /** @var list<string> */
    public const DEFAULT_ALLOWED_CIDRS = [
        '127.0.0.1/32',
        '::1/128',
    ];

    /** @var list<Cidr> */
    private array $cidrs;

    /**
     * @param list<string>|null $allowedCidrs explicit allowlist (each entry is a
     *                                        CIDR string parsed by `Cidr::fromString`).
     *                                        `null` or `[]` falls back to
     *                                        {@see DEFAULT_ALLOWED_CIDRS}. Invalid
     *                                        entries throw at construction time so a
     *                                        misconfigured deploy fails to boot rather
     *                                        than silently widening the gate.
     */
    public function __construct(
        private readonly ResponseFactoryInterface $responseFactory,
        ?array $allowedCidrs = null,
    ) {
        $list = ($allowedCidrs === null || $allowedCidrs === []) ? self::DEFAULT_ALLOWED_CIDRS : $allowedCidrs;
        $this->cidrs = array_map(
            static fn (string $c): Cidr => Cidr::fromString($c),
            $list,
        );
    }

    /**
     * Parses a comma- or whitespace-separated CIDR list (typically
     * `INTERNAL_CIDR_ALLOWLIST` from env). Returns the validated list of
     * CIDR strings ready to feed into the constructor; empty input
     * returns an empty list so the constructor falls back to the
     * loopback default. Invalid CIDRs throw — fail-closed.
     *
     * @return list<string>
     * @throws InvalidCidrException
     */
    public static function parseCidrList(string $raw): array
    {
        if (trim($raw) === '') {
            return [];
        }
        $parts = preg_split('/[\s,]+/', trim($raw)) ?: [];
        $out = [];
        foreach ($parts as $p) {
            if ($p === '') {
                continue;
            }
            // Validate by constructing — discards the Cidr but raises on bad input.
            Cidr::fromString($p);
            $out[] = $p;
        }

        return $out;
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $remote = self::remoteAddr($request);
        if ($remote === null || !$this->isAllowed($remote)) {
            return $this->notFound();
        }

        return $handler->handle($request);
    }

    private function isAllowed(string $remote): bool
    {
        try {
            $ip = IpAddress::fromString($remote);
        } catch (InvalidIpException) {
            return false;
        }

        foreach ($this->cidrs as $cidr) {
            if ($cidr->contains($ip)) {
                return true;
            }
        }

        return false;
    }

    private static function remoteAddr(ServerRequestInterface $request): ?string
    {
        $params = $request->getServerParams();
        $value = $params['REMOTE_ADDR'] ?? null;
        if (!is_string($value) || $value === '') {
            return null;
        }

        return $value;
    }

    private function notFound(): ResponseInterface
    {
        $response = $this->responseFactory->createResponse(404)
            ->withHeader('Content-Type', 'application/json');
        $response->getBody()->write((string) json_encode(['error' => 'not_found']));

        return $response;
    }
}
