using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;

namespace Hc900Crawler.Infrastructure.Docs;

/// <summary>디렉토리/파일 한 항목.</summary>
public sealed record DocEntry(string Name, string RelPath, bool IsDir, long Size, DateTime ModifiedUtc, string Ext);

/// <summary>텍스트 읽기 결과.</summary>
public sealed record DocTextResult(string Text, bool Truncated, long Size, string Ext);

/// <summary>원본 스트림 + 메타.</summary>
public sealed record DocRawResult(Stream Stream, string ContentType, string FileName, string Ext);

/// <summary>경로 이탈·제외·미존재 등 안전 위반.</summary>
public sealed class DocBrowserException : Exception
{
    public DocBrowserException(string message) : base(message) { }
}

/// <summary>
/// 프로젝트 폴더 트리를 안전하게 탐색/편집하기 위한 서비스.
/// 모든 경로는 루트 기준 상대경로로만 다루며, 루트 이탈·제외 디렉토리·민감 파일은 차단한다.
/// 루트는 DocBrowser:Root 설정 또는 (.git/*.sln 자동탐색)으로 결정. 소스 트리가 있는 개발 환경 전용.
/// </summary>
public sealed class DocBrowserService
{
    private readonly string _root;
    private readonly long _maxTextBytes;
    private readonly long _maxUploadBytes;

    // 트리에서 통째로 감출 디렉토리 이름 (대소문자 무시)
    private static readonly HashSet<string> ExcludedDirs = new(StringComparer.OrdinalIgnoreCase)
    {
        ".git", ".hg", ".svn", "bin", "obj", "node_modules", ".vs", ".vscode", ".idea",
        ".venv", "venv", "env", "__pycache__", ".pytest_cache", ".mypy_cache",
        "dist", "build", "packages", "TestResults", "storage", "data", ".claude",
    };

    // 숨기고 읽기/서빙/편집 모두 차단할 민감 파일 패턴
    private static readonly string[] SensitiveGlobs =
    {
        "*.pfx", "*.p12", "*.pem", "*.key", "*.crt", "*.cer",
        ".env", ".env.*", "appsettings*.json", "secrets.json", "*.user", "*.suo",
        "id_rsa", "id_rsa.*", "*.pyc",
    };

    public DocBrowserService(IConfiguration config)
    {
        _root = ResolveRoot(config["DocBrowser:Root"]);
        _maxTextBytes = ParseLong(config["DocBrowser:MaxTextBytes"], 2L * 1024 * 1024);   // 2 MB
        _maxUploadBytes = ParseLong(config["DocBrowser:MaxUploadBytes"], 50L * 1024 * 1024); // 50 MB
    }

    public string Root => _root;
    public long MaxTextBytes => _maxTextBytes;
    public long MaxUploadBytes => _maxUploadBytes;

    // ── 루트 결정 ───────────────────────────────────────────────
    private static string ResolveRoot(string? configured)
    {
        if (!string.IsNullOrWhiteSpace(configured))
        {
            var p = Path.IsPathRooted(configured)
                ? configured
                : Path.Combine(Directory.GetCurrentDirectory(), configured);
            return Path.TrimEndingDirectorySeparator(Path.GetFullPath(p));
        }

        // 자동: 현재 디렉토리에서 위로 올라가며 .git 또는 *.sln 보유 디렉토리를 루트로
        var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
        while (dir != null)
        {
            if (Directory.Exists(Path.Combine(dir.FullName, ".git")) || dir.GetFiles("*.sln").Length > 0)
                return Path.TrimEndingDirectorySeparator(dir.FullName);
            dir = dir.Parent;
        }
        return Path.TrimEndingDirectorySeparator(Directory.GetCurrentDirectory());
    }

    private static long ParseLong(string? s, long fallback)
        => long.TryParse(s, out var v) && v > 0 ? v : fallback;

    // ── 경로 안전 ───────────────────────────────────────────────

