using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

namespace DsmTool;

public sealed class DsmConfig
{
    // 제외 경로 조각(소문자 부분일치). 기본은 빌드 산출물만 — 저장소별 항목은 사용자가 추가.
    public List<string> ExcludePaths = new(DefaultExcludePaths);
    public bool ExcludeSamples = true;
    // 샘플 판정 substring(소문자) — 노드명/경로에 포함되면 샘플로 간주
    public List<string> SampleMarkers = new(DefaultSampleMarkers);
    public bool InternalAuto = true;                 // PackageReference↔프로젝트명 자동 매칭
    public Dictionary<string, string> ManualMap = new(); // 수동 pkgid→노드명(자동에 추가/보정)
    public string GroupMode = "auto";                // auto | topfolder | prefix | none

    public static readonly string[] DefaultExcludePaths =
    {
        @"\bin\", @"\obj\", @"\.vs\", @"\.git\", "_wpftmp",
        @"\packages\", @"\node_modules\", @"\testresults\",
        @"\_backup\", @"\.old\"
    };
    public static readonly string[] DefaultSampleMarkers =
    {
        "sample", "example", "demo", ".test", "\\test", "테스트", "쓰레기통", "temp"
    };

    // ---- ini 로더:  key = value  (반복 key=목록), '#'/';' 주석 ----
    public static DsmConfig Load(string? iniPath)
    {
        var c = new DsmConfig();
        if (iniPath == null || !File.Exists(iniPath)) return c;
        bool exclReset = false; var excl = new List<string>(); var samp = new List<string>();
        foreach (var raw in File.ReadAllLines(iniPath))
        {
            var line = raw.Trim();
            if (line.Length == 0 || line[0] == '#' || line[0] == ';') continue;
            var eq = line.IndexOf('=');
            if (eq <= 0) continue;
            var k = line[..eq].Trim().ToLowerInvariant();
            var v = line[(eq + 1)..].Trim();
            switch (k)
            {
                case "exclude": excl.Add(v.ToLowerInvariant()); break;
                case "excludereset": exclReset = v.Equals("true", StringComparison.OrdinalIgnoreCase); break;
                case "excludesamples": c.ExcludeSamples = v.Equals("true", StringComparison.OrdinalIgnoreCase); break;
                case "samplemarker": samp.Add(v.ToLowerInvariant()); break;
                case "group": c.GroupMode = v.ToLowerInvariant(); break;
                case "internal": c.InternalAuto = !v.Equals("none", StringComparison.OrdinalIgnoreCase); break;
                case "map":
                    var i = v.IndexOf('=');
                    if (i > 0) c.ManualMap[v[..i].Trim().ToLowerInvariant()] = v[(i + 1)..].Trim();
                    break;
            }
        }
        if (excl.Count > 0) c.ExcludePaths = exclReset ? excl : DefaultExcludePaths.Concat(excl).ToList();
        if (samp.Count > 0) c.SampleMarkers = samp;
        return c;
    }
}

public sealed class DsmResult
{
    public int Nodes, Edges, Upper, MaxFanInV, MaxFanOutV;
    public string HtmlPath = "", CsvPath = "", MaxFanIn = "", MaxFanOut = "";
}

public static class DsmEngine
{
    // ---------- helpers ----------
    private static string ReadText(string p)
    {
        var b = File.ReadAllBytes(p);
        if (b.Length >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF) return Encoding.UTF8.GetString(b, 3, b.Length - 3);
        if (b.Length >= 2 && b[0] == 0xFF && b[1] == 0xFE) return Encoding.Unicode.GetString(b, 2, b.Length - 2);
        if (b.Length >= 2 && b[0] == 0xFE && b[1] == 0xFF) return Encoding.BigEndianUnicode.GetString(b, 2, b.Length - 2);
        try { return new UTF8Encoding(false, true).GetString(b); }
        catch { return Encoding.Latin1.GetString(b); }
    }

