<?php
namespace Site\Lib;

/**
 * php_curlへの依存を排除するための独自のCURL実装
 */
class Curl {
  // リクエスト関連のプロパティ
  private string   $url = '';
  private string   $method = 'GET';
  private int      $timeout = 30;
  private array    $headers = [];
  private array    $cookies = [];
  private array    $postFields = [];
  private string   $postRaw = '';
  private string   $userAgent = 'LittleBeast/1.0';
  private bool     $followRedirects = true;
  private int      $maxRedirects = 5;
  private bool     $verbose = false;
  private $stderr = null;
  private string   $caInfoPath = '';
  private bool     $verifySSL = true;
  private string   $username = '';
  private string   $password = '';
  private string   $referer = '';

  // レスポンス関連のプロパティ
  private array  $responseHeaders = [];
  private string $responseBody = '';
  private int    $responseCode = 0;
  private string $responseError = '';
  private array  $info = [];

  /**
   * コンストラクタ
   *
   * @param string|null $url  リクエスト先のURL
   */
  public function __construct(?string $url = null) {
    if ($url !== null) {
      $this->url = $url;
    }
  }

  /**
   * リクエスト先のURLを設定する
   *
   * @param string $url  リクエスト先のURL
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setUrl(string $url): Curl {
    $this->url = $url;
    return $this;
  }

  /**
   * リクエストメソッドを設定する
   *
   * @param string $method  GE又はPOST等のHTTPメソッド
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setMethod(string $method): Curl {
    $this->method = strtoupper($method);
    return $this;
  }

  /**
   * リクエストのタイムアウト秒数を設定する
   *
   * @param int $seconds  タイムアウト秒数
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setTimeout(int $seconds): Curl {
    $this->timeout = (int)$seconds;
    return $this;
  }

  /**
   * リクエストヘッダーを設定する
   *
   * @param array $headers  リクエストヘッダーの配列
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setHeaders(array $headers): Curl {
    $this->headers = $headers;
    return $this;
  }

  /**
   * 単一のヘッダーを追加する
   *
   * @param string $name  ヘッダー名
   * @param mixed $value  ヘッダー値
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function addHeader(string $name, mixed $value): Curl {
    $this->headers[$name] = $value;
    return $this;
  }

  /**
   * リクエストのクッキーを設定する
   *
   * @param array $cookies  クッキーの配列
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setCookies(array $cookies): Curl {
    $this->cookies = $cookies;
    return $this;
  }

  /**
   * 単一のクッキーを追加する
   * 
   * @param string $name  クッキー名
   * @param mixed $value  クッキー値
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function addCookie(string $name, mixed $value): Curl {
    $this->cookies[$name] = $value;
    return $this;
  }

  /**
   * POSTフィールドを設定する
   *
   * @param array $fields  POSTデータの配列
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setPostFields(array $fields): Curl {
    $this->postFields = $fields;
    return $this;
  }

  /**
   * 生のPOSTデータを設定する
   *
   * @param string $data  生のPOSTデータ
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setPostRaw(string $data): Curl {
    $this->postRaw = $data;
    return $this;
  }

  /**
   * ユーザーエージェントを設定する
   *
   * @param string $userAgent  カスタムユーザーエージェント
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setUserAgent(string $userAgent): Curl {
    $this->userAgent = $userAgent;
    return $this;
  }

  /**
   * リダイレクトを追跡するかどうかを設定する
   *
   * @param bool $follow  追跡するかどうか(デフォルトはtrue)
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setFollowRedirects(bool $follow): Curl {
    $this->followRedirects = (bool)$follow;
    return $this;
  }

  /**
   * 追跡するリダイレクトの最大数を設定する
   *
   * @param int $max  リダイレクトの最大数
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setMaxRedirects(int $max): Curl {
    $this->maxRedirects = (int)$max;
    return $this;
  }

  /**
   * SSL証明書を検証するかどうかを設定する
   *
   * @param bool $verify  SSL検証を行うかどうか(デフォルトはtrue)
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setVerifySSL(bool $verify): Curl {
    $this->verifySSL = (bool)$verify;
    return $this;
  }

  /**
   * 基本認証の資格情報を設定する
   *
   * @param string $username  ユーザー名
   * @param string $password  パスワード
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setBasicAuth(string $username, string $password): Curl {
    $this->username = $username;
    $this->password = $password;
    return $this;
  }

  /**
   * リファラーURLを設定する
   *
   * @param string $referer  リファラーURL
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setReferer(string $referer): Curl {
    $this->referer = $referer;
    return $this;
  }

  /**
   * 詳細ログを有効にする
   *
   * @param bool $verbose  詳細ログを有効にするかどうか
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setVerbose(bool $verbose): Curl {
    $this->verbose = (bool)$verbose;
    return $this;
  }

  /**
   * エラー出力先を設定する
   *
   * @param resource $handle  エラー出力先のファイルハンドル
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setStderr($handle): Curl {
    $this->stderr = $handle;
    return $this;
  }

  /**
   * SSL証明書のファイルパスを設定する
   *
   * @param string $path  証明書ファイルのパス
   * @return Curl  このインスタンス(メソッドチェーン用)
   */
  public function setCaInfo(string $path): Curl {
    $this->caInfoPath = $path;
    return $this;
  }