    /// <summary>상대경로를 절대경로로 변환. 루트 이탈·제외 디렉토리·민감 파일이면 예외.</summary>
    public string SafeResolve(string? relPath, bool mustExist = false)
    {
        var rel = (relPath ?? "").Replace('\\', '/').Trim();
        if (rel.Contains('\0')) throw new DocBrowserException("잘못된 경로");
        rel = rel.TrimStart('/');
        if (rel is "." or "./") rel = "";

        var candidate = Path.GetFullPath(Path.Combine(_root, rel));
        candidate = Path.TrimEndingDirectorySeparator(candidate);

        if (!IsWithinRoot(candidate))
            throw new DocBrowserException("루트 밖 경로 접근 차단");

        // 심볼릭 링크가 루트 밖을 가리키면 차단 (존재할 때만 검사)
        var real = ResolveFinalTarget(candidate);
        if (real != null && !IsWithinRoot(real))
            throw new DocBrowserException("루트 밖 링크 접근 차단");

        // 루트~대상 사이 모든 세그먼트 검사
        var relToRoot = Path.GetRelativePath(_root, candidate);
        if (relToRoot != ".")
        {
            var segs = relToRoot.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
            for (int i = 0; i < segs.Length; i++)
            {
                var seg = segs[i];
                bool isLast = i == segs.Length - 1;
                // 마지막이 아니면 반드시 디렉토리 → 디렉토리 제외 검사
                if (!isLast && ExcludedDirs.Contains(seg))
                    throw new DocBrowserException($"제외된 디렉토리: {seg}");
                // 마지막 세그먼트: 디렉토리면 dir-제외, 파일이면 민감 검사
                if (isLast)
                {
                    if (ExcludedDirs.Contains(seg) && Directory.Exists(candidate))
                        throw new DocBrowserException($"제외된 디렉토리: {seg}");
                    if (IsSensitiveFile(seg))
                        throw new DocBrowserException("민감 파일 접근 차단");
                }
            }
        }

        if (mustExist && !File.Exists(candidate) && !Directory.Exists(candidate))
            throw new DocBrowserException("대상이 존재하지 않습니다");

        return candidate;
    }

    private bool IsWithinRoot(string absPath)
    {
        if (string.Equals(absPath, _root, PathComparison)) return true;
        return absPath.StartsWith(_root + Path.DirectorySeparatorChar, PathComparison);
    }

    private static string? ResolveFinalTarget(string path)
    {
        try
        {
            FileSystemInfo? info = Directory.Exists(path) ? new DirectoryInfo(path)
                                 : File.Exists(path) ? new FileInfo(path) : null;
            if (info == null) return null;
            var target = info.ResolveLinkTarget(returnFinalTarget: true);
            return target == null ? null : Path.TrimEndingDirectorySeparator(target.FullName);
        }
        catch { return null; }
    }

    private static StringComparison PathComparison =>
        OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;

    public bool IsExcludedDir(string name) => ExcludedDirs.Contains(name);

    public bool IsSensitiveFile(string name)
    {
        foreach (var g in SensitiveGlobs)
            if (WildcardMatch(name, g)) return true;
        return false;
    }

    private static bool WildcardMatch(string name, string pattern)
    {
        var rx = "^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$";
        return Regex.IsMatch(name, rx, RegexOptions.IgnoreCase);
    }

    /// <summary>절대경로 → 루트 기준 상대경로(슬래시). 루트 자신은 "".</summary>
    public string ToRel(string absPath)
    {
        var rel = Path.GetRelativePath(_root, absPath);
        return rel == "." ? "" : rel.Replace('\\', '/');
    }

    // ── 목록 ────────────────────────────────────────────────────

    public List<DocEntry> List(string? relPath)
    {
        var abs = SafeResolve(relPath, mustExist: true);
        if (!Directory.Exists(abs)) throw new DocBrowserException("디렉토리가 아닙니다");

        var result = new List<DocEntry>();
        var di = new DirectoryInfo(abs);

        foreach (var sub in di.EnumerateDirectories())
        {
            if (ExcludedDirs.Contains(sub.Name)) continue;
            if ((sub.Attributes & FileAttributes.System) != 0) continue;
            result.Add(new DocEntry(sub.Name, ToRel(sub.FullName), true, 0, sub.LastWriteTimeUtc, ""));
        }
        foreach (var f in di.EnumerateFiles())
        {
            if (IsSensitiveFile(f.Name)) continue;
            if ((f.Attributes & FileAttributes.System) != 0) continue;
            result.Add(new DocEntry(f.Name, ToRel(f.FullName), false, f.Length,
                f.LastWriteTimeUtc, f.Extension.TrimStart('.').ToLowerInvariant()));
        }

        return result
            .OrderByDescending(e => e.IsDir)
            .ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
            .ToList();
    }

    // ── 텍스트 읽기 ─────────────────────────────────────────────

    public DocTextResult ReadText(string? relPath)
    {
        var abs = SafeResolve(relPath, mustExist: true);
        if (!File.Exists(abs)) throw new DocBrowserException("파일이 아닙니다");

        var fi = new FileInfo(abs);
        var cap = (int)Math.Min(fi.Length, _maxTextBytes);
        var buf = new byte[cap];
        int total = 0;
        using (var fs = fi.OpenRead())
        {
            int r;
            while (total < cap && (r = fs.Read(buf, total, cap - total)) > 0) total += r;
        }

        // 바이너리 휴리스틱: 앞부분에 NUL 이 있으면 텍스트 아님
        int scan = Math.Min(total, 8000);
        for (int i = 0; i < scan; i++)
            if (buf[i] == 0) throw new DocBrowserException("바이너리 파일은 텍스트로 볼 수 없습니다");

        var text = DecodeUtf8(buf, total);
        return new DocTextResult(text, fi.Length > _maxTextBytes, fi.Length,
            fi.Extension.TrimStart('.').ToLowerInvariant());
    }