    private static readonly Regex VerToken = new(@"[_\.]?\d+\.\d+[_\.]?\d*", RegexOptions.Compiled);
    private static string Norm(string n) => VerToken.Replace(n.ToLowerInvariant(), "").Trim('.', '_', ' ');

    private static readonly Regex WordPrefix = new(@"^[A-Za-z][a-z]+", RegexOptions.Compiled);
    private static string NamePrefix(string baseName)
    {
        var m = WordPrefix.Match(baseName);
        if (m.Success && m.Length >= 2) return m.Value;
        // 대문자 약어/기타: 첫 숫자·구분자 전까지, 최대 10자
        var s = new string(baseName.TakeWhile(ch => char.IsLetter(ch)).ToArray());
        return s.Length == 0 ? "기타" : (s.Length > 10 ? s[..10] : s);
    }

    // ---------- main ----------
    public static DsmResult Run(string root, string outDir, DsmConfig cfg, Action<string> log)
    {
        root = Path.GetFullPath(root);
        var excl = cfg.ExcludePaths;
        var eo = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true };
        var files = new List<string>();
        foreach (var pat in new[] { "*.csproj", "*.vcxproj" })
            foreach (var f in Directory.EnumerateFiles(root, pat, eo))
            {
                var fl = f.ToLowerInvariant();
                if (!excl.Any(x => fl.Contains(x))) files.Add(Path.GetFullPath(f));
            }
        files = files.Distinct(StringComparer.OrdinalIgnoreCase)
                     .OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
        log($"프로젝트 파일 {files.Count}개 (제외 규칙 적용 후)");

        // 노드명: 기본은 파일명. 동일 파일명 충돌 시 상대 디렉터리로 자동 구분.
        var baseName = files.ToDictionary(f => f, f => Path.GetFileNameWithoutExtension(f), StringComparer.OrdinalIgnoreCase);
        var dupBases = baseName.Values.GroupBy(x => x, StringComparer.OrdinalIgnoreCase)
                               .Where(g => g.Count() > 1).Select(g => g.Key)
                               .ToHashSet(StringComparer.OrdinalIgnoreCase);
        var path2node = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach (var f in files)
        {
            var b = baseName[f];
            if (dupBases.Contains(b))
            {
                var relDir = Path.GetDirectoryName(Path.GetRelativePath(root, f)) ?? "";
                path2node[f] = $"{b} ({relDir.Replace('\\', '/')})";
            }
            else path2node[f] = b;
        }
        var nodeBase = path2node.ToDictionary(kv => kv.Value, kv => baseName[kv.Key]); // 노드→원래파일명

        // 이름 매칭용 인덱스 (내부 Reference / PackageReference)
        var byExact = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
        var byNorm = new Dictionary<string, List<string>>();
        foreach (var kv in path2node)
        {
            var b = baseName[kv.Key];
            (byExact.TryGetValue(b, out var le) ? le : byExact[b] = new()).Add(kv.Value);
            var nk = Norm(b);
            (byNorm.TryGetValue(nk, out var ln) ? ln : byNorm[nk] = new()).Add(kv.Value);
        }
        // 프로젝트가 선언한 PackageId 도 매칭 후보에 포함
        var rePkgId = new Regex(@"<PackageId>\s*([^<]+?)\s*</PackageId>", RegexOptions.IgnoreCase);

        var edges = new Dictionary<string, HashSet<string>>();
        foreach (var n in path2node.Values) edges[n] = new HashSet<string>();