  /**
   * リクエストを実行する
   *
   * @return bool  成功または失敗
   */
  public function execute(): bool {
    if (empty($this->url)) {
      $this->responseError = 'URLがありません';
      return false;
    }

    // レスポンスデータのリセット
    $this->responseHeaders = [];
    $this->responseBody = '';
    $this->responseCode = 0;
    $this->responseError = '';
    $this->info = [
      'url' => $this->url,
      'content_type' => '',
      'http_code' => 0,
      'header_size' => 0,
      'request_size' => 0,
      'total_time' => 0,
      'redirect_count' => 0,
      'redirect_url' => '',
    ];

    $startTime = microtime(true);

    // ソケットベースの実装を使用する
    $redirectCount = 0;
    $currentUrl = $this->url;
    $originalMethod = $this->method;

    do {
      if ($this->verbose && $this->stderr) {
        fwrite($this->stderr, "* 接続中: {$currentUrl}\n");
      }

      $parsed = parse_url($currentUrl);
      if (!$parsed) {
        $this->responseError = "無効なURL: {$currentUrl}";
        return false;
      }

      $scheme = isset($parsed['scheme']) ? strtolower($parsed['scheme']) : 'http';
      $host = $parsed['host'];
      $port = isset($parsed['port']) 
        ? $parsed['port'] : ($scheme === 'https' ? 443 : 80);
      $path = isset($parsed['path']) ? $parsed['path'] : '/';
      if (isset($parsed['query'])) {
        $path .= '?'.$parsed['query'];
      }

      // Basic認証
      $authHeader = '';
      if (!empty($this->username) && !empty($this->password)) {
        $authHeader = "Authorization: Basic "
          .base64_encode($this->username.':'.$this->password)."\r\n";
      } elseif (isset($parsed['user']) && isset($parsed['pass'])) {
        $authHeader = "Authorization: Basic "
          .base64_encode($parsed['user'].':'.$parsed['pass'])."\r\n";
      }

      // 送信するHTTPリクエストを構築
      $method = $this->method;
      $httpData = '';

      if ($method === 'POST' || $method === 'PUT') {
        if (!empty($this->postRaw)) {
          $httpData = $this->postRaw;
        } elseif (!empty($this->postFields)) {
          $httpData = http_build_query($this->postFields);
          if (!isset($this->headers['Content-Type'])) {
            $this->headers['Content-Type'] = 'application/x-www-form-urlencoded';
          }
        }
        $this->headers['Content-Length'] = strlen($httpData);
      }

      // HTTPリクエストを構築
      $accept = 'Accept: */*';
      foreach ($this->headers as $h) {
        if (str_contains($h, 'Accept:')) $accept = $h;
      }

      $request = "{$method} {$path} HTTP/1.1\r\n";
      $request .= "Host: {$host}\r\n";
      $request .= "User-Agent: {$this->userAgent}\r\n";
      $request .= "{$accept}\r\n";
      $request .= "Connection: close\r\n";

      if (!empty($authHeader)) {
        $request .= $authHeader;
      }

      // ヘッダーを追加
      foreach ($this->headers as $name => $value) {
        $request .= "{$name}: {$value}\r\n";
      }

      // リファラーが設定されていれば追加
      if (!empty($this->referer) && !isset($this->headers['Referer'])) {
        $request .= "Referer: {$this->referer}\r\n";
      }

      // クッキーヘッダーを追加
      if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
        $cookieStrings = [];
        foreach ($this->cookies as $name => $value) {
          $cookieStrings[] = $name . '=' . urlencode($value);
        }
        $request .= 'Cookie: '.implode('; ', $cookieStrings)."\r\n";
      }

      $request .= "\r\n";

      // POSTデータを追加
      if ($method === 'POST' || $method === 'PUT') {
        $request .= $httpData;
      }

      if ($this->verbose && $this->stderr) {
        fwrite($this->stderr, "* リクエストヘッダー:\n{$request}\n");
      }

      // ソケット接続を確立
      $errno = 0;
      $errstr = '';
      
      if ($scheme === 'https') {
        $sslOptions = [
          'verify_peer' => $this->verifySSL,
          'verify_peer_name' => $this->verifySSL
        ];
        
        if (!empty($this->caInfoPath) && file_exists($this->caInfoPath)) {
          $sslOptions['cafile'] = $this->caInfoPath;
        }
        
        $context = stream_context_create(['ssl' => $sslOptions]);
        
        $socket = @stream_socket_client(
          "tls://{$host}:{$port}",
          $errno,
          $errstr,
          $this->timeout,
          STREAM_CLIENT_CONNECT,
          $context
        );
      } else {
        $socket = @fsockopen($host, $port, $errno, $errstr, $this->timeout);
      }

      if (!$socket) {
        $this->responseError = "接続出来ません: {$errstr} ({$errno})";
        if ($this->verbose && $this->stderr) {
          fwrite($this->stderr, "* エラー: {$this->responseError}\n");
        }
        return false;
      }

      // タイムアウトを設定
      stream_set_timeout($socket, $this->timeout);
      
      // リクエストを送信
      fwrite($socket, $request);
      
      // レスポンスを読み込む
      $rawResponse = '';
      $headers = '';
      $body = '';
      $headersComplete = false;

      // ヘッダーとボディを分けて読み込む
      while (!feof($socket)) {
        $line = fgets($socket);
        if ($line === false) {
          break;
        }

        $rawResponse .= $line;

        if (!$headersComplete) {
          if (trim($line) === '') {
            $headersComplete = true;
          } else {
            $headers .= $line;
          }
        } else {
          $body .= $line;
        }
      }

      fclose($socket);

      // レスポンスヘッダーを解析
      $headerLines = explode("\r\n", $headers);

      // ステータスコードを取得
      $statusLine = isset($headerLines[0]) ? $headerLines[0] : '';
      $statusParts = explode(' ', $statusLine, 3);
      $this->responseCode = isset($statusParts[1]) ? (int)$statusParts[1] : 0;
      $this->info['http_code'] = $this->responseCode;

      // ヘッダーを解析
      $this->responseHeaders = [];
      $redirectUrl = '';

      foreach ($headerLines as $index => $header) {
        if ($index === 0) continue;

        if (strpos($header, ':') !== false) {
          list($name, $value) = explode(':', $header, 2);
          $name = trim($name);
          $value = trim($value);
          $this->responseHeaders[$name] = $value;

          if (strtolower($name) === 'content-type') {
            $this->info['content_type'] = $value;
          }

          // リダイレクトをチェック
          if ($this->followRedirects && 
              strtolower($name) === 'location' && 
              $this->responseCode >= 300 && 
              $this->responseCode < 400) {

            $redirectUrl = $value;

            // 相対URLを絶対URLに変換
            if (strpos($redirectUrl, 'http') !== 0) {
              if ($redirectUrl[0] === '/') {
                $redirectUrl = "{$scheme}://{$host}"
                  .($port != 80 && $port != 443 ? ":{$port}" : '').$redirectUrl;
              } else {
                $redirectUrl = "{$scheme}://{$host}"
                  .($port != 80 && $port != 443 ? ":{$port}" : '')
                  .dirname($path).'/'.$redirectUrl;
              }
            }
            
            $this->info['redirect_url'] = $redirectUrl;
          }
        }
      }

      $this->info['header_size'] += strlen($headers);
      $this->responseBody .= $body;

      if ($this->verbose && $this->stderr) {
        fwrite($this->stderr, "* レスポンスコード: {$this->responseCode}\n");
        fwrite($this->stderr, "* レスポンスヘッダー:\n{$headers}\n");
        if (!empty($redirectUrl)) {
          fwrite($this->stderr, "* リダイレクト先: {$redirectUrl}\n");
        }
      }

      // リダイレクトが必要な場合
      if (!empty($redirectUrl) && $redirectCount < $this->maxRedirects) {
        $currentUrl = $redirectUrl;
        $redirectCount++;
        $this->info['redirect_count'] = $redirectCount;

        // 302や303リダイレクトはGETにメソッドを変更
        if ($this->responseCode == 302 || $this->responseCode == 303) {
          $this->method = 'GET';
          $this->postRaw = '';
          $this->postFields = [];
        }
      } else {
        break;
      }
    } while (true);