    private static string DecodeUtf8(byte[] buf, int len)
    {
        // UTF-8 BOM 제거
        int start = (len >= 3 && buf[0] == 0xEF && buf[1] == 0xBB && buf[2] == 0xBF) ? 3 : 0;
        return new UTF8Encoding(false, false).GetString(buf, start, len - start);
    }

    // ── 원본 스트림 ─────────────────────────────────────────────

    public DocRawResult OpenRaw(string? relPath)
    {
        var abs = SafeResolve(relPath, mustExist: true);
        if (!File.Exists(abs)) throw new DocBrowserException("파일이 아닙니다");
        var ext = Path.GetExtension(abs).TrimStart('.').ToLowerInvariant();
        var stream = new FileStream(abs, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, true);
        return new DocRawResult(stream, ContentType(ext), Path.GetFileName(abs), ext);
    }

    public static string ContentType(string ext) => ext switch
    {
        "pdf" => "application/pdf",
        "txt" or "log" or "md" or "markdown" or "csv" or "ini" or "conf" or "cfg" => "text/plain; charset=utf-8",
        "json" => "application/json; charset=utf-8",
        "xml" => "application/xml; charset=utf-8",
        "html" or "htm" => "text/html; charset=utf-8",
        "svg" => "image/svg+xml",
        "png" => "image/png",
        "jpg" or "jpeg" => "image/jpeg",
        "gif" => "image/gif",
        "webp" => "image/webp",
        _ => "application/octet-stream",
    };

    // ── 변경 작업 (admin) ──────────────────────────────────────

    public void WriteText(string? relPath, string content)
    {
        var abs = SafeResolve(relPath);
        if (Directory.Exists(abs)) throw new DocBrowserException("디렉토리에는 쓸 수 없습니다");
        var bytes = new UTF8Encoding(false).GetBytes(content ?? "");
        if (bytes.LongLength > _maxTextBytes) throw new DocBrowserException("저장 한도를 초과했습니다");
        var parent = Path.GetDirectoryName(abs);
        if (parent == null || !Directory.Exists(parent)) throw new DocBrowserException("상위 폴더가 없습니다");
        File.WriteAllBytes(abs, bytes);
    }

    public string Rename(string? fromRel, string? toRel)
    {
        var from = SafeResolve(fromRel, mustExist: true);
        var to = SafeResolve(toRel);
        if (File.Exists(to) || Directory.Exists(to)) throw new DocBrowserException("대상이 이미 존재합니다");
        var parent = Path.GetDirectoryName(to);
        if (parent == null || !Directory.Exists(parent)) throw new DocBrowserException("대상 상위 폴더가 없습니다");

        if (Directory.Exists(from)) Directory.Move(from, to);
        else if (File.Exists(from)) File.Move(from, to);
        else throw new DocBrowserException("원본이 없습니다");
        return ToRel(to);
    }

    public void Delete(string? relPath, bool recursive)
    {
        var abs = SafeResolve(relPath, mustExist: true);
        if (string.Equals(abs, _root, PathComparison)) throw new DocBrowserException("루트는 삭제할 수 없습니다");

        if (Directory.Exists(abs))
        {
            if (!recursive && Directory.EnumerateFileSystemEntries(abs).Any())
                throw new DocBrowserException("폴더가 비어있지 않습니다 (recursive 필요)");
            Directory.Delete(abs, recursive);
        }
        else if (File.Exists(abs))
        {
            File.Delete(abs);
        }
    }

    public string MakeDir(string? relPath)
    {
        var abs = SafeResolve(relPath);
        if (File.Exists(abs)) throw new DocBrowserException("같은 이름의 파일이 있습니다");
        Directory.CreateDirectory(abs);
        return ToRel(abs);
    }

    public async Task<string> SaveUploadAsync(string? dirRel, string fileName, Stream input, CancellationToken ct)
    {
        var safeName = Path.GetFileName(fileName ?? "");
        if (string.IsNullOrWhiteSpace(safeName)) throw new DocBrowserException("파일명이 필요합니다");
        if (IsSensitiveFile(safeName)) throw new DocBrowserException("이 형식은 업로드할 수 없습니다");

        var dirAbs = SafeResolve(dirRel, mustExist: true);
        if (!Directory.Exists(dirAbs)) throw new DocBrowserException("대상 폴더가 없습니다");

        var abs = SafeResolve(Path.Combine(ToRel(dirAbs), safeName));
        if (File.Exists(abs)) throw new DocBrowserException("같은 이름의 파일이 이미 있습니다");

        using var fs = new FileStream(abs, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, true);
        var buffer = new byte[64 * 1024];
        long total = 0;
        int read;
        while ((read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), ct)) > 0)
        {
            total += read;
            if (total > _maxUploadBytes)
            {
                fs.Close();
                try { File.Delete(abs); } catch { }
                throw new DocBrowserException("업로드 한도를 초과했습니다");
            }
            await fs.WriteAsync(buffer.AsMemory(0, read), ct);
        }
        return ToRel(abs);
    }
}