        var reProj = new Regex("ProjectReference\\s+Include\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase);
        var reRef = new Regex("<Reference\\s+Include\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase);
        var rePkg = new Regex("PackageReference\\s+Include\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase);

        // PackageId 선언 수집
        foreach (var f in files)
        {
            string txt; try { txt = ReadText(f); } catch { continue; }
            var pm = rePkgId.Match(txt);
            if (pm.Success)
            {
                var id = pm.Groups[1].Value.Trim();
                (byExact.TryGetValue(id, out var l) ? l : byExact[id] = new()).Add(path2node[f]);
            }
        }

        void LinkPackage(string src, string id)
        {
            id = id.Trim();
            if (cfg.ManualMap.TryGetValue(id.ToLowerInvariant(), out var forced))
            { if (edges.ContainsKey(forced) && forced != src) edges[src].Add(forced); return; }
            if (!cfg.InternalAuto) return;
            if (byExact.TryGetValue(id, out var ex)) { foreach (var t in ex) if (t != src) edges[src].Add(t); return; }
            if (byNorm.TryGetValue(Norm(id), out var nm) && nm.Count == 1 && nm[0] != src) edges[src].Add(nm[0]);
            // 정규화 매칭이 다중이면 모호 → 링크 생략(오탐 방지)
        }

        foreach (var f in files)
        {
            var src = path2node[f];
            var dir = Path.GetDirectoryName(f)!;
            string txt; try { txt = ReadText(f); } catch { continue; }

            foreach (Match m in reProj.Matches(txt))
            {
                string tgt; try { tgt = Path.GetFullPath(Path.Combine(dir, m.Groups[1].Value.Replace('\\', '/'))); } catch { continue; }
                if (path2node.TryGetValue(tgt, out var tn) && tn != src) edges[src].Add(tn);
            }
            foreach (Match m in reRef.Matches(txt))
            {
                var inc = m.Groups[1].Value.Split(',')[0].Trim();
                if (byExact.TryGetValue(inc, out var ex)) { foreach (var t in ex) if (t != src) edges[src].Add(t); }
                else if (byNorm.TryGetValue(Norm(inc), out var nm) && nm.Count == 1 && nm[0] != src) edges[src].Add(nm[0]);
            }
            foreach (Match m in rePkg.Matches(txt)) LinkPackage(src, m.Groups[1].Value);
        }

        // 샘플 제외
        var nodes = new HashSet<string>(path2node.Values);
        if (cfg.ExcludeSamples)
        {
            var markers = cfg.SampleMarkers;
            bool IsSample(string node)
            {
                var f = path2node.First(kv => kv.Value == node).Key.ToLowerInvariant();
                var nl = node.ToLowerInvariant();
                return markers.Any(mk => f.Contains(mk) || nl.Contains(mk));
            }
            nodes = new HashSet<string>(nodes.Where(n => !IsSample(n)));
        }
        var e2 = new Dictionary<string, HashSet<string>>();
        foreach (var n in nodes) e2[n] = new HashSet<string>(edges[n].Where(nodes.Contains));
        edges = e2;

        // 위상 정렬(의존 대상 먼저)
        var order = new List<string>(); var seen = new HashSet<string>();
        void Visit(string n, HashSet<string> stk)
        {
            if (seen.Contains(n) || stk.Contains(n)) return;
            stk.Add(n);
            foreach (var t in edges[n].OrderBy(x => x, StringComparer.Ordinal)) Visit(t, stk);
            stk.Remove(n); seen.Add(n); order.Add(n);
        }
        foreach (var n in nodes.OrderBy(x => edges[x].Count > 0).ThenBy(x => x, StringComparer.Ordinal)) Visit(n, new HashSet<string>());
        var idx = new Dictionary<string, int>();
        for (int i = 0; i < order.Count; i++) idx[order[i]] = i;
        int N = order.Count;

        var fout = order.ToDictionary(n => n, n => edges[n].Count);
        var fin = order.ToDictionary(n => n, _ => 0);
        foreach (var n in order) foreach (var t in edges[n]) fin[t]++;
        int upper = order.Sum(a => edges[a].Count(b => idx[b] > idx[a]));

        // 그룹핑
        string TopFolder(string node)
        {
            var f = path2node.First(kv => kv.Value == node).Key;
            var rel = Path.GetRelativePath(root, f).Replace('/', '\\').Split('\\');
            return rel.Length > 1 ? rel[0] : "(root)";
        }
        var mode = cfg.GroupMode;
        if (mode == "auto")
        {
            var tf = order.Select(TopFolder).Distinct().Count();
            mode = (N > 0 && tf > Math.Max(8, N * 0.5)) ? "prefix" : "topfolder";
            log($"그룹 모드 auto → {mode} (최상위폴더 {tf}종)");
        }
        Func<string, string> groupOf = mode switch
        {
            "none" => _ => "all",
            "prefix" => n => NamePrefix(nodeBase[n]),
            _ => TopFolder,
        };
        var group = order.ToDictionary(n => n, groupOf);
        var gnames = group.Values.Distinct().OrderBy(x => x, StringComparer.Ordinal).ToList();
        string[] pal = { "#2ea043", "#1f6feb", "#8957e5", "#d29922", "#0969da", "#e36209", "#1a7f37", "#cf222e", "#6e7781", "#bf8700", "#a371f7", "#bc4c00", "#3fb950", "#db61a2", "#c9510c", "#0550ae" };
        var gcol = new Dictionary<string, string>();
        for (int i = 0; i < gnames.Count; i++) gcol[gnames[i]] = pal[i % pal.Length];

        Directory.CreateDirectory(outDir);

        // 출력 파일명: {분석폴더명}_DependencyMetrix
        var folder = new DirectoryInfo(root).Name;
        if (string.IsNullOrWhiteSpace(folder)) folder = "root";
        foreach (var ch in Path.GetInvalidFileNameChars()) folder = folder.Replace(ch, '_');
        var stem = folder + "_DependencyMetrix";

        // CSV
        var sb = new StringBuilder();
        sb.Append("idx,node,group,").Append(string.Join(",", Enumerable.Range(1, N))).Append('\n');
        for (int i = 0; i < N; i++)
        {
            var n = order[i];
            sb.Append(i + 1).Append(",\"").Append(n.Replace("\"", "\"\"")).Append("\",\"").Append(group[n].Replace("\"", "\"\"")).Append('"');
            for (int j = 0; j < N; j++) { sb.Append(','); sb.Append(i == j ? "\\" : (edges[n].Contains(order[j]) ? "1" : "")); }
            sb.Append('\n');
        }
        var csvPath = Path.Combine(outDir, stem + ".csv");
        File.WriteAllText(csvPath, sb.ToString(), new UTF8Encoding(false));

        var htmlPath = Path.Combine(outDir, stem + ".html");
        File.WriteAllText(htmlPath, BuildHtml(order, edges, group, gcol, gnames, fin, fout, root, N, upper), new UTF8Encoding(false));

        string maxFi = N > 0 ? order.OrderByDescending(n => fin[n]).First() : "";
        string maxFo = N > 0 ? order.OrderByDescending(n => fout[n]).First() : "";
        return new DsmResult
        {
            Nodes = N,
            Edges = fout.Values.Sum(),
            Upper = upper,
            HtmlPath = htmlPath,
            CsvPath = csvPath,
            MaxFanIn = maxFi,
            MaxFanInV = maxFi != "" ? fin[maxFi] : 0,
            MaxFanOut = maxFo,
            MaxFanOutV = maxFo != "" ? fout[maxFo] : 0
        };
    }

    // ---------- output builders ----------
    private static string HtmlEsc(string s) => s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");

    private static string JStr(string s)
    {
        var sb = new StringBuilder("\"");
        foreach (char c in s)
        {
            if (c == '\\' || c == '"') { sb.Append('\\'); sb.Append(c); }
            else if (c == '\n') sb.Append("\\n");
            else if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4"));
            else sb.Append(c);
        }
        return sb.Append('"').ToString();
    }
    private static string JArr(IEnumerable<string> xs) => "[" + string.Join(",", xs.Select(JStr)) + "]";
    private static string JArrI(IEnumerable<int> xs) => "[" + string.Join(",", xs) + "]";
    private static string JDict(Dictionary<string, string> d) => "{" + string.Join(",", d.Select(kv => JStr(kv.Key) + ":" + JStr(kv.Value))) + "}";

    private static string BuildHtml(
        List<string> order, Dictionary<string, HashSet<string>> edges,
        Dictionary<string, string> group, Dictionary<string, string> gcol, List<string> gnames,
        Dictionary<string, int> fin, Dictionary<string, int> fout, string root, int N, int upper)
    {
        var mat = new StringBuilder("[");
        for (int i = 0; i < N; i++)
        {
            if (i > 0) mat.Append(',');
            mat.Append('[');
            for (int j = 0; j < N; j++)
            {
                if (j > 0) mat.Append(',');
                mat.Append(i != j && edges[order[i]].Contains(order[j]) ? '1' : '0');
            }
            mat.Append(']');
        }
        mat.Append(']');

        var leg = new StringBuilder();
        foreach (var g in gnames)
            leg.Append("<span class='chip'><i style='background:").Append(gcol[g]).Append("'></i>").Append(HtmlEsc(g)).Append("</span>");

        int maxFi = N > 0 ? order.Max(n => fin[n]) : 0;
        int maxFo = N > 0 ? order.Max(n => fout[n]) : 0;

        return Template
            .Replace("__ROOT__", HtmlEsc(root))
            .Replace("__N__", N.ToString())
            .Replace("__EDGES__", order.Sum(n => edges[n].Count).ToString())
            .Replace("__UPPER__", upper.ToString())
            .Replace("__MAXFI__", maxFi.ToString())
            .Replace("__MAXFO__", maxFo.ToString())
            .Replace("__LEGEND__", leg.ToString())
            .Replace("__R__", JArr(order))
            .Replace("__G__", JArr(order.Select(n => group.GetValueOrDefault(n, ""))))
            .Replace("__M__", mat.ToString())
            .Replace("__FI__", JArrI(order.Select(n => fin[n])))
            .Replace("__FO__", JArrI(order.Select(n => fout[n])))
            .Replace("__GC__", JDict(gcol));
    }

    private const string Template = @"<!doctype html><html lang='ko'><head><meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'><title>의존성 매트릭스 (DSM)</title>
<style>
:root{--bg:#fff;--fg:#1f2328;--mut:#57606a;--line:#d0d7de;--diag:#57606a;--hl:#fff3cd;--panel:#f6f8fa;--dep:#0969da}
@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--mut:#8b949e;--line:#30363d;--diag:#484f58;--hl:#3d3417;--panel:#161b22;--dep:#58a6ff}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 'Segoe UI','Malgun Gothic',sans-serif}
.wrap{max-width:1600px;margin:0 auto;padding:24px}h1{font-size:20px;margin:0 0 4px}.sub{color:var(--mut);font-size:12.5px;margin:0 0 14px}
.stats{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}.stat{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 12px}
.stat b{font-size:17px}.stat span{color:var(--mut);font-size:11.5px;display:block}
.legend{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 16px}.chip{display:inline-flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:20px;padding:3px 10px;font-size:11.5px}.chip i{width:10px;height:10px;border-radius:2px}
.scroll{overflow:auto;border:1px solid var(--line);border-radius:8px;max-height:88vh}table{border-collapse:collapse;font-size:11px}th,td{padding:0;text-align:center}
td.cell{width:15px;height:15px;min-width:15px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}td.dep{background:var(--dep)}td.diag{background:var(--diag)}
th.col{height:170px;width:15px;min-width:15px;position:sticky;top:0;background:var(--bg);z-index:2;border-bottom:1px solid var(--line)}th.col div{transform:rotate(-90deg);white-space:nowrap;width:15px;font-size:9.5px;color:var(--mut)}
th.corner{position:sticky;top:0;left:0;z-index:4;background:var(--bg)}
th.row{position:sticky;left:0;background:var(--bg);z-index:1;text-align:left;white-space:nowrap;padding:0 8px;height:15px;border-bottom:1px solid var(--line);border-right:2px solid var(--line);font-weight:400;max-width:340px;overflow:hidden;text-overflow:ellipsis}
th.row .gi{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px}th.row .ix{color:var(--mut);display:inline-block;width:30px;text-align:right;margin-right:6px}
tr.hl th.row,tr.hl td.cell{background:var(--hl)}td.cell.hlc{background:var(--hl)}td.dep.hlc{background:var(--dep)}.fan{color:var(--mut);font-size:9.5px;margin-left:6px}
.note{color:var(--mut);font-size:12px;margin-top:14px;max-width:900px}.note b{color:var(--fg)}
</style></head><body><div class='wrap'>
<h1>의존성 매트릭스 (DSM)</h1>
<p class='sub'>root <code>__ROOT__</code> · 행 → 열(행이 열에 의존) · 위상 정렬 · ProjectReference/내부 Reference/내부 NuGet 자동 파싱</p>
<div class='stats'>
 <div class='stat'><b>__N__</b><span>프로젝트</span></div><div class='stat'><b>__EDGES__</b><span>내부 간선</span></div>
 <div class='stat'><b>__UPPER__</b><span>순환(상삼각) 위반</span></div>
 <div class='stat'><b>__MAXFI__</b><span>최대 Fan-in</span></div><div class='stat'><b>__MAXFO__</b><span>최대 Fan-out</span></div>
</div><div class='legend'>__LEGEND__</div>
<div class='scroll'><table id='dsm'></table></div>
<p class='note'><b>읽는 법</b> · 파란 칸 = 행 프로젝트가 열 프로젝트에 의존. 대각선 = 자기 자신. 행/열 hover 시 교차 강조. 표식이 모두 대각선 아래면(상삼각 위반 0) 순환 없음 → 위→아래가 빌드 순서.</p></div>
<script>
const R=__R__,G=__G__,M=__M__,FI=__FI__,FO=__FO__,GC=__GC__;
const N=R.length,t=document.getElementById('dsm');let h='<tr><th class=corner></th>';
for(let j=0;j<N;j++)h+=`<th class='col'><div>${j+1}. ${R[j].replace(/</g,'&lt;')}</div></th>`;h+='</tr>';
for(let i=0;i<N;i++){h+=`<tr data-r='${i}'><th class='row'><span class=ix>${i+1}</span><span class=gi style='background:${GC[G[i]]}'></span>${R[i].replace(/</g,'&lt;')}<span class=fan>${FO[i]}/${FI[i]}</span></th>`;
 for(let j=0;j<N;j++){h+=i===j?`<td class='cell diag'></td>`:`<td class='cell${M[i][j]?' dep':''}' data-r='${i}' data-c='${j}'></td>`;}h+='</tr>';}
t.innerHTML=h;
t.addEventListener('mouseover',e=>{const c=e.target.closest('td.cell,th.col,th.row');clr();if(!c)return;const r=c.dataset.r,col=c.dataset.c;
 if(r!=null){t.querySelectorAll(`td.cell[data-r='${r}']`).forEach(x=>x.classList.add('hlc'));t.querySelector(`tr[data-r='${r}']`)?.classList.add('hl');}
 if(col!=null)t.querySelectorAll(`td[data-c='${col}']`).forEach(x=>x.classList.add('hlc'));});
function clr(){t.querySelectorAll('.hl').forEach(x=>x.classList.remove('hl'));t.querySelectorAll('.hlc').forEach(x=>x.classList.remove('hlc'));}
</script></body></html>";
}