    // リクエスト完了後、元のメソッドに戻す
    $this->method = $originalMethod;
    $this->info['total_time'] = microtime(true) - $startTime;

    return true;
  }

  /**
   * レスポンスボディを取得する
   *
   * @return string  レスポンスボディ
   */
  public function getResponseBody(): string {
    return $this->responseBody;
  }

  /**
   * レスポンスヘッダーを取得する
   *
   * @return array  レスポンスヘッダーの配列
   */
  public function getResponseHeaders(): array {
    return $this->responseHeaders;
  }

  /**
   * HTTPレスポンスコードを取得する
   *
   * @return int  HTTPレスポンスコード
   */
  public function getResponseCode(): int {
    return $this->responseCode;
  }

  /**
   * エラーメッセージがあれば取得する
   *
   * @return string  エラーメッセージ
   */
  public function getError(): string {
    return $this->responseError;
  }

  /**
   * リクエスト/レスポンス情報を取得する
   *
   * @return array  情報の配列
   */
  public function getInfo(): array {
    return $this->info;
  }

  // 機能性メソッド

  /**
   * リダイレクトURLを確認する
   * 
   * @param string $name  ヘッダー名
   * @param string $value  ヘッダー値
   * @param string $currentUrl  現在のURL
   * @return string  リダイレクトURL、リダイレクトがない場合は空文字
   */
  private function checkReds(string $name, string $value, string $currentUrl): string {
    $redirectUrl = '';

    if ($this->followRedirects && (strtolower($name) === 'location'
        && $this->responseCode >= 300 && $this->responseCode < 400)) {
        $redirectUrl = $value;

        if (strpos($redirectUrl, 'http') !== 0) {
          if ($redirectUrl[0] === '/') {
            $parsed = parse_url($currentUrl);
            $redirectUrl = $parsed['scheme'].'://'.$parsed['host']
              .(isset($parsed['port']) ? ':'.$parsed['port'] : '')
              .$redirectUrl;
          } else {
            $redirectUrl = dirname($currentUrl).'/'.$redirectUrl;
          }
        }
    }

    return $redirectUrl;
  }

  /**
   * ヘッダー文字列を構築する
   * 
   * @return string  構築されたヘッダー文字列
   */
  private function buildHeaderString(): string {
    $headers = [];

    // ユーザー指定のヘッダーを追加
    foreach ($this->headers as $name => $value) {
      $headers[] = "{$name}: {$value}";
    }

    // リファラーが設定されていれば追加
    if (!empty($this->referer) && !isset($this->headers['Referer'])) {
      $headers[] = "Referer: {$this->referer}";
    }

    // 必要に応じてクッキーヘッダーを追加
    if (!empty($this->cookies) && !isset($this->headers['Cookie'])) {
      $cookieStrings = [];
      foreach ($this->cookies as $name => $value) {
        $cookieStrings[] = $name.'='.urlencode($value);
      }

      $headers[] = 'Cookie: '.implode('; ', $cookieStrings);
    }

    return implode("\r\n", $headers)."\r\n";
  }

  /**
   * レスポンスを解析してヘッダーとボディに分割する
   *
   * @param string $response 完全なHTTPレスポンス
   * @return array [ヘッダー配列, ボディ文字列]
   */
  private function parseResponse(string $response): array {
    $parts = explode("\r\n\r\n", $response, 2);
    
    if (count($parts) < 2) {
      return [[], ''];
    }
    
    $headers = explode("\r\n", $parts[0]);
    $body = $parts[1];
    
    // チャンク転送エンコーディングを処理
    if (isset($this->responseHeaders['Transfer-Encoding']) && 
        strtolower($this->responseHeaders['Transfer-Encoding']) === 'chunked') {
      $body = $this->decodeChunkedBody($body);
    }
    
    return [$headers, $body];
  }

  /**
   * チャンク転送エンコーディングされたボディをデコードする
   *
   * @param string $body チャンクエンコードされたボディ
   * @return string デコードされたボディ
   */
  private function decodeChunkedBody(string $body): string {
    $decodedBody = '';
    $position = 0;
    
    while ($position < strlen($body)) {
      $lineEnd = strpos($body, "\r\n", $position);
      if ($lineEnd === false) {
        break;
      }
      
      $chunkSize = hexdec(substr($body, $position, $lineEnd - $position));
      
      if ($chunkSize === 0) {
        break;
      }
      
      $position = $lineEnd + 2;
      $decodedBody .= substr($body, $position, $chunkSize);
      $position += $chunkSize + 2; // チャンクサイズ + CRLF
    }
    
    return $decodedBody;
  }
}